mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-05-18 11:07:36 -03:00
Compare commits
29 Commits
0ffee3a854
...
v1.0.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94e1a8ac7b | ||
|
|
cc20d3b992 | ||
|
|
a74cbe7aa2 | ||
|
|
94edfaa190 | ||
|
|
31c54ff068 | ||
|
|
21872a8e9e | ||
|
|
612612f1c7 | ||
|
|
ff240db5b1 | ||
|
|
bcfed4b874 | ||
|
|
1352c6ecbe | ||
|
|
30b01b8a92 | ||
|
|
a105cb322b | ||
|
|
3bf396d003 | ||
|
|
60cfb3b8e0 | ||
|
|
6763abb83c | ||
|
|
5c53968caa | ||
|
|
b4f7dd75af | ||
|
|
86118d0654 | ||
|
|
df1410535e | ||
|
|
75f74d54d8 | ||
|
|
ab6100f596 | ||
|
|
5d3ab3bbf8 | ||
|
|
d9dc0dba8d | ||
|
|
3631c5eb10 | ||
|
|
6d5b4b7312 | ||
|
|
7803bd542d | ||
|
|
f0a86dbbc0 | ||
|
|
682e964f89 | ||
|
|
908464bc0a |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -15,6 +15,7 @@ model_cache/
|
||||
# agent
|
||||
.opencode/
|
||||
.claude/
|
||||
.sisyphus/
|
||||
.codex
|
||||
|
||||
# Vue widgets development cache (but keep build output)
|
||||
|
||||
130
README.md
130
README.md
@@ -54,137 +54,7 @@ Insomnia Art Designs, megakirbs, Brennok, 2018cfh, W+K+White, wackop, Phil, Carl
|
||||
|
||||
<!-- SUPPORTERS-END -->
|
||||
|
||||
## Release Notes
|
||||
|
||||
### v1.0.5
|
||||
|
||||
* **Excluded Models Management View** - Added a new global-menu view for excluded models, with actions to restore them or delete them permanently.
|
||||
* **Fix for `401 Unauthorized` Downloads** - Fixed an issue where some `civitai.red` downloads could lose authentication during redirect and fail with `401 Unauthorized`.
|
||||
|
||||
### v1.0.4
|
||||
|
||||
* **Civitai Domain Split Support** - Added support for `civitai.com` and `civitai.red` model URLs and recipe/image URLs across import, analysis, and download flows.
|
||||
* **Civitai API Host Migration** - Updated core Civitai API requests to use `civitai.red` for compatibility with Civitai's current API host.
|
||||
* **Configurable Civitai View Host** - Added a setting to choose which Civitai site opens by default for model, search, and view links.
|
||||
* **401 Unauthorized Reminder** - Some users have reported `401 Unauthorized` errors. If you run into this, try generating a new API key on `civitai.red` and updating it in LoRA Manager settings.
|
||||
|
||||
### v1.0.3
|
||||
|
||||
* **Custom Recipe Storage Path** - Added support for configuring a custom storage path for recipes, with migration support to move existing recipe data when changing locations.
|
||||
* **Wildcard Support for LM Text/Prompt Nodes** - The LM `Text` node and `Prompt` node now support the new `/wildcard` command, with runtime wildcard expansion and support for dynamic prompt syntax for more flexible prompt construction.
|
||||
* **System Diagnostics ("Doctor")** - Added a new diagnostics feature to help surface environment and setup issues more clearly.
|
||||
* **User-State Backup Support** - Added backup support for user state, with accompanying UI and clearer backup scope messaging in Settings.
|
||||
* **Downloaded Status Visibility** - Added clearer downloaded-status UX so previously downloaded model versions are easier to recognize.
|
||||
* **Autocomplete Performance Improvements** - Fixed autocomplete performance issues to reduce tag-search overhead and improve responsiveness.
|
||||
|
||||
### v1.0.2
|
||||
|
||||
* **Model Download History Tracking** - LoRA Manager now keeps a history of downloaded model versions, allowing it to recognize whether a version has been downloaded before, even if it is no longer currently present in your library.
|
||||
* **Skip Previously Downloaded Model Versions** - Added a new setting, `Skip previously downloaded model versions`, to help avoid downloading model versions you have already downloaded in the past.
|
||||
* **LoRA Stack Combiner Trigger Words Fix** - Fixed an issue where trigger word updates from `LORA_STACK` inputs were not propagated correctly through the LoRA Stack Combiner node.
|
||||
* **CivitAI Example Image Compatibility** - Improved support for CivitAI CDN subdomains so example images load more reliably.
|
||||
|
||||
### v1.0.1
|
||||
|
||||
* **Batch Recipe Import** - Import recipes from multiple URLs or directories simultaneously with optimized concurrency.
|
||||
* **Bulk Download Missing LoRAs** - New bulk action for recipes: select multiple recipes and download all missing LoRAs for the selected recipes in one operation.
|
||||
* **Import-Only Recipe Option** - Save recipe metadata without downloading missing LoRAs, allowing you to save interesting recipes for later and download dependencies when needed.
|
||||
* **Editable Recipe Prompts** - Edit recipe prompts directly in the recipe detail modal.
|
||||
* **Checkpoint Loader LM Node** - Behaves like ComfyUI's built-in Load Checkpoint node, with the added ability to load checkpoints from Extra Folder Paths.
|
||||
* **UNET Loader LM Node** - Behaves like ComfyUI's built-in Load Diffusion Model node, with support for loading from Extra Folder Paths and GGUF format (requires ComfyUI-GGUF custom node).
|
||||
* **LoRA Stack Combiner Node** - Merge two LoRA stacks into one. For example: use separate Randomizers for character and style LoRAs, then combine before applying.
|
||||
* **LoRA Pool Regex Filtering** - Filter which LoRAs enter the pool using custom regex patterns for include/exclude rules.
|
||||
* **Dynamic Base Model Types** - Base model types are now fetched dynamically from Civitai API, keeping them synchronized with the latest available models.
|
||||
* **Prompt Autocomplete Enhancements** - Tab key acceptance, configurable behavior, and improved multi-word tag matching.
|
||||
* **Download Base Model Exclusions** - Exclude specific base models from download operations when you only want certain model types.
|
||||
* **Mature Blur Threshold Setting** - Configure blur levels (`PG13` / `R` / `X` / `XXX`, default `R+`) for mature content previews.
|
||||
* **Experimental: Nunchaku Qwen LoRA Support** - Experimental support for loading and applying LoRAs to Nunchaku quantized Qwen-Image models.
|
||||
* **Bug Fixes & UX Improvements** - Various fixes for a smoother workflow.
|
||||
|
||||
### v1.0.0
|
||||
* **Extra Folder Paths Support** - Added support for additional model root paths exclusive to LoRA Manager. This allows loading LoRAs from extra locations outside ComfyUI's standard folders, helping avoid performance issues when working with large model libraries.
|
||||
* **Settings UI Overhaul** - Redesigned the Settings interface with a more organized layout, making it easier to find and configure application settings.
|
||||
* **Lazy Hash Computation** - Implemented lazy hash calculation for large model files (checkpoints and diffusion models). Hashes are now computed only when strictly necessary, minimizing redundant disk I/O and significantly accelerating application initialization.
|
||||
* **Milestone & Supporter Recognition** - Updated the Supporter window to show appreciation for all project supporters as this v1.0.0 milestone is reached. Great thanks to the community for the ongoing support!
|
||||
* **Bug Fixes & UX Enhancements** - Various bug fixes and user experience improvements for a smoother workflow.
|
||||
|
||||
### v0.9.16
|
||||
* **Duplicate Detection Enhancement** - The model duplicates mode now respects filter configurations, making it easier to find duplicate groups within specific filtered results.
|
||||
* **Tag Logic Toggle** - Added OR/AND toggle for include tags filtering in the filters panel, providing more flexible tag-based model searches.
|
||||
* **Metadata Refresh Skip Paths** - New setting to exclude specific paths from metadata refresh operations. Models under these paths will be skipped when fetching metadata from remote sources.
|
||||
* **Dynamic Trigger Words in Prompt Node** - Prompt node now supports dynamic numbers of trigger word inputs for greater flexibility.
|
||||
* **Early Access Updates** - Model updates now display Early Access information, with a new setting to ignore Early Access updates if desired.
|
||||
* **LM Civitai Extension Integration** - Added integration with the LM Civitai Extension. Clicking the download button in model updates now sends downloads to the extension's download queue for seamless one-click downloads.
|
||||
|
||||
### v0.9.15
|
||||
* **Filter Presets** - Save filter combinations as presets for quick switching and reapplication.
|
||||
* **Bug Fixes** - Fixed various bugs for improved stability.
|
||||
|
||||
### v0.9.14
|
||||
* **LoRA Cycler Node** - Introduced a new LoRA Cycler node that enables iteration through specified LoRAs with support for repeat count and pause iteration functionality. Refer to the new "Lora Cycler" template workflow for concrete example.
|
||||
* **Enhanced Prompt Node with Tag Autocomplete** - Enhanced the Prompt node with comprehensive tag autocomplete based on merged Danbooru + e621 tags. Supports tag search and autocomplete functionality. Implemented a command system with shortcuts like `/character` or `/artist` for category-specific tag searching. Added `/ac` or `/noac` commands to quickly enable or disable autocomplete. Refer to the "Lora Manager Basic" template workflow in ComfyUI -> Templates -> ComfyUI-Lora-Manager for detailed tips.
|
||||
* **Bug Fixes & Stability** - Addressed multiple bugs and improved overall stability.
|
||||
|
||||
### v0.9.12
|
||||
* **LoRA Randomizer System** - Introduced a comprehensive LoRA randomization system featuring LoRA Pool and LoRA Randomizer nodes for flexible and dynamic generation workflows.
|
||||
* **LoRA Randomizer Template** - Refer to the new "LoRA Randomizer" template workflow for detailed examples of flexible randomization modes, lock & reuse options, and other features.
|
||||
* **Recipe Folders** - Introduced a folder system for the Recipes page, allowing users to freely organize recipes just like they do with models.
|
||||
* **Recipe Bulk Operations** - Added bulk mode support for batch moving, deleting, and setting base models for selected recipes with intuitive controls like click-and-drag selection, drag-to-folder, and Ctrl+A (Select All).
|
||||
* **Prompt Search & Sorting** - Search recipes by prompt content and sort by Recipe Name, Imported Date, or LoRA Count for better browsing.
|
||||
* **Recipe Favorites** - Mark specific recipes as favorites for quick access.
|
||||
* **Video Recipe Support** - Enabled support for video recipes (import via LM extension or URL; video file import not supported).
|
||||
* **Performance Improvements** - Fixed performance issues for dramatically improved startup and loading speed. After first scan, subsequent loads are instant regardless of collection size.
|
||||
* **ComfyUI Nodes 2.0 Support** - Basic support for ComfyUI Nodes 2.0.
|
||||
|
||||
### v0.9.10
|
||||
* **Smarter Update Matching** - Users can now choose to check and group updates by matching base model only or with no base-model constraint; version lists also support toggling between same-base versions or all versions.
|
||||
* **Flexible Tag Filtering** - The filter panel now supports tag exclusion: click a tag to include, click again to exclude, and click a third time to clear, enabling stronger and more flexible tag filters.
|
||||
* **License Visibility & Controls** - Model detail headers and ComfyUI preview popups now show Civitai license icons. The filter panel gains license include/exclude options, and a new global context menu action, "Refresh license metadata," fetches missing license data.
|
||||
* **Recipe Improvements** - Recipes now allow importing with zero LoRAs, and recipe detail pages show the related checkpoint for easier reference.
|
||||
* **Better ZIP Downloads** - When downloading models packaged in ZIPs, model files are extracted into the target model folder; ZIPs containing multiple model files (e.g., WanVideo high/low LoRA pairs) are added as separate models.
|
||||
* **Template Workflow Update** - Refreshed the "Illustrious Pony Example" template workflow with usage guidance for each LoRA Manager node.
|
||||
* **Bug Fixes & Stability** - General fixes and stability improvements.
|
||||
|
||||
### v0.9.9
|
||||
* **Check for Updates Feature** - Users can now check for updates for all models or selected models in bulk mode. Models with available updates will display an "update available" badge on their model card, and users can filter to show only models with updates.
|
||||
* **Model Versions Management** - Added a new Versions tab in the model modal that centralizes all versions of a model, providing download, delete, and ignore update functions.
|
||||
* **Send Checkpoint to ComfyUI** - Users can now click the send button on a checkpoint card to send the checkpoint directly to the current workflow's checkpoint or diffusion model loader node in ComfyUI.
|
||||
* **Customizable Model Card Display** - Added a new setting that allows users to choose whether to display the model name or filename on model cards.
|
||||
* **New Path Template Placeholders** - Added new path template placeholders: `{model_name}` and `{version_name}` for more flexible organization.
|
||||
* **ComfyUI Auto Path Correction Setting** - Added a new setting within ComfyUI to enable or disable the auto path correction feature.
|
||||
|
||||
### v0.9.8
|
||||
* **Full CivArchive API Support** - Added complete support for the CivArchive API as a fallback metadata source beyond Civitai API. Models deleted from Civitai can now still retrieve metadata through the CivArchive API.
|
||||
* **Download Models from CivArchive** - Added support for downloading models directly from CivArchive, similar to downloading from Civitai. Simply click the Download button and paste the model URL to download the corresponding model.
|
||||
* **Custom Priority Tags** - Introduced Custom Priority Tags feature, allowing users to define custom priority tags. These tags will appear as suggestions when editing tags or during auto organization/download using default paths, providing more precise and controlled folder organization. [Guide](https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Priority-Tags-Configuration-Guide)
|
||||
* **Drag and Drop Tag Reordering** - Added drag and drop functionality to reorder tags in the tags edit mode for improved usability.
|
||||
* **Download Control in Example Images Panel** - Added stop control in the Download Example Images Panel for better download management.
|
||||
* **Prompt (LoraManager) Node with Autocomplete** - Added new Prompt (LoraManager) node with autocomplete feature for adding embeddings.
|
||||
* **Lora Manager Nodes in Subgraphs** - Lora Manager nodes now support being placed within subgraphs for more flexible workflow organization.
|
||||
|
||||
### v0.9.6
|
||||
* **Metadata Archive Database Support** - Added the ability to download and utilize a metadata archive database, enabling access to metadata for models that have been deleted from CivitAI.
|
||||
* **App-Level Proxy Settings** - Introduced support for configuring a global proxy within the application, making it easier to use the manager behind network restrictions.
|
||||
* **Bug Fixes** - Various bug fixes for improved stability and reliability.
|
||||
|
||||
### v0.9.2
|
||||
* **Bulk Auto-Organization Action** - Added a new bulk auto-organization feature. You can now select multiple models and automatically organize them according to your current path template settings for streamlined management.
|
||||
* **Bug Fixes** - Addressed several bugs to improve stability and reliability.
|
||||
|
||||
### v0.9.1
|
||||
* **Enhanced Bulk Operations** - Improved bulk operations with Marquee Selection and a bulk operation context menu, providing a more intuitive, desktop-application-like user experience.
|
||||
* **New Bulk Actions** - Added bulk operations for adding tags and setting base models to multiple models simultaneously.
|
||||
|
||||
### v0.9.0
|
||||
* **UI Overhaul for Enhanced Navigation** - Replaced the top flat folder tags with a new folder sidebar and breadcrumb navigation system for a more intuitive folder browsing and selection experience.
|
||||
* **Dual-Mode Folder Sidebar** - The new folder sidebar offers two display modes: 'List Mode,' which mirrors the classic folder view, and 'Tree Mode,' which presents a hierarchical folder structure for effortless navigation through nested directories.
|
||||
* **Internationalization Support** - Introduced multi-language support, now available in English, Simplified Chinese, Traditional Chinese, Spanish, Japanese, Korean, French, Russian, and German. Feedback from native speakers is welcome to improve the translations.
|
||||
* **Automatic Filename Conflict Resolution** - Implemented automatic file renaming (`original name + short hash`) to prevent conflicts when downloading or moving models.
|
||||
* **Performance Optimizations & Bug Fixes** - Various performance improvements and bug fixes for a more stable and responsive experience.
|
||||
|
||||
[View Update History](./update_logs.md)
|
||||
|
||||
---
|
||||
|
||||
## **⚠ Important Note**: To use the CivitAI download feature, you'll need to:
|
||||
|
||||
|
||||
@@ -15,215 +15,222 @@
|
||||
"Phil",
|
||||
"Carl G.",
|
||||
"Arlecchino Shion",
|
||||
"stone9k",
|
||||
"$MetaSamsara",
|
||||
"Rob Williams",
|
||||
"stone9k",
|
||||
"runte3221",
|
||||
"Kiba",
|
||||
"Mozzel",
|
||||
"itismyelement",
|
||||
"Gingko Biloba",
|
||||
"onesecondinosaur",
|
||||
"Christian Byrne",
|
||||
"DM",
|
||||
"Sen314",
|
||||
"Estragon",
|
||||
"Takkan",
|
||||
"Charles Blakemore",
|
||||
"Rob Williams",
|
||||
"Rosenthal",
|
||||
"ClockDaemon",
|
||||
"Francisco Tatis",
|
||||
"Tobi_Swagg",
|
||||
"SG",
|
||||
"jmack",
|
||||
"Andrew Wilson",
|
||||
"Greybush",
|
||||
"iamresist",
|
||||
"Wolffen",
|
||||
"Ricky Carter",
|
||||
"JongWon Han",
|
||||
"VantAI",
|
||||
"runte3221",
|
||||
"Tim",
|
||||
"Michael Wong",
|
||||
"Illrigger",
|
||||
"Tom Corrigan",
|
||||
"JackieWang",
|
||||
"FreelancerZ",
|
||||
"fnkylove",
|
||||
"Echo",
|
||||
"Lilleman",
|
||||
"Robert Stacey",
|
||||
"PM",
|
||||
"Edgar Tejeda",
|
||||
"Jorge Hussni",
|
||||
"Liam MacDougal",
|
||||
"Sterilized",
|
||||
"Fraser Cross",
|
||||
"Polymorphic Indeterminate",
|
||||
"Marc Whiffen",
|
||||
"Birdy",
|
||||
"Skalabananen",
|
||||
"Kiba",
|
||||
"quarz",
|
||||
"Reno Lam",
|
||||
"Mozzel",
|
||||
"JSST",
|
||||
"sig",
|
||||
"Christian Byrne",
|
||||
"DM",
|
||||
"Sen314",
|
||||
"Estragon",
|
||||
"J\\B/ 8r0wns0n",
|
||||
"Snaggwort",
|
||||
"ClockDaemon",
|
||||
"Baekdoosixt",
|
||||
"Jonathan Ross",
|
||||
"KD",
|
||||
"Omnidex",
|
||||
"Nazono_hito",
|
||||
"Melville Parrish",
|
||||
"daniel dove",
|
||||
"Lustre",
|
||||
"Tyler Trebuchon",
|
||||
"Release Cabrakan",
|
||||
"JW Sin",
|
||||
"contrite831",
|
||||
"SG",
|
||||
"Alex",
|
||||
"carozzz",
|
||||
"Marlon Daniels",
|
||||
"James Dooley",
|
||||
"zenbound",
|
||||
"Buzzard",
|
||||
"jmack",
|
||||
"Adam Shaw",
|
||||
"Mark Corneglio",
|
||||
"SarcasticHashtag",
|
||||
"Anthony Rizzo",
|
||||
"iamresist",
|
||||
"Gooohokrbe",
|
||||
"RedrockVP",
|
||||
"Wolffen",
|
||||
"James Todd",
|
||||
"ASLPro3D",
|
||||
"OldBones",
|
||||
"FinalyFree",
|
||||
"Steven Pfeiffer",
|
||||
"Tim",
|
||||
"Timmy",
|
||||
"Johnny",
|
||||
"Lisster",
|
||||
"Michael Wong",
|
||||
"whudunit",
|
||||
"Tom Corrigan",
|
||||
"dl0901dm",
|
||||
"JackieWang",
|
||||
"fnkylove",
|
||||
"Yushio",
|
||||
"Vik71it",
|
||||
"Echo",
|
||||
"Lilleman",
|
||||
"Robert Stacey",
|
||||
"PM",
|
||||
"Todd Keck",
|
||||
"Briton Heilbrun",
|
||||
"Aleksander Wujczyk",
|
||||
"BadassArabianMofo",
|
||||
"Sterilized",
|
||||
"Pascal Dahle",
|
||||
"quarz",
|
||||
"Penfore",
|
||||
"Greg",
|
||||
"JSST",
|
||||
"lmsupporter",
|
||||
"zounic",
|
||||
"wfpearl",
|
||||
"Baekdoosixt",
|
||||
"Jack B Nimble",
|
||||
"Melville Parrish",
|
||||
"daniel dove",
|
||||
"Lustre",
|
||||
"JW Sin",
|
||||
"Alex",
|
||||
"bh",
|
||||
"Marlon Daniels",
|
||||
"Starkselle",
|
||||
"Aaron Bleuer",
|
||||
"LacesOut!",
|
||||
"greebles",
|
||||
"Cosmosis",
|
||||
"M Postkasse",
|
||||
"FloPro4Sho",
|
||||
"ASLPro3D",
|
||||
"Jacob Hoehler",
|
||||
"FinalyFree",
|
||||
"Weasyl",
|
||||
"Lex Song",
|
||||
"Cory Paza",
|
||||
"Tak",
|
||||
"Gonzalo Andre Allendes Lopez",
|
||||
"Lisster",
|
||||
"Zach Gonser",
|
||||
"Big Red",
|
||||
"Jimmy Ledbetter",
|
||||
"whudunit",
|
||||
"Luc Job",
|
||||
"Philip Hempel",
|
||||
"dl0901dm",
|
||||
"corde",
|
||||
"Nick Walker",
|
||||
"Julian V",
|
||||
"Steven Owens",
|
||||
"Yushio",
|
||||
"Vik71it",
|
||||
"Bishoujoker",
|
||||
"aai",
|
||||
"Todd Keck",
|
||||
"Briton Heilbrun",
|
||||
"Tori",
|
||||
"wildnut",
|
||||
"jean jahren",
|
||||
"Aleksander Wujczyk",
|
||||
"AM Kuro",
|
||||
"ViperC",
|
||||
"Ran C",
|
||||
"Sangheili460",
|
||||
"BadassArabianMofo",
|
||||
"Pascal Dahle",
|
||||
"Penfore",
|
||||
"Greg",
|
||||
"MagnaInsomnia",
|
||||
"Karl P.",
|
||||
"Akira_HentAI",
|
||||
"Gordon Cole",
|
||||
"yuxz69",
|
||||
"esthe",
|
||||
"AbstractAss",
|
||||
"lmsupporter",
|
||||
"andrew.tappan",
|
||||
"N/A",
|
||||
"Greenmoustache",
|
||||
"zounic",
|
||||
"wfpearl",
|
||||
"Eldithor",
|
||||
"Jack B Nimble",
|
||||
"JaxMax",
|
||||
"bh",
|
||||
"Jwk0205",
|
||||
"Starkselle",
|
||||
"Olive",
|
||||
"Aaron Bleuer",
|
||||
"LacesOut!",
|
||||
"greebles",
|
||||
"Some Guy Named Barry",
|
||||
"Cosmosis",
|
||||
"M Postkasse",
|
||||
"FloPro4Sho",
|
||||
"wamekukyouzin",
|
||||
"Jacob Hoehler",
|
||||
"Matt Wenzel",
|
||||
"Weasyl",
|
||||
"Lex Song",
|
||||
"Cory Paza",
|
||||
"Gonzalo Andre Allendes Lopez",
|
||||
"Serge Bekenkamp",
|
||||
"Jimmy Ledbetter",
|
||||
"Philip Hempel",
|
||||
"ApathyJones",
|
||||
"Julian V",
|
||||
"Steven Owens",
|
||||
"dan",
|
||||
"aai",
|
||||
"Mouthlessman",
|
||||
"otaku fra",
|
||||
"ViperC",
|
||||
"Ran C",
|
||||
"MiraiKuriyamaSy",
|
||||
"Sangheili460",
|
||||
"Karl P.",
|
||||
"yuxz69",
|
||||
"Adam Taylor",
|
||||
"Weird_With_A_Beard",
|
||||
"esthe",
|
||||
"The Spawn",
|
||||
"graysock",
|
||||
"Pozadine1",
|
||||
"Greenmoustache",
|
||||
"fancypants",
|
||||
"IamAyam",
|
||||
"Eldithor",
|
||||
"Joboshy",
|
||||
"Digital",
|
||||
"JaxMax",
|
||||
"takyamtom",
|
||||
"Bohemian Corporal",
|
||||
"Dan",
|
||||
"confiscated Zyra",
|
||||
"Jwk0205",
|
||||
"Bro Xie",
|
||||
"yer fey",
|
||||
"batblue",
|
||||
"carey6409",
|
||||
"Olive",
|
||||
"太郎 ゲーム",
|
||||
"Tee Gee",
|
||||
"Some Guy Named Barry",
|
||||
"jinxedx",
|
||||
"tarek helmi",
|
||||
"Max Marklund",
|
||||
"AELOX",
|
||||
"Dankin",
|
||||
"Nicfit23",
|
||||
"wamekukyouzin",
|
||||
"drum matthieu",
|
||||
"Dogmaster",
|
||||
"Matt Wenzel",
|
||||
"Frank Nitty",
|
||||
"Pronredn",
|
||||
"Christopher Michel",
|
||||
"Serge Bekenkamp",
|
||||
"DougPeterson",
|
||||
"LeoZero",
|
||||
"Antonio Pontes",
|
||||
"ApathyJones",
|
||||
"nahinahi9",
|
||||
"lh qwe",
|
||||
"Kevin John Duck",
|
||||
"conner",
|
||||
"Dustin Chen",
|
||||
"dan",
|
||||
"Blackfish95",
|
||||
"Mouthlessman",
|
||||
"Princess Bright Eyes",
|
||||
"Paul Kroll",
|
||||
"AbstractAss",
|
||||
"otaku fra",
|
||||
"Felipe dos Santos",
|
||||
"Bas Imagineer",
|
||||
"Markus",
|
||||
"MiraiKuriyamaSy",
|
||||
"Adam Taylor",
|
||||
"Douglas Gaspar",
|
||||
"Weird_With_A_Beard",
|
||||
"AlexDuKaNa",
|
||||
"George",
|
||||
"dw",
|
||||
"Qarob",
|
||||
"AIGooner",
|
||||
"Luc",
|
||||
"ProtonPrince",
|
||||
"DiffDuck",
|
||||
"fancypants",
|
||||
"IamAyam",
|
||||
"Joboshy",
|
||||
"Digital",
|
||||
"takyamtom",
|
||||
"Bohemian Corporal",
|
||||
"Dan",
|
||||
"confiscated Zyra",
|
||||
"Bro Xie",
|
||||
"yer fey",
|
||||
"batblue",
|
||||
"carey6409",
|
||||
"太郎 ゲーム",
|
||||
"Roslynd",
|
||||
"Tee Gee",
|
||||
"jinxedx",
|
||||
"tarek helmi",
|
||||
"Neco28",
|
||||
"Max Marklund",
|
||||
"AELOX",
|
||||
"Dankin",
|
||||
"Nicfit23",
|
||||
"Cristian Vazquez",
|
||||
"drum matthieu",
|
||||
"Dogmaster",
|
||||
"Frank Nitty",
|
||||
"Magic Noob",
|
||||
"Pronredn",
|
||||
"Christopher Michel",
|
||||
"DougPeterson",
|
||||
"LeoZero",
|
||||
"Antonio Pontes",
|
||||
"Bruce",
|
||||
"nahinahi9",
|
||||
"lh qwe",
|
||||
"Kevin John Duck",
|
||||
"conner",
|
||||
"Dustin Chen",
|
||||
"Blackfish95",
|
||||
"Princess Bright Eyes",
|
||||
"Paul Kroll",
|
||||
"Felipe dos Santos",
|
||||
"Bas Imagineer",
|
||||
"Markus",
|
||||
"John Statham",
|
||||
"Douglas Gaspar",
|
||||
"AlexDuKaNa",
|
||||
"George",
|
||||
"dw",
|
||||
"decoy",
|
||||
"elu3199",
|
||||
"Hasturkun",
|
||||
"Jon Sandman",
|
||||
@@ -233,56 +240,59 @@
|
||||
"wundershark",
|
||||
"mr_dinosaur",
|
||||
"Tyrswood",
|
||||
"Ray Wing",
|
||||
"Ranzitho",
|
||||
"Gus",
|
||||
"MJG",
|
||||
"David LaVallee",
|
||||
"linnfrey",
|
||||
"Pkrsky",
|
||||
"奚明 刘",
|
||||
"Josef Lanzl",
|
||||
"Nerezza",
|
||||
"sanborondon",
|
||||
"Griffin Dahlberg",
|
||||
"준희 김",
|
||||
"Error_Rule34_Not_found",
|
||||
"Taylor Funk",
|
||||
"aezin",
|
||||
"jcay015",
|
||||
"Gerald Welly",
|
||||
"Roslynd",
|
||||
"Erik Lopez",
|
||||
"Mateo Curić",
|
||||
"Geolog",
|
||||
"Neco28",
|
||||
"Eris3D",
|
||||
"Tomohiro Baba",
|
||||
"David Ortega",
|
||||
"Noora",
|
||||
"Cristian Vazquez",
|
||||
"Mattssn",
|
||||
"Magic Noob",
|
||||
"a _",
|
||||
"Jeff",
|
||||
"Bruce",
|
||||
"James Coleman",
|
||||
"Kevin Christopher",
|
||||
"Emil Andersson",
|
||||
"Ouro Boros",
|
||||
"Chad Idk",
|
||||
"Yaboi",
|
||||
"dd",
|
||||
"Steam Steam",
|
||||
"CryptoTraderJK",
|
||||
"Davaitamin",
|
||||
"Dušan Ryban",
|
||||
"tedcor",
|
||||
"Sam",
|
||||
"Fotek Design",
|
||||
"sjon kreutz",
|
||||
"John Statham",
|
||||
"MadSpin",
|
||||
"Metryman55",
|
||||
"inbijiburu",
|
||||
"decoy",
|
||||
"Nick “Loadstone” D",
|
||||
"Ray Wing",
|
||||
"Ranzitho",
|
||||
"Gus",
|
||||
"地獄の禄",
|
||||
"MJG",
|
||||
"David LaVallee",
|
||||
"ae",
|
||||
"Tr4shP4nda",
|
||||
"Gamalonia",
|
||||
"WRL_SPR",
|
||||
"capn",
|
||||
"Joseph",
|
||||
"momokai",
|
||||
"Mirko Katzula",
|
||||
"dan",
|
||||
"Piccio08",
|
||||
@@ -296,54 +306,57 @@
|
||||
"kudari",
|
||||
"Naomi Hale Danchi",
|
||||
"dc7431",
|
||||
"epicgamer0020690",
|
||||
"Joshua Porrata",
|
||||
"SuBu",
|
||||
"RedPIXel",
|
||||
"Vir",
|
||||
"Richard",
|
||||
"Andrew",
|
||||
"Brian M",
|
||||
"sanborondon",
|
||||
"Seth Christensen",
|
||||
"Robert Wegemund",
|
||||
"Littlehuggy",
|
||||
"Draven T",
|
||||
"Taylor Funk",
|
||||
"aezin",
|
||||
"mrjuan",
|
||||
"Brian Buie",
|
||||
"Thought2Form",
|
||||
"jcay015",
|
||||
"Kevin Picco",
|
||||
"Erik Lopez",
|
||||
"Mateo Curić",
|
||||
"Sadlip",
|
||||
"Aquatic Coffee",
|
||||
"Eris3D",
|
||||
"m",
|
||||
"ethanfel",
|
||||
"Pierce McBride",
|
||||
"Joshua Gray",
|
||||
"Focuschannel",
|
||||
"Mikko Hemilä",
|
||||
"Jacob McDaniel",
|
||||
"Jamie Ogletree",
|
||||
"a _",
|
||||
"James Coleman",
|
||||
"Temikus",
|
||||
"Artokun",
|
||||
"Michael Taylor",
|
||||
"Derek Baker",
|
||||
"Martial",
|
||||
"Anthony Faxlandez",
|
||||
"battu",
|
||||
"Emil Andersson",
|
||||
"Michael Anthony Scott",
|
||||
"Atilla Berke Pekduyar",
|
||||
"Decx _",
|
||||
"Yuji Kaneko",
|
||||
"Pat Hen",
|
||||
"semicolon drainpipe",
|
||||
"Jordan Shaw",
|
||||
"Rops Alot",
|
||||
"Thesharingbrother",
|
||||
"Sam",
|
||||
"Ace Ventura",
|
||||
"ResidentDeviant",
|
||||
"四糸凜音",
|
||||
"Nihongasuki",
|
||||
"JC",
|
||||
"Prompt Pirate",
|
||||
"uwutismxd",
|
||||
"momokai",
|
||||
"zenobeus",
|
||||
"ken",
|
||||
"epicgamer0020690",
|
||||
"Joshua Porrata",
|
||||
"Crocket",
|
||||
"keemun",
|
||||
"SuBu",
|
||||
"RedPIXel",
|
||||
"Wind",
|
||||
"Jackthemind",
|
||||
"Nexus",
|
||||
@@ -362,21 +375,26 @@
|
||||
"socrasteeze",
|
||||
"OrganicArtifact",
|
||||
"Stryker",
|
||||
"ResidentDeviant",
|
||||
"MudkipMedkitz",
|
||||
"deanbrian",
|
||||
"Alex Wortman",
|
||||
"Cody",
|
||||
"smart.edge5178",
|
||||
"InformedViewz",
|
||||
"CHKeeho80",
|
||||
"Bubbafett",
|
||||
"leaf",
|
||||
"Menard",
|
||||
"Skyfire83",
|
||||
"Adam Rinehart",
|
||||
"gzmzmvp",
|
||||
"raf8osz",
|
||||
"ElitaSSJ4",
|
||||
"Richard",
|
||||
"blikkies",
|
||||
"Andrew",
|
||||
"Chris",
|
||||
"Robert Wegemund",
|
||||
"Littlehuggy",
|
||||
"Gregory Kozhemiak",
|
||||
"mrjuan",
|
||||
"Brian Buie",
|
||||
"Shock Shockor",
|
||||
"Sadlip",
|
||||
"Goldwaters",
|
||||
"Eric Whitney",
|
||||
"Joey Callahan",
|
||||
@@ -390,30 +408,20 @@
|
||||
"Theerat Jiramate",
|
||||
"aRtFuL_DodGeR",
|
||||
"Noah",
|
||||
"Jacob McDaniel",
|
||||
"X",
|
||||
"Sloan Steddy",
|
||||
"Temikus",
|
||||
"Artokun",
|
||||
"Michael Taylor",
|
||||
"Derek Baker",
|
||||
"CrimsonDX",
|
||||
"Michael Anthony Scott",
|
||||
"hexxish",
|
||||
"DarkSunset",
|
||||
"Atilla Berke Pekduyar",
|
||||
"Nathan",
|
||||
"Billy Gladky",
|
||||
"NICHOLAS BAXLEY",
|
||||
"Decx _",
|
||||
"Probis",
|
||||
"Ed Wang",
|
||||
"ItsGeneralButtNaked",
|
||||
"Nimess",
|
||||
"SRDB",
|
||||
"g unit",
|
||||
"Distortik",
|
||||
"Youguang",
|
||||
"四糸凜音",
|
||||
"Saya",
|
||||
"andrewzpong",
|
||||
"FrxzenSnxw",
|
||||
@@ -421,40 +429,38 @@
|
||||
"lrdchs",
|
||||
"Tree Tagger",
|
||||
"Inversity",
|
||||
"Crocket",
|
||||
"AIVORY3D",
|
||||
"Kevinj",
|
||||
"Mitchell Robson",
|
||||
"Whitepinetrader",
|
||||
"ResidentDeviant",
|
||||
"deanbrian",
|
||||
"POPPIN",
|
||||
"Alex Wortman",
|
||||
"Cody",
|
||||
"Ginnie",
|
||||
"Raku",
|
||||
"smart.edge5178",
|
||||
"InformedViewz",
|
||||
"CHKeeho80",
|
||||
"Bubbafett",
|
||||
"leaf",
|
||||
"Menard",
|
||||
"Skyfire83",
|
||||
"Adam Rinehart",
|
||||
"emadsultan",
|
||||
"Pitpe11",
|
||||
"TheD1rtyD03",
|
||||
"moonpetal",
|
||||
"SomeDude",
|
||||
"g9p0o",
|
||||
"Pkrsky",
|
||||
"TheHolySheep",
|
||||
"Monte Won",
|
||||
"SpringBootisTrash",
|
||||
"carsten",
|
||||
"ikok",
|
||||
"quantenmecha",
|
||||
"Jason+Nash",
|
||||
"BillyBoy84",
|
||||
"DarkRoast",
|
||||
"letzte",
|
||||
"Nasty+Hobbit",
|
||||
"Sora+Yori",
|
||||
"lrdchs2",
|
||||
"Duk3+Rand0m",
|
||||
"Nathen+Choi",
|
||||
"T",
|
||||
"LarsesFPC",
|
||||
"cocona",
|
||||
"sfasdfasfdsa",
|
||||
"Buecyb99",
|
||||
"Welkor",
|
||||
"David Schenck",
|
||||
@@ -463,15 +469,15 @@
|
||||
"Ink Temptation",
|
||||
"moranqianlong",
|
||||
"Kalli Core",
|
||||
"Time Valentine",
|
||||
"elleshar666",
|
||||
"ACTUALLY_the_Real_Willem_Dafoe",
|
||||
"Haru Yotu",
|
||||
"Михал Михалыч",
|
||||
"Matt",
|
||||
"Kauffy",
|
||||
"EpicElric",
|
||||
"Kyron Mahan",
|
||||
"Edward Kennedy",
|
||||
"Justin Blaylock",
|
||||
"Matura Arbeit",
|
||||
"Nick Kage",
|
||||
"TBitz33",
|
||||
"Anonym dkjglfleeoeldldldlkf",
|
||||
@@ -480,12 +486,14 @@
|
||||
"Cyrus Fett",
|
||||
"Ezokewn",
|
||||
"SendingRavens",
|
||||
"hexxish",
|
||||
"Xenon Xue",
|
||||
"notedfakes",
|
||||
"Michael Docherty",
|
||||
"Michael Scott",
|
||||
"Paul Hartsuyker",
|
||||
"Henrique Faiolli",
|
||||
"elitassj",
|
||||
"Solixer",
|
||||
"Jacob Winter",
|
||||
"Ryan Presley Ng",
|
||||
"Wes Sims",
|
||||
@@ -494,7 +502,6 @@
|
||||
"David",
|
||||
"Meilo",
|
||||
"Filippo Ferrari",
|
||||
"Pen Bouryoung",
|
||||
"shinonomeiro",
|
||||
"Snille",
|
||||
"MaartenAlbers",
|
||||
@@ -511,12 +518,21 @@
|
||||
"Kalnei",
|
||||
"Scott",
|
||||
"Muratoraccio",
|
||||
"Ginnie",
|
||||
"emadsultan",
|
||||
"D",
|
||||
"nanana",
|
||||
"Dark_Pest",
|
||||
"Alex",
|
||||
"Jacky+Ho",
|
||||
"Karru",
|
||||
"ghoulars",
|
||||
"ChaChanoKo",
|
||||
"null",
|
||||
"Beau",
|
||||
"redcarrot",
|
||||
"powerbot99",
|
||||
"Fthehappy",
|
||||
"rsamerica",
|
||||
"sfasdfasfdsa",
|
||||
"Alan+Cano",
|
||||
"FeralOpticsAI",
|
||||
"Pavlaki",
|
||||
@@ -524,60 +540,50 @@
|
||||
"Doug+Rintoul",
|
||||
"Noor",
|
||||
"Yorunai",
|
||||
"quantenmecha",
|
||||
"abattoirblues",
|
||||
"Jason+Nash",
|
||||
"BillyBoy84",
|
||||
"zounik",
|
||||
"DarkRoast",
|
||||
"letzte",
|
||||
"Nasty+Hobbit",
|
||||
"Sora+Yori",
|
||||
"lrdchs2",
|
||||
"Duk3+Rand0m",
|
||||
"4IXplr0r3r",
|
||||
"hayden",
|
||||
"ahoystan",
|
||||
"Leland Saunders",
|
||||
"Bob Barker",
|
||||
"edk",
|
||||
"JBsuede",
|
||||
"Time Valentine",
|
||||
"Aeternyx",
|
||||
"YOU SINWOO",
|
||||
"Christian Schäfer",
|
||||
"りん あめ",
|
||||
"ja s",
|
||||
"Михал Михалыч",
|
||||
"Matt",
|
||||
"Doug Mason",
|
||||
"Jeremy Townsend",
|
||||
"Locrospiel",
|
||||
"Frogmilk",
|
||||
"Sean voets",
|
||||
"Owen Gwosdz",
|
||||
"SPJ",
|
||||
"Thomas Wanner",
|
||||
"Kor",
|
||||
"Joseph Hanson",
|
||||
"Bryan Rutkowski",
|
||||
"Devil Lude",
|
||||
"David Murcko",
|
||||
"kevin stoddard",
|
||||
"Jack Dole",
|
||||
"max blo",
|
||||
"Xenon Xue",
|
||||
"Steven",
|
||||
"CptNeo",
|
||||
"JackJohnnyJim",
|
||||
"TenaciousD",
|
||||
"Dmitry Ryzhov",
|
||||
"Khánh Đặng",
|
||||
"Maso",
|
||||
"Edward Ten Eyck",
|
||||
"Eric Ketchum",
|
||||
"Kevin Wallace",
|
||||
"Matheus Couto",
|
||||
"Jimmy Borup",
|
||||
"ChicRic",
|
||||
"Henrique Faiolli",
|
||||
"mercur",
|
||||
"Solixer",
|
||||
"J C",
|
||||
"Pete Pain",
|
||||
"RHopkirk",
|
||||
"jinksta187",
|
||||
"Andrew Wilkinson",
|
||||
"Yavizu3d",
|
||||
"Maxim",
|
||||
"Manu Thetug",
|
||||
"Karlanx",
|
||||
"Yves Poezevara",
|
||||
@@ -629,6 +635,20 @@
|
||||
"SelfishMedic",
|
||||
"adderleighn",
|
||||
"EnragedAntelope",
|
||||
"Drizzly",
|
||||
"Sildoren",
|
||||
"Darvidous",
|
||||
"Seon+Song",
|
||||
"2turbo",
|
||||
"balut+omelette",
|
||||
"Nebuleux",
|
||||
"Dmitry+Viznesenskiy",
|
||||
"Tanjin90",
|
||||
"Somebody",
|
||||
"sternenkrieger",
|
||||
"eriick",
|
||||
"Join+Chun",
|
||||
"Pascalou",
|
||||
"lighthawke",
|
||||
"Terraformer",
|
||||
"GDS+DEV",
|
||||
@@ -651,77 +671,66 @@
|
||||
"D",
|
||||
"datasl4ve",
|
||||
"Somebody",
|
||||
"Dark_Pest",
|
||||
"Aza",
|
||||
"Jacky+Ho",
|
||||
"koopa990",
|
||||
"Karru",
|
||||
"ChaChanoKo",
|
||||
"null",
|
||||
"bo",
|
||||
"The+Forgetful+Dev",
|
||||
"redcarrot",
|
||||
"powerbot99",
|
||||
"Mateusz+Kosela",
|
||||
"Bula",
|
||||
"KUJYAKU",
|
||||
"Coeur+de+cochon",
|
||||
"han b",
|
||||
"Nico",
|
||||
"Maximilian Krischan",
|
||||
"Banana Joe",
|
||||
"_ G3n",
|
||||
"Donovan Jenkins",
|
||||
"Tú Nguyễn Lý Hoàng",
|
||||
"shira1011",
|
||||
"Michael Eid",
|
||||
"beersandbacon",
|
||||
"Maximilian Pyko",
|
||||
"Invis",
|
||||
"Bob barker",
|
||||
"Ben D",
|
||||
"Garrett Wood",
|
||||
"G",
|
||||
"Ronan Delevacq",
|
||||
"james",
|
||||
"Christian Schäfer",
|
||||
"OrochiNights",
|
||||
"Michael Zhu",
|
||||
"gonzalo",
|
||||
"Nemisu",
|
||||
"Seraphy",
|
||||
"雨の心 落",
|
||||
"AllTimeNoobie",
|
||||
"Leslie Andrew Ridings",
|
||||
"jumpd",
|
||||
"John C",
|
||||
"Rim",
|
||||
"Dave Abraham",
|
||||
"Joaquin Hierrezuelo",
|
||||
"Dismem",
|
||||
"Locrospiel",
|
||||
"Jairus Knudsen",
|
||||
"Jarrid Lee",
|
||||
"Poophead27 Blyat",
|
||||
"Xan Dionysus",
|
||||
"Nathan lee",
|
||||
"Kor",
|
||||
"Joseph Hanson",
|
||||
"Mewtora",
|
||||
"Middo",
|
||||
"Forbidden Atelier",
|
||||
"John Rednoulf",
|
||||
"Spire",
|
||||
"DrB",
|
||||
"AZ Party Oasis",
|
||||
"Adictedtohumping",
|
||||
"Boba Smith",
|
||||
"Towelie",
|
||||
"MR.Bear",
|
||||
"matt",
|
||||
"dsffsdfsdfsdfsdfsdf",
|
||||
"somethingtosay8",
|
||||
"Jean-françois SEMA",
|
||||
"Kurt",
|
||||
"ivistorm",
|
||||
"Sauv",
|
||||
"Steven",
|
||||
"TenaciousD",
|
||||
"Khánh Đặng",
|
||||
"jimyjomson",
|
||||
"Borte",
|
||||
"Chase Kwon",
|
||||
"Ted Cart",
|
||||
"Sage Himeros",
|
||||
"Inyoshu",
|
||||
"Goober719",
|
||||
"Chad Barnes",
|
||||
"Person Y",
|
||||
"David Spearing",
|
||||
@@ -740,7 +749,8 @@
|
||||
"dxjaymz",
|
||||
"L C",
|
||||
"Dude",
|
||||
"Somebody",
|
||||
"CK"
|
||||
],
|
||||
"totalCount": 739
|
||||
"totalCount": 749
|
||||
}
|
||||
@@ -233,6 +233,7 @@
|
||||
"noCreditRequired": "Kein Credit erforderlich",
|
||||
"allowSellingGeneratedContent": "Verkauf erlaubt",
|
||||
"noTags": "Keine Tags",
|
||||
"autoTags": "Auto-Tags",
|
||||
"noBaseModelMatches": "Keine Basismodelle entsprechen der aktuellen Suche.",
|
||||
"clearAll": "Alle Filter löschen",
|
||||
"any": "Beliebig",
|
||||
@@ -640,8 +641,6 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Modelliste aktualisieren",
|
||||
"quick": "Änderungen synchronisieren",
|
||||
"quickTooltip": "Nach neuen oder fehlenden Modelldateien suchen, damit die Liste aktuell bleibt.",
|
||||
"full": "Cache neu aufbauen",
|
||||
"fullTooltip": "Alle Modelldetails aus Metadatendateien neu laden – nutzen, wenn die Bibliothek veraltet wirkt oder nach manuellen Änderungen."
|
||||
},
|
||||
@@ -687,11 +686,23 @@
|
||||
"autoOrganize": "Automatisch organisieren",
|
||||
"skipMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle überspringen",
|
||||
"resumeMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle fortsetzen",
|
||||
"setFavorite": "Als Favorit setzen",
|
||||
"setFavoriteCount": "Als Favorit setzen ({favorited}/{total})",
|
||||
"unfavorite": "Aus Favoriten entfernen",
|
||||
"deleteAll": "Ausgewählte löschen",
|
||||
"downloadMissingLoras": "Fehlende LoRAs herunterladen",
|
||||
"downloadExamples": "Beispielbilder herunterladen",
|
||||
"clear": "Auswahl löschen",
|
||||
"skipMetadataRefreshCount": "Überspringen({count} Modelle)",
|
||||
"resumeMetadataRefreshCount": "Fortsetzen({count} Modelle)",
|
||||
"sendToWorkflow": "An Workflow senden",
|
||||
"sections": {
|
||||
"workflow": "Workflow",
|
||||
"metadata": "Metadaten",
|
||||
"attributes": "Attribute",
|
||||
"organize": "Organisieren",
|
||||
"download": "Download"
|
||||
},
|
||||
"autoOrganizeProgress": {
|
||||
"initializing": "Automatische Organisation wird initialisiert...",
|
||||
"starting": "Automatische Organisation für {type} wird gestartet...",
|
||||
@@ -804,8 +815,6 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Rezeptliste aktualisieren",
|
||||
"quick": "Änderungen synchronisieren",
|
||||
"quickTooltip": "Änderungen synchronisieren - schnelle Aktualisierung ohne Cache-Neubau",
|
||||
"full": "Cache neu aufbauen",
|
||||
"fullTooltip": "Cache neu aufbauen - vollständiger Rescan aller Rezeptdateien"
|
||||
},
|
||||
@@ -1077,6 +1086,12 @@
|
||||
"countMessage": "Modelle werden dauerhaft gelöscht.",
|
||||
"action": "Alle löschen"
|
||||
},
|
||||
"bulkDeleteRecipes": {
|
||||
"title": "Mehrere Rezepte löschen",
|
||||
"message": "Sind Sie sicher, dass Sie alle ausgewählten Rezepte und ihre zugehörigen Dateien löschen möchten?",
|
||||
"countMessage": "Rezepte werden dauerhaft gelöscht.",
|
||||
"action": "Alle löschen"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "Alle {typePlural} auf Updates prüfen?",
|
||||
"message": "Damit werden alle {typePlural} in deiner Bibliothek auf Updates geprüft. Bei großen Sammlungen kann das etwas länger dauern.",
|
||||
@@ -1699,6 +1714,11 @@
|
||||
"bulkContentRatingSet": "Inhaltsbewertung auf {level} für {count} Modell(e) gesetzt",
|
||||
"bulkContentRatingPartial": "Inhaltsbewertung auf {level} für {success} Modell(e) gesetzt, {failed} fehlgeschlagen",
|
||||
"bulkContentRatingFailed": "Inhaltsbewertung für ausgewählte Modelle konnte nicht aktualisiert werden",
|
||||
"bulkFavoriteUpdating": "Füge {count} Modell(e) zu Favoriten hinzu...",
|
||||
"bulkUnfavoriteUpdating": "Entferne {count} Modell(e) aus Favoriten...",
|
||||
"bulkFavoritePartialAdded": "{success} Modell(e) zu Favoriten hinzugefügt, {failed} fehlgeschlagen",
|
||||
"bulkFavoritePartialRemoved": "{success} Modell(e) aus Favoriten entfernt, {failed} fehlgeschlagen",
|
||||
"bulkFavoriteFailed": "Fehler beim Aktualisieren des Favoritenstatus",
|
||||
"bulkUpdatesChecking": "Ausgewählte {type}-Modelle werden auf Updates geprüft...",
|
||||
"bulkUpdatesSuccess": "Updates für {count} ausgewählte {type}-Modelle verfügbar",
|
||||
"bulkUpdatesNone": "Keine Updates für ausgewählte {type}-Modelle gefunden",
|
||||
|
||||
@@ -233,6 +233,7 @@
|
||||
"noCreditRequired": "No Credit Required",
|
||||
"allowSellingGeneratedContent": "Allow Selling",
|
||||
"noTags": "No tags",
|
||||
"autoTags": "Auto Tags",
|
||||
"noBaseModelMatches": "No base models match the current search.",
|
||||
"clearAll": "Clear All Filters",
|
||||
"any": "Any",
|
||||
@@ -640,8 +641,6 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Refresh model list",
|
||||
"quick": "Sync Changes",
|
||||
"quickTooltip": "Scan for new or missing model files so the list stays current.",
|
||||
"full": "Rebuild Cache",
|
||||
"fullTooltip": "Reload all model details from metadata files—use if the library looks out of date or after manual edits."
|
||||
},
|
||||
@@ -687,11 +686,23 @@
|
||||
"autoOrganize": "Auto-Organize Selected",
|
||||
"skipMetadataRefresh": "Skip Metadata Refresh for Selected",
|
||||
"resumeMetadataRefresh": "Resume Metadata Refresh for Selected",
|
||||
"setFavorite": "Set as Favorite",
|
||||
"setFavoriteCount": "Set as Favorite ({favorited}/{total})",
|
||||
"unfavorite": "Remove from Favorites",
|
||||
"deleteAll": "Delete Selected",
|
||||
"downloadMissingLoras": "Download Missing LoRAs",
|
||||
"downloadExamples": "Download Example Images",
|
||||
"clear": "Clear Selection",
|
||||
"skipMetadataRefreshCount": "Skip ({count} models)",
|
||||
"resumeMetadataRefreshCount": "Resume ({count} models)",
|
||||
"sendToWorkflow": "Send to Workflow",
|
||||
"sections": {
|
||||
"workflow": "Workflow",
|
||||
"metadata": "Metadata",
|
||||
"attributes": "Attributes",
|
||||
"organize": "Organize",
|
||||
"download": "Download"
|
||||
},
|
||||
"autoOrganizeProgress": {
|
||||
"initializing": "Initializing auto-organize...",
|
||||
"starting": "Starting auto-organize for {type}...",
|
||||
@@ -804,8 +815,6 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Refresh recipe list",
|
||||
"quick": "Sync Changes",
|
||||
"quickTooltip": "Sync changes - quick refresh without rebuilding cache",
|
||||
"full": "Rebuild Cache",
|
||||
"fullTooltip": "Rebuild cache - full rescan of all recipe files"
|
||||
},
|
||||
@@ -1077,6 +1086,12 @@
|
||||
"countMessage": "models will be permanently deleted.",
|
||||
"action": "Delete All"
|
||||
},
|
||||
"bulkDeleteRecipes": {
|
||||
"title": "Delete Multiple Recipes",
|
||||
"message": "Are you sure you want to delete all selected recipes and their associated files?",
|
||||
"countMessage": "recipes will be permanently deleted.",
|
||||
"action": "Delete All"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "Check updates for all {typePlural}?",
|
||||
"message": "This checks every {typePlural} in your library for updates. Large collections may take a little longer.",
|
||||
@@ -1699,6 +1714,11 @@
|
||||
"bulkContentRatingSet": "Set content rating to {level} for {count} model(s)",
|
||||
"bulkContentRatingPartial": "Set content rating to {level} for {success} model(s), {failed} failed",
|
||||
"bulkContentRatingFailed": "Failed to update content rating for selected models",
|
||||
"bulkFavoriteUpdating": "Adding {count} model(s) to favorites...",
|
||||
"bulkUnfavoriteUpdating": "Removing {count} model(s) from favorites...",
|
||||
"bulkFavoritePartialAdded": "Added {success} model(s) to favorites, {failed} failed",
|
||||
"bulkFavoritePartialRemoved": "Removed {success} model(s) from favorites, {failed} failed",
|
||||
"bulkFavoriteFailed": "Failed to update favorite status for selected models",
|
||||
"bulkUpdatesChecking": "Checking selected {type}(s) for updates...",
|
||||
"bulkUpdatesSuccess": "Updates available for {count} selected {type}(s)",
|
||||
"bulkUpdatesNone": "No updates found for selected {type}(s)",
|
||||
@@ -1944,4 +1964,4 @@
|
||||
"retry": "Retry"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -233,6 +233,7 @@
|
||||
"noCreditRequired": "Sin crédito requerido",
|
||||
"allowSellingGeneratedContent": "Venta permitida",
|
||||
"noTags": "Sin etiquetas",
|
||||
"autoTags": "Etiquetas automáticas",
|
||||
"noBaseModelMatches": "Ningún modelo base coincide con la búsqueda actual.",
|
||||
"clearAll": "Limpiar todos los filtros",
|
||||
"any": "Cualquiera",
|
||||
@@ -640,8 +641,6 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Actualizar lista de modelos",
|
||||
"quick": "Sincronizar cambios",
|
||||
"quickTooltip": "Busca archivos de modelo nuevos o faltantes para mantener la lista al día.",
|
||||
"full": "Reconstruir caché",
|
||||
"fullTooltip": "Vuelve a cargar todos los detalles desde los archivos de metadatos; úsalo si la biblioteca parece desactualizada o tras ediciones manuales."
|
||||
},
|
||||
@@ -687,11 +686,23 @@
|
||||
"autoOrganize": "Auto-organizar seleccionados",
|
||||
"skipMetadataRefresh": "Omitir actualización de metadatos para seleccionados",
|
||||
"resumeMetadataRefresh": "Reanudar actualización de metadatos para seleccionados",
|
||||
"setFavorite": "Marcar como favorito",
|
||||
"setFavoriteCount": "Marcar como favorito ({favorited}/{total})",
|
||||
"unfavorite": "Quitar de favoritos",
|
||||
"deleteAll": "Eliminar seleccionados",
|
||||
"downloadMissingLoras": "Descargar LoRAs faltantes",
|
||||
"downloadExamples": "Descargar imágenes de ejemplo",
|
||||
"clear": "Limpiar selección",
|
||||
"skipMetadataRefreshCount": "Omitir({count} modelos)",
|
||||
"resumeMetadataRefreshCount": "Reanudar({count} modelos)",
|
||||
"sendToWorkflow": "Enviar al workflow",
|
||||
"sections": {
|
||||
"workflow": "Workflow",
|
||||
"metadata": "Metadatos",
|
||||
"attributes": "Atributos",
|
||||
"organize": "Organizar",
|
||||
"download": "Descargar"
|
||||
},
|
||||
"autoOrganizeProgress": {
|
||||
"initializing": "Inicializando auto-organización...",
|
||||
"starting": "Iniciando auto-organización para {type}...",
|
||||
@@ -804,8 +815,6 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Actualizar lista de recetas",
|
||||
"quick": "Sincronizar cambios",
|
||||
"quickTooltip": "Sincronizar cambios - actualización rápida sin reconstruir caché",
|
||||
"full": "Reconstruir caché",
|
||||
"fullTooltip": "Reconstruir caché - reescaneo completo de todos los archivos de recetas"
|
||||
},
|
||||
@@ -1077,6 +1086,12 @@
|
||||
"countMessage": "modelos serán eliminados permanentemente.",
|
||||
"action": "Eliminar todo"
|
||||
},
|
||||
"bulkDeleteRecipes": {
|
||||
"title": "Eliminar múltiples recetas",
|
||||
"message": "¿Estás seguro de que quieres eliminar todas las recetas seleccionadas y sus archivos asociados?",
|
||||
"countMessage": "recetas serán eliminadas permanentemente.",
|
||||
"action": "Eliminar todo"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "¿Comprobar actualizaciones para todos los {typePlural}?",
|
||||
"message": "Esto comprobará las actualizaciones de todos los {typePlural} de tu biblioteca. En colecciones grandes puede tardar un poco más.",
|
||||
@@ -1699,6 +1714,11 @@
|
||||
"bulkContentRatingSet": "Clasificación de contenido establecida en {level} para {count} modelo(s)",
|
||||
"bulkContentRatingPartial": "Clasificación de contenido establecida en {level} para {success} modelo(s), {failed} fallaron",
|
||||
"bulkContentRatingFailed": "No se pudo actualizar la clasificación de contenido para los modelos seleccionados",
|
||||
"bulkFavoriteUpdating": "Añadiendo {count} modelo(s) a favoritos...",
|
||||
"bulkUnfavoriteUpdating": "Eliminando {count} modelo(s) de favoritos...",
|
||||
"bulkFavoritePartialAdded": "{success} modelo(s) añadido(s) a favoritos, {failed} fallido(s)",
|
||||
"bulkFavoritePartialRemoved": "{success} modelo(s) eliminado(s) de favoritos, {failed} fallido(s)",
|
||||
"bulkFavoriteFailed": "Error al actualizar el estado de favorito",
|
||||
"bulkUpdatesChecking": "Comprobando actualizaciones para {type} seleccionados...",
|
||||
"bulkUpdatesSuccess": "Actualizaciones disponibles para {count} {type} seleccionados",
|
||||
"bulkUpdatesNone": "No se encontraron actualizaciones para los {type} seleccionados",
|
||||
|
||||
@@ -233,6 +233,7 @@
|
||||
"noCreditRequired": "Crédit non requis",
|
||||
"allowSellingGeneratedContent": "Vente autorisée",
|
||||
"noTags": "Aucun tag",
|
||||
"autoTags": "Auto-Tags",
|
||||
"noBaseModelMatches": "Aucun modèle de base ne correspond à la recherche actuelle.",
|
||||
"clearAll": "Effacer tous les filtres",
|
||||
"any": "N'importe quel",
|
||||
@@ -640,8 +641,6 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Actualiser la liste des modèles",
|
||||
"quick": "Synchroniser les changements",
|
||||
"quickTooltip": "Analyse les nouveaux fichiers de modèle ou les fichiers manquants pour garder la liste à jour.",
|
||||
"full": "Reconstruire le cache",
|
||||
"fullTooltip": "Recharge tous les détails des modèles depuis les fichiers metadata — à utiliser si la bibliothèque paraît obsolète ou après des modifications manuelles."
|
||||
},
|
||||
@@ -687,11 +686,23 @@
|
||||
"autoOrganize": "Auto-organiser la sélection",
|
||||
"skipMetadataRefresh": "Ignorer l'actualisation des métadonnées pour la sélection",
|
||||
"resumeMetadataRefresh": "Reprendre l'actualisation des métadonnées pour la sélection",
|
||||
"setFavorite": "Définir comme favori",
|
||||
"setFavoriteCount": "Définir comme favori ({favorited}/{total})",
|
||||
"unfavorite": "Retirer des favoris",
|
||||
"deleteAll": "Supprimer la sélection",
|
||||
"downloadMissingLoras": "Télécharger les LoRAs manquants",
|
||||
"downloadExamples": "Télécharger les images d'exemple",
|
||||
"clear": "Effacer la sélection",
|
||||
"skipMetadataRefreshCount": "Ignorer({count} modèles)",
|
||||
"resumeMetadataRefreshCount": "Reprendre({count} modèles)",
|
||||
"sendToWorkflow": "Envoyer au workflow",
|
||||
"sections": {
|
||||
"workflow": "Workflow",
|
||||
"metadata": "Métadonnées",
|
||||
"attributes": "Attributs",
|
||||
"organize": "Organiser",
|
||||
"download": "Télécharger"
|
||||
},
|
||||
"autoOrganizeProgress": {
|
||||
"initializing": "Initialisation de l'auto-organisation...",
|
||||
"starting": "Démarrage de l'auto-organisation pour {type}...",
|
||||
@@ -804,8 +815,6 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Actualiser la liste des recipes",
|
||||
"quick": "Synchroniser les changements",
|
||||
"quickTooltip": "Synchroniser les changements - actualisation rapide sans reconstruire le cache",
|
||||
"full": "Reconstruire le cache",
|
||||
"fullTooltip": "Reconstruire le cache - rescan complet de tous les fichiers de recipes"
|
||||
},
|
||||
@@ -1077,6 +1086,12 @@
|
||||
"countMessage": "modèles seront définitivement supprimés.",
|
||||
"action": "Tout supprimer"
|
||||
},
|
||||
"bulkDeleteRecipes": {
|
||||
"title": "Supprimer plusieurs recipes",
|
||||
"message": "Êtes-vous sûr de vouloir supprimer toutes les recipes sélectionnées et leurs fichiers associés ?",
|
||||
"countMessage": "recipes seront définitivement supprimées.",
|
||||
"action": "Tout supprimer"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "Vérifier les mises à jour pour tous les {typePlural} ?",
|
||||
"message": "Cette action vérifie les mises à jour pour tous les {typePlural} de votre bibliothèque. Les grandes collections peuvent prendre un peu plus de temps.",
|
||||
@@ -1699,6 +1714,11 @@
|
||||
"bulkContentRatingSet": "Classification du contenu définie sur {level} pour {count} modèle(s)",
|
||||
"bulkContentRatingPartial": "Classification du contenu définie sur {level} pour {success} modèle(s), {failed} échec(s)",
|
||||
"bulkContentRatingFailed": "Impossible de mettre à jour la classification du contenu pour les modèles sélectionnés",
|
||||
"bulkFavoriteUpdating": "Ajout de {count} modèle(s) aux favoris...",
|
||||
"bulkUnfavoriteUpdating": "Suppression de {count} modèle(s) des favoris...",
|
||||
"bulkFavoritePartialAdded": "{success} modèle(s) ajouté(s) aux favoris, {failed} échec(s)",
|
||||
"bulkFavoritePartialRemoved": "{success} modèle(s) retiré(s) des favoris, {failed} échec(s)",
|
||||
"bulkFavoriteFailed": "Échec de la mise à jour du statut de favori",
|
||||
"bulkUpdatesChecking": "Vérification des mises à jour pour les {type} sélectionnés...",
|
||||
"bulkUpdatesSuccess": "Mises à jour disponibles pour {count} {type} sélectionnés",
|
||||
"bulkUpdatesNone": "Aucune mise à jour trouvée pour les {type} sélectionnés",
|
||||
|
||||
@@ -233,6 +233,7 @@
|
||||
"noCreditRequired": "ללא קרדיט נדרש",
|
||||
"allowSellingGeneratedContent": "אפשר מכירה",
|
||||
"noTags": "ללא תגיות",
|
||||
"autoTags": "תגיות אוטומטיות",
|
||||
"noBaseModelMatches": "אין מודלי בסיס התואמים לחיפוש הנוכחי.",
|
||||
"clearAll": "נקה את כל המסננים",
|
||||
"any": "כלשהו",
|
||||
@@ -640,8 +641,6 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "רענן רשימת מודלים",
|
||||
"quick": "סנכרון שינויים",
|
||||
"quickTooltip": "סריקה לאיתור קבצי מודל חדשים או חסרים כדי לשמור את הרשימה מעודכנת.",
|
||||
"full": "בניית מטמון מחדש",
|
||||
"fullTooltip": "טוען מחדש את כל פרטי המודלים מקבצי המטא-דאטה – לשימוש אם הספרייה נראית לא מעודכנת או לאחר עריכות ידניות."
|
||||
},
|
||||
@@ -687,11 +686,23 @@
|
||||
"autoOrganize": "ארגן אוטומטית נבחרים",
|
||||
"skipMetadataRefresh": "דילוג על רענון מטא-נתונים לנבחרים",
|
||||
"resumeMetadataRefresh": "המשך רענון מטא-נתונים לנבחרים",
|
||||
"setFavorite": "הגדר כמועדף",
|
||||
"setFavoriteCount": "הגדר כמועדף ({favorited}/{total})",
|
||||
"unfavorite": "הסר ממועדפים",
|
||||
"deleteAll": "מחק נבחרים",
|
||||
"downloadMissingLoras": "הורדת LoRAs חסרים",
|
||||
"downloadExamples": "הורד תמונות דוגמה",
|
||||
"clear": "נקה בחירה",
|
||||
"skipMetadataRefreshCount": "דילוג({count} מודלים)",
|
||||
"resumeMetadataRefreshCount": "המשך({count} מודלים)",
|
||||
"sendToWorkflow": "שלח ל-Workflow",
|
||||
"sections": {
|
||||
"workflow": "Workflow",
|
||||
"metadata": "מטא-נתונים",
|
||||
"attributes": "מאפיינים",
|
||||
"organize": "ארגן",
|
||||
"download": "הורדה"
|
||||
},
|
||||
"autoOrganizeProgress": {
|
||||
"initializing": "מאתחל ארגון אוטומטי...",
|
||||
"starting": "מתחיל ארגון אוטומטי עבור {type}...",
|
||||
@@ -804,8 +815,6 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "רענן רשימת מתכונים",
|
||||
"quick": "סנכרן שינויים",
|
||||
"quickTooltip": "סנכרן שינויים - רענון מהיר ללא בניית מטמון מחדש",
|
||||
"full": "בנה מטמון מחדש",
|
||||
"fullTooltip": "בנה מטמון מחדש - סריקה מחדש מלאה של כל קבצי המתכונים"
|
||||
},
|
||||
@@ -1077,6 +1086,12 @@
|
||||
"countMessage": "מודלים יימחקו לצמיתות.",
|
||||
"action": "מחק הכל"
|
||||
},
|
||||
"bulkDeleteRecipes": {
|
||||
"title": "מחק מספר מתכונים",
|
||||
"message": "האם אתה בטוח שברצונך למחוק את כל המתכונים שנבחרו ואת הקבצים הנלווים אליהם?",
|
||||
"countMessage": "מתכונים יימחקו לצמיתות.",
|
||||
"action": "מחק הכל"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "לבדוק עדכונים לכל ה-{typePlural}?",
|
||||
"message": "הפעולה תבדוק עדכונים עבור כל ה-{typePlural} בספרייה שלך. באוספים גדולים זה עלול לקחת מעט יותר זמן.",
|
||||
@@ -1699,6 +1714,11 @@
|
||||
"bulkContentRatingSet": "דירוג התוכן הוגדר ל-{level} עבור {count} מודלים",
|
||||
"bulkContentRatingPartial": "דירוג התוכן הוגדר ל-{level} עבור {success} מודלים, {failed} נכשלו",
|
||||
"bulkContentRatingFailed": "עדכון דירוג התוכן עבור המודלים שנבחרו נכשל",
|
||||
"bulkFavoriteUpdating": "מוסיף {count} דגמים למועדפים...",
|
||||
"bulkUnfavoriteUpdating": "מסיר {count} דגמים ממועדפים...",
|
||||
"bulkFavoritePartialAdded": "{success} דגמים נוספו למועדפים, {failed} נכשלו",
|
||||
"bulkFavoritePartialRemoved": "{success} דגמים הוסרו ממועדפים, {failed} נכשלו",
|
||||
"bulkFavoriteFailed": "עדכון סטטוס מועדפים נכשל",
|
||||
"bulkUpdatesChecking": "בודק עדכונים עבור {type} שנבחרו...",
|
||||
"bulkUpdatesSuccess": "יש עדכונים עבור {count} {type} שנבחרו",
|
||||
"bulkUpdatesNone": "לא נמצאו עדכונים עבור {type} שנבחרו",
|
||||
|
||||
@@ -233,6 +233,7 @@
|
||||
"noCreditRequired": "クレジット不要",
|
||||
"allowSellingGeneratedContent": "販売許可",
|
||||
"noTags": "タグなし",
|
||||
"autoTags": "自動タグ",
|
||||
"noBaseModelMatches": "現在の検索に一致するベースモデルはありません。",
|
||||
"clearAll": "すべてのフィルタをクリア",
|
||||
"any": "いずれか",
|
||||
@@ -640,8 +641,6 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "モデルリストを更新",
|
||||
"quick": "変更を同期",
|
||||
"quickTooltip": "新しいモデルファイルや欠けているファイルをスキャンして一覧を最新に保ちます。",
|
||||
"full": "キャッシュを再構築",
|
||||
"fullTooltip": "メタデータファイルから全モデル情報を再読み込みします。リストが古いと感じるときや手動編集後に使用してください。"
|
||||
},
|
||||
@@ -687,11 +686,23 @@
|
||||
"autoOrganize": "自動整理を実行",
|
||||
"skipMetadataRefresh": "選択したモデルのメタデータ更新をスキップ",
|
||||
"resumeMetadataRefresh": "選択したモデルのメタデータ更新を再開",
|
||||
"setFavorite": "お気に入りに設定",
|
||||
"setFavoriteCount": "お気に入りに設定 ({favorited}/{total})",
|
||||
"unfavorite": "お気に入りから削除",
|
||||
"deleteAll": "選択したものを削除",
|
||||
"downloadMissingLoras": "不足している LoRA をダウンロード",
|
||||
"downloadExamples": "例画像をダウンロード",
|
||||
"clear": "選択をクリア",
|
||||
"skipMetadataRefreshCount": "スキップ({count}モデル)",
|
||||
"resumeMetadataRefreshCount": "再開({count}モデル)",
|
||||
"sendToWorkflow": "ワークフローに送信",
|
||||
"sections": {
|
||||
"workflow": "ワークフロー",
|
||||
"metadata": "メタデータ",
|
||||
"attributes": "属性",
|
||||
"organize": "整理",
|
||||
"download": "ダウンロード"
|
||||
},
|
||||
"autoOrganizeProgress": {
|
||||
"initializing": "自動整理を初期化中...",
|
||||
"starting": "{type}の自動整理を開始中...",
|
||||
@@ -804,8 +815,6 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "レシピリストを更新",
|
||||
"quick": "変更を同期",
|
||||
"quickTooltip": "変更を同期 - キャッシュを再構築せずにクイック更新",
|
||||
"full": "キャッシュを再構築",
|
||||
"fullTooltip": "キャッシュを再構築 - すべてのレシピファイルを完全に再スキャン"
|
||||
},
|
||||
@@ -1077,6 +1086,12 @@
|
||||
"countMessage": "モデルが完全に削除されます。",
|
||||
"action": "すべて削除"
|
||||
},
|
||||
"bulkDeleteRecipes": {
|
||||
"title": "複数のレシピを削除",
|
||||
"message": "選択したすべてのレシピと関連ファイルを削除してもよろしいですか?",
|
||||
"countMessage": "レシピが完全に削除されます。",
|
||||
"action": "すべて削除"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "すべての{type}の更新を確認しますか?",
|
||||
"message": "ライブラリ内のすべての{type}で更新を確認します。コレクションが大きい場合は時間がかかることがあります。",
|
||||
@@ -1699,6 +1714,11 @@
|
||||
"bulkContentRatingSet": "{count} 件のモデルのコンテンツレーティングを {level} に設定しました",
|
||||
"bulkContentRatingPartial": "{success} 件のモデルのコンテンツレーティングを {level} に設定、{failed} 件は失敗しました",
|
||||
"bulkContentRatingFailed": "選択したモデルのコンテンツレーティングを更新できませんでした",
|
||||
"bulkFavoriteUpdating": "{count} 個のモデルをお気に入りに追加中...",
|
||||
"bulkUnfavoriteUpdating": "{count} 個のモデルをお気に入りから削除中...",
|
||||
"bulkFavoritePartialAdded": "{success} 個のモデルをお気に入りに追加、{failed} 個失敗",
|
||||
"bulkFavoritePartialRemoved": "{success} 個のモデルをお気に入りから削除、{failed} 個失敗",
|
||||
"bulkFavoriteFailed": "お気に入り状態の更新に失敗しました",
|
||||
"bulkUpdatesChecking": "選択された{type}の更新を確認しています...",
|
||||
"bulkUpdatesSuccess": "{count} 件の選択された{type}に利用可能な更新があります",
|
||||
"bulkUpdatesNone": "選択された{type}には更新が見つかりませんでした",
|
||||
|
||||
@@ -233,6 +233,7 @@
|
||||
"noCreditRequired": "크레딧 표기 없음",
|
||||
"allowSellingGeneratedContent": "판매 허용",
|
||||
"noTags": "태그 없음",
|
||||
"autoTags": "자동 태그",
|
||||
"noBaseModelMatches": "현재 검색과 일치하는 베이스 모델이 없습니다.",
|
||||
"clearAll": "모든 필터 지우기",
|
||||
"any": "아무",
|
||||
@@ -640,8 +641,6 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "모델 목록 새로고침",
|
||||
"quick": "변경 사항 동기화",
|
||||
"quickTooltip": "새로운 모델 파일이나 누락된 파일을 찾아 목록을 최신 상태로 유지합니다.",
|
||||
"full": "캐시 재구성",
|
||||
"fullTooltip": "메타데이터 파일에서 모든 모델 정보를 다시 불러옵니다. 라이브러리가 오래되어 보이거나 수동 수정 후에 사용하세요."
|
||||
},
|
||||
@@ -687,11 +686,23 @@
|
||||
"autoOrganize": "자동 정리 선택",
|
||||
"skipMetadataRefresh": "선택한 모델의 메타데이터 새로고침 건너뛰기",
|
||||
"resumeMetadataRefresh": "선택한 모델의 메타데이터 새로고침 재개",
|
||||
"setFavorite": "즐겨찾기로 설정",
|
||||
"setFavoriteCount": "즐겨찾기로 설정 ({favorited}/{total})",
|
||||
"unfavorite": "즐겨찾기 해제",
|
||||
"deleteAll": "선택된 항목 삭제",
|
||||
"downloadMissingLoras": "누락된 LoRA 다운로드",
|
||||
"downloadExamples": "예시 이미지 다운로드",
|
||||
"clear": "선택 지우기",
|
||||
"skipMetadataRefreshCount": "건너뛰기({count}개 모델)",
|
||||
"resumeMetadataRefreshCount": "재개({count}개 모델)",
|
||||
"sendToWorkflow": "워크플로우로 보내기",
|
||||
"sections": {
|
||||
"workflow": "워크플로우",
|
||||
"metadata": "메타데이터",
|
||||
"attributes": "속성",
|
||||
"organize": "정리",
|
||||
"download": "다운로드"
|
||||
},
|
||||
"autoOrganizeProgress": {
|
||||
"initializing": "자동 정리 초기화 중...",
|
||||
"starting": "{type}에 대한 자동 정리 시작...",
|
||||
@@ -804,8 +815,6 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "레시피 목록 새로고침",
|
||||
"quick": "변경 사항 동기화",
|
||||
"quickTooltip": "변경 사항 동기화 - 캐시를 재구성하지 않고 빠른 새로고침",
|
||||
"full": "캐시 재구성",
|
||||
"fullTooltip": "캐시 재구성 - 모든 레시피 파일을 완전히 다시 스캔"
|
||||
},
|
||||
@@ -1077,6 +1086,12 @@
|
||||
"countMessage": "개의 모델이 영구적으로 삭제됩니다.",
|
||||
"action": "모두 삭제"
|
||||
},
|
||||
"bulkDeleteRecipes": {
|
||||
"title": "여러 레시피 삭제",
|
||||
"message": "선택된 모든 레시피와 관련 파일을 삭제하시겠습니까?",
|
||||
"countMessage": "개의 레시피가 영구적으로 삭제됩니다.",
|
||||
"action": "모두 삭제"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "{type} 전체 업데이트를 확인할까요?",
|
||||
"message": "라이브러리에 있는 모든 {type}의 업데이트를 확인합니다. 컬렉션이 클수록 시간이 조금 더 걸릴 수 있습니다.",
|
||||
@@ -1699,6 +1714,11 @@
|
||||
"bulkContentRatingSet": "{count}개 모델의 콘텐츠 등급을 {level}(으)로 설정했습니다",
|
||||
"bulkContentRatingPartial": "{success}개 모델의 콘텐츠 등급을 {level}(으)로 설정했고, {failed}개는 실패했습니다",
|
||||
"bulkContentRatingFailed": "선택한 모델의 콘텐츠 등급을 업데이트하지 못했습니다",
|
||||
"bulkFavoriteUpdating": "{count}개 모델을 즐겨찾기에 추가 중...",
|
||||
"bulkUnfavoriteUpdating": "{count}개 모델을 즐겨찾기에서 제거 중...",
|
||||
"bulkFavoritePartialAdded": "{success}개 모델을 즐겨찾기에 추가, {failed}개 실패",
|
||||
"bulkFavoritePartialRemoved": "{success}개 모델을 즐겨찾기에서 제거, {failed}개 실패",
|
||||
"bulkFavoriteFailed": "즐겨찾기 상태 업데이트 실패",
|
||||
"bulkUpdatesChecking": "선택한 {type}의 업데이트를 확인하는 중...",
|
||||
"bulkUpdatesSuccess": "선택한 {count}개의 {type}에 사용할 수 있는 업데이트가 있습니다",
|
||||
"bulkUpdatesNone": "선택한 {type}에 대한 업데이트가 없습니다",
|
||||
|
||||
@@ -233,6 +233,7 @@
|
||||
"noCreditRequired": "Без указания авторства",
|
||||
"allowSellingGeneratedContent": "Продажа разрешена",
|
||||
"noTags": "Без тегов",
|
||||
"autoTags": "Авто-теги",
|
||||
"noBaseModelMatches": "Нет базовых моделей, соответствующих текущему поиску.",
|
||||
"clearAll": "Очистить все фильтры",
|
||||
"any": "Любой",
|
||||
@@ -640,8 +641,6 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Обновить список моделей",
|
||||
"quick": "Синхронизировать изменения",
|
||||
"quickTooltip": "Находит новые или отсутствующие файлы моделей, чтобы список оставался актуальным.",
|
||||
"full": "Перестроить кэш",
|
||||
"fullTooltip": "Перечитывает все данные моделей из файлов метаданных — используйте, если библиотека выглядит устаревшей или после ручных правок."
|
||||
},
|
||||
@@ -687,11 +686,23 @@
|
||||
"autoOrganize": "Автоматически организовать выбранные",
|
||||
"skipMetadataRefresh": "Пропустить обновление метаданных для выбранных",
|
||||
"resumeMetadataRefresh": "Возобновить обновление метаданных для выбранных",
|
||||
"setFavorite": "Добавить в избранное",
|
||||
"setFavoriteCount": "Добавить в избранное ({favorited}/{total})",
|
||||
"unfavorite": "Удалить из избранного",
|
||||
"deleteAll": "Удалить выбранные",
|
||||
"downloadMissingLoras": "Скачать отсутствующие LoRAs",
|
||||
"downloadExamples": "Загрузить примеры изображений",
|
||||
"clear": "Очистить выбор",
|
||||
"skipMetadataRefreshCount": "Пропустить({count} моделей)",
|
||||
"resumeMetadataRefreshCount": "Возобновить({count} моделей)",
|
||||
"sendToWorkflow": "Отправить в Workflow",
|
||||
"sections": {
|
||||
"workflow": "Workflow",
|
||||
"metadata": "Метаданные",
|
||||
"attributes": "Атрибуты",
|
||||
"organize": "Организовать",
|
||||
"download": "Скачать"
|
||||
},
|
||||
"autoOrganizeProgress": {
|
||||
"initializing": "Инициализация автоматической организации...",
|
||||
"starting": "Запуск автоматической организации для {type}...",
|
||||
@@ -804,8 +815,6 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Обновить список рецептов",
|
||||
"quick": "Синхронизировать изменения",
|
||||
"quickTooltip": "Синхронизировать изменения - быстрое обновление без перестроения кэша",
|
||||
"full": "Перестроить кэш",
|
||||
"fullTooltip": "Перестроить кэш - полное повторное сканирование всех файлов рецептов"
|
||||
},
|
||||
@@ -1077,6 +1086,12 @@
|
||||
"countMessage": "моделей будут удалены навсегда.",
|
||||
"action": "Удалить все"
|
||||
},
|
||||
"bulkDeleteRecipes": {
|
||||
"title": "Удалить несколько рецептов",
|
||||
"message": "Вы уверены, что хотите удалить все выбранные рецепты и связанные с ними файлы?",
|
||||
"countMessage": "рецептов будут удалены навсегда.",
|
||||
"action": "Удалить все"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "Проверить обновления для всех {typePlural}?",
|
||||
"message": "Будут проверены обновления для всех {typePlural} в вашей библиотеке. Для больших коллекций это может занять немного больше времени.",
|
||||
@@ -1699,6 +1714,11 @@
|
||||
"bulkContentRatingSet": "Рейтинг контента установлен на {level} для {count} модель(ей)",
|
||||
"bulkContentRatingPartial": "Рейтинг контента {level} установлен для {success} модель(ей), {failed} не удалось",
|
||||
"bulkContentRatingFailed": "Не удалось обновить рейтинг контента для выбранных моделей",
|
||||
"bulkFavoriteUpdating": "Добавление {count} моделей в избранное...",
|
||||
"bulkUnfavoriteUpdating": "Удаление {count} моделей из избранного...",
|
||||
"bulkFavoritePartialAdded": "{success} моделей добавлено в избранное, {failed} не удалось",
|
||||
"bulkFavoritePartialRemoved": "{success} моделей удалено из избранного, {failed} не удалось",
|
||||
"bulkFavoriteFailed": "Не удалось обновить статус избранного",
|
||||
"bulkUpdatesChecking": "Проверка обновлений для выбранных {type}...",
|
||||
"bulkUpdatesSuccess": "Доступны обновления для {count} выбранных {type}",
|
||||
"bulkUpdatesNone": "Обновления для выбранных {type} не найдены",
|
||||
|
||||
@@ -233,6 +233,7 @@
|
||||
"noCreditRequired": "无需署名",
|
||||
"allowSellingGeneratedContent": "允许销售",
|
||||
"noTags": "无标签",
|
||||
"autoTags": "自动标签",
|
||||
"noBaseModelMatches": "没有基础模型符合当前搜索。",
|
||||
"clearAll": "清除所有筛选",
|
||||
"any": "任一",
|
||||
@@ -640,8 +641,6 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "刷新模型列表",
|
||||
"quick": "同步变更",
|
||||
"quickTooltip": "扫描新的或缺失的模型文件,保持列表最新。",
|
||||
"full": "重建缓存",
|
||||
"fullTooltip": "从元数据文件重新加载所有模型信息;用于列表过时或手动编辑后。"
|
||||
},
|
||||
@@ -687,11 +686,23 @@
|
||||
"autoOrganize": "自动整理所选模型",
|
||||
"skipMetadataRefresh": "跳过所选模型的元数据刷新",
|
||||
"resumeMetadataRefresh": "恢复所选模型的元数据刷新",
|
||||
"setFavorite": "设为收藏",
|
||||
"setFavoriteCount": "设为收藏 ({favorited}/{total})",
|
||||
"unfavorite": "取消收藏",
|
||||
"deleteAll": "删除已选",
|
||||
"downloadMissingLoras": "下载缺失的 LoRAs",
|
||||
"downloadExamples": "下载示例图片",
|
||||
"clear": "清除选择",
|
||||
"skipMetadataRefreshCount": "跳过({count} 个模型)",
|
||||
"resumeMetadataRefreshCount": "恢复({count} 个模型)",
|
||||
"sendToWorkflow": "发送到工作流",
|
||||
"sections": {
|
||||
"workflow": "工作流",
|
||||
"metadata": "元数据",
|
||||
"attributes": "属性",
|
||||
"organize": "整理",
|
||||
"download": "下载"
|
||||
},
|
||||
"autoOrganizeProgress": {
|
||||
"initializing": "正在初始化自动整理...",
|
||||
"starting": "正在为 {type} 启动自动整理...",
|
||||
@@ -804,8 +815,6 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "刷新配方列表",
|
||||
"quick": "同步变更",
|
||||
"quickTooltip": "同步变更 - 快速刷新而不重建缓存",
|
||||
"full": "重建缓存",
|
||||
"fullTooltip": "重建缓存 - 重新扫描所有配方文件"
|
||||
},
|
||||
@@ -1077,6 +1086,12 @@
|
||||
"countMessage": "模型将被永久删除。",
|
||||
"action": "全部删除"
|
||||
},
|
||||
"bulkDeleteRecipes": {
|
||||
"title": "删除多个配方",
|
||||
"message": "你确定要删除所有选中的配方及其相关文件吗?",
|
||||
"countMessage": "配方将被永久删除。",
|
||||
"action": "全部删除"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "检查所有 {type} 的更新?",
|
||||
"message": "这会为库中的每个 {type} 检查更新,大型集合可能需要一些时间。",
|
||||
@@ -1699,6 +1714,11 @@
|
||||
"bulkContentRatingSet": "已将 {count} 个模型的内容评级设置为 {level}",
|
||||
"bulkContentRatingPartial": "已将 {success} 个模型的内容评级设置为 {level},{failed} 个失败",
|
||||
"bulkContentRatingFailed": "未能更新所选模型的内容评级",
|
||||
"bulkFavoriteUpdating": "正在将 {count} 个模型添加到收藏...",
|
||||
"bulkUnfavoriteUpdating": "正在将 {count} 个模型从收藏移除...",
|
||||
"bulkFavoritePartialAdded": "已将 {success} 个模型添加到收藏,{failed} 个失败",
|
||||
"bulkFavoritePartialRemoved": "已将 {success} 个模型从收藏移除,{failed} 个失败",
|
||||
"bulkFavoriteFailed": "更新收藏状态失败",
|
||||
"bulkUpdatesChecking": "正在检查所选 {type} 的更新...",
|
||||
"bulkUpdatesSuccess": "{count} 个所选 {type} 有可用更新",
|
||||
"bulkUpdatesNone": "所选 {type} 未发现更新",
|
||||
|
||||
@@ -233,6 +233,7 @@
|
||||
"noCreditRequired": "無需署名",
|
||||
"allowSellingGeneratedContent": "允許銷售",
|
||||
"noTags": "無標籤",
|
||||
"autoTags": "自動標籤",
|
||||
"noBaseModelMatches": "沒有基礎模型符合目前的搜尋。",
|
||||
"clearAll": "清除所有篩選",
|
||||
"any": "任一",
|
||||
@@ -640,8 +641,6 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "重新整理模型列表",
|
||||
"quick": "同步變更",
|
||||
"quickTooltip": "掃描新的或缺少的模型檔案,讓清單保持最新。",
|
||||
"full": "重建快取",
|
||||
"fullTooltip": "從中繼資料檔重新載入所有模型資訊;適用於清單過時或手動編輯後。"
|
||||
},
|
||||
@@ -687,11 +686,23 @@
|
||||
"autoOrganize": "自動整理所選模型",
|
||||
"skipMetadataRefresh": "跳過所選模型的元數據更新",
|
||||
"resumeMetadataRefresh": "恢復所選模型的元數據更新",
|
||||
"setFavorite": "設為收藏",
|
||||
"setFavoriteCount": "設為收藏 ({favorited}/{total})",
|
||||
"unfavorite": "取消收藏",
|
||||
"deleteAll": "刪除所選",
|
||||
"downloadMissingLoras": "下載缺失的 LoRAs",
|
||||
"downloadExamples": "下載範例圖片",
|
||||
"clear": "清除選取",
|
||||
"skipMetadataRefreshCount": "跳過({count} 個模型)",
|
||||
"resumeMetadataRefreshCount": "恢復({count} 個模型)",
|
||||
"sendToWorkflow": "發送到工作流",
|
||||
"sections": {
|
||||
"workflow": "工作流",
|
||||
"metadata": "元數據",
|
||||
"attributes": "屬性",
|
||||
"organize": "整理",
|
||||
"download": "下載"
|
||||
},
|
||||
"autoOrganizeProgress": {
|
||||
"initializing": "正在初始化自動整理...",
|
||||
"starting": "正在開始自動整理 {type}...",
|
||||
@@ -804,8 +815,6 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "重新整理配方列表",
|
||||
"quick": "同步變更",
|
||||
"quickTooltip": "同步變更 - 快速重新整理而不重建快取",
|
||||
"full": "重建快取",
|
||||
"fullTooltip": "重建快取 - 重新掃描所有配方檔案"
|
||||
},
|
||||
@@ -1077,6 +1086,12 @@
|
||||
"countMessage": "模型將被永久刪除。",
|
||||
"action": "全部刪除"
|
||||
},
|
||||
"bulkDeleteRecipes": {
|
||||
"title": "刪除多個配方",
|
||||
"message": "您確定要刪除所有選取的配方及其相關檔案嗎?",
|
||||
"countMessage": "配方將被永久刪除。",
|
||||
"action": "全部刪除"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "要檢查所有 {type} 的更新嗎?",
|
||||
"message": "這會為資料庫中的每個 {type} 檢查更新,大型收藏可能會花上一些時間。",
|
||||
@@ -1699,6 +1714,11 @@
|
||||
"bulkContentRatingSet": "已將 {count} 個模型的內容分級設定為 {level}",
|
||||
"bulkContentRatingPartial": "已將 {success} 個模型的內容分級設定為 {level},{failed} 個失敗",
|
||||
"bulkContentRatingFailed": "無法更新所選模型的內容分級",
|
||||
"bulkFavoriteUpdating": "正在將 {count} 個模型加入收藏...",
|
||||
"bulkUnfavoriteUpdating": "正在將 {count} 個模型從收藏移除...",
|
||||
"bulkFavoritePartialAdded": "已將 {success} 個模型加入收藏,{failed} 個失敗",
|
||||
"bulkFavoritePartialRemoved": "已將 {success} 個模型從收藏移除,{failed} 個失敗",
|
||||
"bulkFavoriteFailed": "更新收藏狀態失敗",
|
||||
"bulkUpdatesChecking": "正在檢查所選 {type} 的更新...",
|
||||
"bulkUpdatesSuccess": "{count} 個所選 {type} 有可用更新",
|
||||
"bulkUpdatesNone": "所選 {type} 未找到更新",
|
||||
|
||||
96
py/config.py
96
py/config.py
@@ -172,6 +172,12 @@ class Config:
|
||||
self.extra_unet_roots: List[str] = []
|
||||
self.extra_embeddings_roots: List[str] = []
|
||||
self.recipes_path: str = ""
|
||||
|
||||
# Load extra folder paths from active library settings before symlink scan
|
||||
# so both primary and extra paths are discovered in a single pass.
|
||||
if not standalone_mode:
|
||||
self._load_extra_paths_from_settings()
|
||||
|
||||
# Scan symbolic links during initialization
|
||||
self._initialize_symlink_mappings()
|
||||
|
||||
@@ -179,6 +185,96 @@ class Config:
|
||||
# Save the paths to settings.json when running in ComfyUI mode
|
||||
self.save_folder_paths_to_settings()
|
||||
|
||||
def _load_extra_paths_from_settings(self) -> None:
|
||||
"""Read extra folder paths from the active library and apply them.
|
||||
|
||||
Called during ``Config.__init__`` before the symlink scan so both primary and
|
||||
extra paths are discovered in a single pass. Mirrors the extra-path
|
||||
portion of ``_apply_library_paths`` without replacing the primary roots
|
||||
that were already resolved from ComfyUI's ``folder_paths``.
|
||||
"""
|
||||
try:
|
||||
from .services.settings_manager import get_settings_manager
|
||||
|
||||
settings_manager = get_settings_manager()
|
||||
library_name = settings_manager.get_active_library_name()
|
||||
libraries = settings_manager.get_libraries()
|
||||
|
||||
if not library_name or library_name not in libraries:
|
||||
return
|
||||
|
||||
library_config = libraries[library_name]
|
||||
if not isinstance(library_config, dict):
|
||||
return
|
||||
|
||||
extra_folder_paths = library_config.get("extra_folder_paths")
|
||||
if not isinstance(extra_folder_paths, dict):
|
||||
return
|
||||
|
||||
extra_lora = extra_folder_paths.get("loras", []) or []
|
||||
extra_checkpoint = extra_folder_paths.get("checkpoints", []) or []
|
||||
extra_unet = extra_folder_paths.get("unet", []) or []
|
||||
extra_embedding = extra_folder_paths.get("embeddings", []) or []
|
||||
|
||||
if not any([extra_lora, extra_checkpoint, extra_unet, extra_embedding]):
|
||||
return
|
||||
|
||||
filtered_extra_lora = self._filter_overlapping_extra_lora_paths(
|
||||
self.loras_roots, extra_lora
|
||||
)
|
||||
self.extra_loras_roots = self._prepare_lora_paths(filtered_extra_lora)
|
||||
(
|
||||
_,
|
||||
self.extra_checkpoints_roots,
|
||||
self.extra_unet_roots,
|
||||
) = self._prepare_checkpoint_paths(extra_checkpoint, extra_unet)
|
||||
self.extra_embeddings_roots = self._prepare_embedding_paths(
|
||||
extra_embedding
|
||||
)
|
||||
|
||||
recipes_path = library_config.get("recipes_path", "")
|
||||
if isinstance(recipes_path, str) and recipes_path:
|
||||
self.recipes_path = recipes_path
|
||||
|
||||
if self.extra_loras_roots:
|
||||
logger.info(
|
||||
"Found extra LoRA roots:"
|
||||
+ "\n - "
|
||||
+ "\n - ".join(self.extra_loras_roots)
|
||||
)
|
||||
if self.extra_checkpoints_roots:
|
||||
logger.info(
|
||||
"Found extra checkpoint roots:"
|
||||
+ "\n - "
|
||||
+ "\n - ".join(self.extra_checkpoints_roots)
|
||||
)
|
||||
if self.extra_unet_roots:
|
||||
logger.info(
|
||||
"Found extra diffusion model roots:"
|
||||
+ "\n - "
|
||||
+ "\n - ".join(self.extra_unet_roots)
|
||||
)
|
||||
if self.extra_embeddings_roots:
|
||||
logger.info(
|
||||
"Found extra embedding roots:"
|
||||
+ "\n - "
|
||||
+ "\n - ".join(self.extra_embeddings_roots)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Applied library settings for '%s' with extra paths: loras=%s, "
|
||||
"checkpoints=%s, embeddings=%s",
|
||||
library_name,
|
||||
extra_lora,
|
||||
extra_checkpoint,
|
||||
extra_embedding,
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Could not load extra paths from library settings: %s", exc
|
||||
)
|
||||
|
||||
def save_folder_paths_to_settings(self):
|
||||
"""Persist ComfyUI-derived folder paths to the multi-library settings."""
|
||||
try:
|
||||
|
||||
@@ -184,39 +184,6 @@ class LoraManager:
|
||||
async def _initialize_services(cls):
|
||||
"""Initialize all services using the ServiceRegistry"""
|
||||
try:
|
||||
# Apply library settings to load extra folder paths before scanning
|
||||
# Only apply if extra paths haven't been loaded yet (preserves test mocks)
|
||||
try:
|
||||
from .services.settings_manager import get_settings_manager
|
||||
|
||||
settings_manager = get_settings_manager()
|
||||
library_name = settings_manager.get_active_library_name()
|
||||
libraries = settings_manager.get_libraries()
|
||||
if library_name and library_name in libraries:
|
||||
library_config = libraries[library_name]
|
||||
# Only apply settings if extra paths are not already configured
|
||||
# This preserves values set by tests via monkeypatch
|
||||
extra_paths = library_config.get("extra_folder_paths", {})
|
||||
has_extra_paths = (
|
||||
config.extra_loras_roots
|
||||
or config.extra_checkpoints_roots
|
||||
or config.extra_unet_roots
|
||||
or config.extra_embeddings_roots
|
||||
)
|
||||
if not has_extra_paths and any(extra_paths.values()):
|
||||
config.apply_library_settings(library_config)
|
||||
logger.info(
|
||||
"Applied library settings for '%s' with extra paths: loras=%s, checkpoints=%s, embeddings=%s",
|
||||
library_name,
|
||||
extra_paths.get("loras", []),
|
||||
extra_paths.get("checkpoints", []),
|
||||
extra_paths.get("embeddings", []),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to apply library settings during initialization: %s", exc
|
||||
)
|
||||
|
||||
# Initialize CivitaiClient first to ensure it's ready for other services
|
||||
await ServiceRegistry.get_civitai_client()
|
||||
|
||||
|
||||
@@ -16,55 +16,65 @@ class RecipeEnricher:
|
||||
async def enrich_recipe(
|
||||
recipe: Dict[str, Any],
|
||||
civitai_client: Any,
|
||||
request_params: Optional[Dict[str, Any]] = None
|
||||
request_params: Optional[Dict[str, Any]] = None,
|
||||
prefetched_civitai_meta_raw: Optional[Dict[str, Any]] = None,
|
||||
prefetched_model_version_id: Optional[int] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Enrich a recipe dictionary in-place with metadata from Civitai and embedded params.
|
||||
|
||||
|
||||
Args:
|
||||
recipe: The recipe dictionary to enrich. Must have 'gen_params' initialized.
|
||||
civitai_client: Authenticated Civitai client instance.
|
||||
request_params: (Optional) Parameters from a user request (e.g. import).
|
||||
|
||||
prefetched_civitai_meta_raw: (Optional) Pre-fetched raw meta from Civitai
|
||||
get_image_info, avoiding a duplicate API call.
|
||||
prefetched_model_version_id: (Optional) Pre-fetched model version ID.
|
||||
|
||||
Returns:
|
||||
bool: True if the recipe was modified, False otherwise.
|
||||
"""
|
||||
updated = False
|
||||
gen_params = recipe.get("gen_params", {})
|
||||
|
||||
# 1. Fetch Civitai Info if available
|
||||
|
||||
# 1. Obtain Civitai metadata
|
||||
civitai_meta = None
|
||||
model_version_id = None
|
||||
|
||||
source_url = recipe.get("source_url") or recipe.get("source_path", "")
|
||||
|
||||
# Check if it's a Civitai image URL
|
||||
image_id = extract_civitai_image_id(str(source_url))
|
||||
if image_id:
|
||||
try:
|
||||
image_info = await civitai_client.get_image_info(
|
||||
image_id, source_url=str(source_url)
|
||||
)
|
||||
if image_info:
|
||||
# Handle nested meta often found in Civitai API responses
|
||||
raw_meta = image_info.get("meta")
|
||||
if isinstance(raw_meta, dict):
|
||||
if "meta" in raw_meta and isinstance(raw_meta["meta"], dict):
|
||||
civitai_meta = raw_meta["meta"]
|
||||
else:
|
||||
civitai_meta = raw_meta
|
||||
|
||||
model_version_id = image_info.get("modelVersionId")
|
||||
|
||||
# If not at top level, check resources in meta
|
||||
if not model_version_id and civitai_meta:
|
||||
resources = civitai_meta.get("civitaiResources", [])
|
||||
for res in resources:
|
||||
if res.get("type") == "checkpoint":
|
||||
model_version_id = res.get("modelVersionId")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch Civitai image info: {e}")
|
||||
model_version_id = prefetched_model_version_id
|
||||
|
||||
source_path = recipe.get("source_path", "")
|
||||
|
||||
if prefetched_civitai_meta_raw is not None:
|
||||
raw_meta = prefetched_civitai_meta_raw
|
||||
if isinstance(raw_meta, dict):
|
||||
if "meta" in raw_meta and isinstance(raw_meta["meta"], dict):
|
||||
civitai_meta = raw_meta["meta"]
|
||||
else:
|
||||
civitai_meta = raw_meta
|
||||
else:
|
||||
image_id = extract_civitai_image_id(str(source_path))
|
||||
if image_id:
|
||||
try:
|
||||
image_info = await civitai_client.get_image_info(
|
||||
image_id, source_url=str(source_path)
|
||||
)
|
||||
if image_info:
|
||||
raw_meta = image_info.get("meta")
|
||||
if isinstance(raw_meta, dict):
|
||||
if "meta" in raw_meta and isinstance(raw_meta["meta"], dict):
|
||||
civitai_meta = raw_meta["meta"]
|
||||
else:
|
||||
civitai_meta = raw_meta
|
||||
|
||||
model_version_id = image_info.get("modelVersionId")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch Civitai image info: {e}")
|
||||
|
||||
if not model_version_id and civitai_meta:
|
||||
resources = civitai_meta.get("civitaiResources", [])
|
||||
for res in resources:
|
||||
if res.get("type") == "checkpoint":
|
||||
model_version_id = res.get("modelVersionId")
|
||||
break
|
||||
|
||||
# 2. Merge Parameters
|
||||
# Priority: request_params > civitai_meta > embedded (existing gen_params)
|
||||
|
||||
@@ -2065,7 +2065,7 @@ class ModelLibraryHandler:
|
||||
file_path=file_path if isinstance(file_path, str) else None,
|
||||
)
|
||||
else:
|
||||
await history_service.mark_not_downloaded(model_type, model_version_id)
|
||||
await history_service.mark_as_deleted(model_type, model_version_id)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
@@ -2139,8 +2139,19 @@ class ModelLibraryHandler:
|
||||
]
|
||||
await found_cache.resort()
|
||||
|
||||
scanner_map = {
|
||||
"lora": lora_scanner,
|
||||
"checkpoint": checkpoint_scanner,
|
||||
"embedding": embedding_scanner,
|
||||
}
|
||||
scanner = scanner_map.get(found_type)
|
||||
if scanner:
|
||||
persist = getattr(scanner, "_persist_current_cache", None)
|
||||
if callable(persist):
|
||||
await persist()
|
||||
|
||||
history_service = await self._get_download_history_service()
|
||||
await history_service.mark_not_downloaded(found_type, model_version_id)
|
||||
await history_service.mark_as_deleted(found_type, model_version_id)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
|
||||
@@ -301,6 +301,15 @@ class ModelListingHandler:
|
||||
for tag in exclude_tags:
|
||||
if tag:
|
||||
tag_filters[tag] = "exclude"
|
||||
|
||||
auto_tag_filters: Dict[str, str] = {}
|
||||
for tag in request.query.getall("auto_tag_include", []):
|
||||
if tag:
|
||||
auto_tag_filters[tag] = "include"
|
||||
for tag in request.query.getall("auto_tag_exclude", []):
|
||||
if tag:
|
||||
auto_tag_filters[tag] = "exclude"
|
||||
|
||||
favorites_only = request.query.get("favorites_only", "false").lower() == "true"
|
||||
|
||||
search_options = {
|
||||
@@ -367,6 +376,7 @@ class ModelListingHandler:
|
||||
"fuzzy_search": fuzzy_search,
|
||||
"base_models": base_models,
|
||||
"tags": tag_filters,
|
||||
"auto_tags": auto_tag_filters,
|
||||
"tag_logic": tag_logic,
|
||||
"search_options": search_options,
|
||||
"hash_filters": hash_filters,
|
||||
|
||||
@@ -93,6 +93,8 @@ class RecipeHandlerSet:
|
||||
"cancel_batch_import": self.batch_import.cancel_batch_import,
|
||||
"start_directory_import": self.batch_import.start_directory_import,
|
||||
"browse_directory": self.batch_import.browse_directory,
|
||||
"check_image_exists": self.management.check_image_exists,
|
||||
"import_from_url": self.management.import_from_url,
|
||||
}
|
||||
|
||||
|
||||
@@ -541,7 +543,7 @@ class RecipeQueryHandler:
|
||||
)
|
||||
response_data.append(
|
||||
{
|
||||
"type": "source_url",
|
||||
"type": "source_path",
|
||||
"fingerprint": url,
|
||||
"count": len(recipes),
|
||||
"recipes": recipes,
|
||||
@@ -607,6 +609,7 @@ class RecipeManagementHandler:
|
||||
self._downloader_factory = downloader_factory
|
||||
self._civitai_client_getter = civitai_client_getter
|
||||
self._ws_manager = ws_manager
|
||||
self._import_semaphore = asyncio.Semaphore(2)
|
||||
|
||||
async def save_recipe(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
@@ -760,125 +763,28 @@ class RecipeManagementHandler:
|
||||
gen_params_request = self._parse_gen_params(params.get("gen_params"))
|
||||
|
||||
self._logger.info(
|
||||
"Remote recipe import received: url=%s, request_gen_params_keys=%s, lora_count=%d, checkpoint_keys=%s",
|
||||
"Remote recipe import received: url=%s, lora_count=%d",
|
||||
image_url,
|
||||
sorted(gen_params_request.keys()) if gen_params_request else [],
|
||||
len(lora_entries),
|
||||
)
|
||||
self._logger.debug(
|
||||
" gen_params_keys=%s, checkpoint_keys=%s",
|
||||
sorted(gen_params_request.keys()) if gen_params_request else [],
|
||||
sorted(checkpoint_entry.keys()) if isinstance(checkpoint_entry, dict) else [],
|
||||
)
|
||||
|
||||
# 2. Initial Metadata Construction
|
||||
metadata: Dict[str, Any] = {
|
||||
"base_model": params.get("base_model", "") or "",
|
||||
"loras": lora_entries,
|
||||
"gen_params": gen_params_request or {},
|
||||
"source_url": image_url,
|
||||
}
|
||||
|
||||
source_path = params.get("source_path")
|
||||
if source_path:
|
||||
metadata["source_path"] = source_path
|
||||
|
||||
# Checkpoint handling
|
||||
if checkpoint_entry:
|
||||
metadata["checkpoint"] = checkpoint_entry
|
||||
# Ensure checkpoint is also in gen_params for consistency if needed by enricher?
|
||||
# Actually enricher looks at metadata['checkpoint'], so this is fine.
|
||||
|
||||
# Try to resolve base model from checkpoint if not explicitly provided
|
||||
if not metadata["base_model"]:
|
||||
base_model_from_metadata = (
|
||||
await self._resolve_base_model_from_checkpoint(checkpoint_entry)
|
||||
)
|
||||
if base_model_from_metadata:
|
||||
metadata["base_model"] = base_model_from_metadata
|
||||
|
||||
tags = self._parse_tags(params.get("tags"))
|
||||
|
||||
# 3. Download Image
|
||||
(
|
||||
image_bytes,
|
||||
extension,
|
||||
civitai_meta_from_download,
|
||||
) = await self._download_remote_media(image_url)
|
||||
|
||||
# 4. Extract Embedded Metadata
|
||||
# Note: We still extract this here because Enricher currently expects 'gen_params' to already be populated
|
||||
# with embedded data if we want it to merge it.
|
||||
# However, logic in Enricher merges: request > civitai > embedded.
|
||||
# So we should gather embedded params and put them into the recipe's gen_params (as initial state)
|
||||
# OR pass them to enricher to handle?
|
||||
# The interface of Enricher.enrich_recipe takes `recipe` (with gen_params) and `request_params`.
|
||||
# So let's extract embedded and put it into recipe['gen_params'] but careful not to overwrite request params.
|
||||
# Actually, `GenParamsMerger` which `Enricher` uses handles 3 layers.
|
||||
# But `Enricher` interface is: recipe['gen_params'] (as embedded) + request_params + civitai (fetched internally).
|
||||
# Wait, `Enricher` fetches Civitai info internally based on URL.
|
||||
# `civitai_meta_from_download` is returned by `_download_remote_media` which might be useful if URL didn't have ID.
|
||||
|
||||
# Let's extract embedded metadata first
|
||||
embedded_gen_params = {}
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=extension, delete=False
|
||||
) as temp_img:
|
||||
temp_img.write(image_bytes)
|
||||
temp_img_path = temp_img.name
|
||||
|
||||
try:
|
||||
raw_embedded = ExifUtils.extract_image_metadata(temp_img_path)
|
||||
if raw_embedded:
|
||||
parser = (
|
||||
self._analysis_service._recipe_parser_factory.create_parser(
|
||||
raw_embedded
|
||||
)
|
||||
)
|
||||
if parser:
|
||||
parsed_embedded = await parser.parse_metadata(
|
||||
raw_embedded, recipe_scanner=recipe_scanner
|
||||
)
|
||||
if parsed_embedded and "gen_params" in parsed_embedded:
|
||||
embedded_gen_params = parsed_embedded["gen_params"]
|
||||
else:
|
||||
embedded_gen_params = {"raw_metadata": raw_embedded}
|
||||
finally:
|
||||
if os.path.exists(temp_img_path):
|
||||
os.unlink(temp_img_path)
|
||||
except Exception as exc:
|
||||
self._logger.warning(
|
||||
"Failed to extract embedded metadata during import: %s", exc
|
||||
# Throttle concurrent imports to avoid starving ComfyUI's event loop
|
||||
async with self._import_semaphore:
|
||||
return await self._do_import_remote_recipe(
|
||||
image_url=image_url,
|
||||
name=name,
|
||||
lora_entries=lora_entries,
|
||||
checkpoint_entry=checkpoint_entry,
|
||||
gen_params_request=gen_params_request,
|
||||
tags=self._parse_tags(params.get("tags")),
|
||||
base_model=params.get("base_model", "") or "",
|
||||
source_path=params.get("source_path") or image_url,
|
||||
)
|
||||
|
||||
# Pre-populate gen_params with embedded data so Enricher treats it as the "base" layer
|
||||
if embedded_gen_params:
|
||||
# Merge embedded into existing gen_params (which currently only has request params if any)
|
||||
# But wait, we want request params to override everything.
|
||||
# So we should set recipe['gen_params'] = embedded, and pass request params to enricher.
|
||||
metadata["gen_params"] = embedded_gen_params
|
||||
|
||||
# 5. Enrich with unified logic
|
||||
# This will fetch Civitai info (if URL matches) and merge: request > civitai > embedded
|
||||
civitai_client = self._civitai_client_getter()
|
||||
await RecipeEnricher.enrich_recipe(
|
||||
recipe=metadata,
|
||||
civitai_client=civitai_client,
|
||||
request_params=gen_params_request, # Pass explicit request params here to override
|
||||
)
|
||||
|
||||
# If we got civitai_meta from download but Enricher didn't fetch it (e.g. not a civitai URL or failed),
|
||||
# we might want to manually merge it?
|
||||
# But usually `import_remote_recipe` is used with Civitai URLs.
|
||||
# For now, relying on Enricher's internal fetch is consistent with repair.
|
||||
|
||||
result = await self._persistence_service.save_recipe(
|
||||
recipe_scanner=recipe_scanner,
|
||||
image_bytes=image_bytes,
|
||||
image_base64=None,
|
||||
name=name,
|
||||
tags=tags,
|
||||
metadata=metadata,
|
||||
extension=extension,
|
||||
)
|
||||
return web.json_response(result.payload, status=result.status)
|
||||
except RecipeValidationError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=400)
|
||||
except RecipeDownloadError as exc:
|
||||
@@ -889,6 +795,150 @@ class RecipeManagementHandler:
|
||||
)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
async def _do_import_remote_recipe(
|
||||
self,
|
||||
*,
|
||||
image_url: str,
|
||||
name: str,
|
||||
lora_entries: list,
|
||||
checkpoint_entry: dict,
|
||||
gen_params_request: dict,
|
||||
tags: list,
|
||||
base_model: str,
|
||||
source_path: str,
|
||||
) -> web.Response:
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
metadata: Dict[str, Any] = {
|
||||
"base_model": base_model,
|
||||
"loras": lora_entries,
|
||||
"gen_params": gen_params_request or {},
|
||||
"source_path": source_path,
|
||||
}
|
||||
|
||||
if checkpoint_entry:
|
||||
metadata["checkpoint"] = checkpoint_entry
|
||||
if not metadata["base_model"]:
|
||||
base_model_from_metadata = (
|
||||
await self._resolve_base_model_from_checkpoint(checkpoint_entry)
|
||||
)
|
||||
if base_model_from_metadata:
|
||||
metadata["base_model"] = base_model_from_metadata
|
||||
|
||||
# Download image
|
||||
(
|
||||
image_bytes,
|
||||
extension,
|
||||
civitai_meta_raw,
|
||||
model_version_id,
|
||||
) = await self._download_remote_media(image_url)
|
||||
|
||||
# Extract embedded EXIF metadata (offloaded to thread pool in this call)
|
||||
embedded_gen_params = {}
|
||||
parsed_embedded = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=extension, delete=False
|
||||
) as temp_img:
|
||||
temp_img.write(image_bytes)
|
||||
temp_img_path = temp_img.name
|
||||
|
||||
try:
|
||||
raw_embedded = await asyncio.to_thread(
|
||||
ExifUtils.extract_image_metadata, temp_img_path
|
||||
)
|
||||
if raw_embedded:
|
||||
parser = (
|
||||
self._analysis_service._recipe_parser_factory.create_parser(
|
||||
raw_embedded
|
||||
)
|
||||
)
|
||||
if parser:
|
||||
parsed_embedded = await parser.parse_metadata(
|
||||
raw_embedded, recipe_scanner=recipe_scanner
|
||||
)
|
||||
if parsed_embedded and "gen_params" in parsed_embedded:
|
||||
embedded_gen_params = parsed_embedded["gen_params"]
|
||||
else:
|
||||
embedded_gen_params = {"raw_metadata": raw_embedded}
|
||||
finally:
|
||||
if os.path.exists(temp_img_path):
|
||||
os.unlink(temp_img_path)
|
||||
except Exception as exc:
|
||||
self._logger.warning(
|
||||
"Failed to extract embedded metadata during import: %s", exc
|
||||
)
|
||||
|
||||
# Parse CivitAI API meta to discover all resources from modelVersionIds
|
||||
# (modelVersionIds is injected at root level by _download_remote_media).
|
||||
# Run unconditionally — EXIF parsing may succeed for gen_params but miss
|
||||
# LoRAs since modelVersionIds is NOT embedded in the image EXIF.
|
||||
civitai_parsed = None
|
||||
if civitai_meta_raw:
|
||||
civitai_inner_meta = civitai_meta_raw
|
||||
if isinstance(civitai_meta_raw, dict) and "meta" in civitai_meta_raw:
|
||||
civitai_inner_meta = civitai_meta_raw["meta"]
|
||||
# modelVersionIds lives at outer meta level; propagate after unwrap
|
||||
_mvids = civitai_meta_raw.get("modelVersionIds")
|
||||
if _mvids and isinstance(civitai_inner_meta, dict):
|
||||
civitai_inner_meta["modelVersionIds"] = _mvids
|
||||
if isinstance(civitai_inner_meta, dict):
|
||||
parser = self._analysis_service._recipe_parser_factory.create_parser(
|
||||
civitai_inner_meta
|
||||
)
|
||||
if parser:
|
||||
civitai_parsed = await parser.parse_metadata(
|
||||
civitai_inner_meta, recipe_scanner=recipe_scanner
|
||||
)
|
||||
if civitai_parsed and "gen_params" in civitai_parsed:
|
||||
# Merge: API gen_params override EXIF at field level,
|
||||
# EXIF fills in fields the API doesn't have.
|
||||
embedded_gen_params = {
|
||||
**(embedded_gen_params or {}),
|
||||
**civitai_parsed["gen_params"],
|
||||
}
|
||||
|
||||
if embedded_gen_params:
|
||||
metadata["gen_params"] = embedded_gen_params
|
||||
|
||||
# Merge LoRAs: prefer frontend resources, supplement with CivitAI modelVersionIds
|
||||
if civitai_parsed:
|
||||
civitai_loras = civitai_parsed.get("loras", [])
|
||||
if civitai_loras and not metadata.get("loras"):
|
||||
metadata["loras"] = civitai_loras
|
||||
civitai_model = civitai_parsed.get("model")
|
||||
if civitai_model and not metadata.get("checkpoint"):
|
||||
metadata["checkpoint"] = civitai_model
|
||||
elif parsed_embedded:
|
||||
parsed_loras = parsed_embedded.get("loras")
|
||||
if parsed_loras and not metadata.get("loras"):
|
||||
metadata["loras"] = parsed_loras
|
||||
parsed_model = parsed_embedded.get("model")
|
||||
if parsed_model and not metadata.get("checkpoint"):
|
||||
metadata["checkpoint"] = parsed_model
|
||||
|
||||
civitai_client = self._civitai_client_getter()
|
||||
await RecipeEnricher.enrich_recipe(
|
||||
recipe=metadata,
|
||||
civitai_client=civitai_client,
|
||||
request_params=gen_params_request,
|
||||
prefetched_civitai_meta_raw=civitai_meta_raw,
|
||||
prefetched_model_version_id=model_version_id,
|
||||
)
|
||||
|
||||
result = await self._persistence_service.save_recipe(
|
||||
recipe_scanner=recipe_scanner,
|
||||
image_bytes=image_bytes,
|
||||
image_base64=None,
|
||||
name=name,
|
||||
tags=tags,
|
||||
metadata=metadata,
|
||||
extension=extension,
|
||||
)
|
||||
return web.json_response(result.payload, status=result.status)
|
||||
|
||||
async def delete_recipe(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
@@ -1190,7 +1240,7 @@ class RecipeManagementHandler:
|
||||
"exclude": False,
|
||||
}
|
||||
|
||||
async def _download_remote_media(self, image_url: str) -> tuple[bytes, str, Any]:
|
||||
async def _download_remote_media(self, image_url: str) -> tuple[bytes, str, Any, Any]:
|
||||
civitai_client = self._civitai_client_getter()
|
||||
downloader = await self._downloader_factory()
|
||||
temp_path = None
|
||||
@@ -1238,10 +1288,31 @@ class RecipeManagementHandler:
|
||||
extension = ".webp" # Default to webp if unknown
|
||||
|
||||
with open(temp_path, "rb") as file_obj:
|
||||
model_ver_id = None
|
||||
civitai_meta_raw = (
|
||||
image_info.get("meta") if civitai_image_id and image_info else None
|
||||
)
|
||||
if civitai_image_id and image_info:
|
||||
model_ver_id = image_info.get("modelVersionId")
|
||||
if not model_ver_id:
|
||||
ids = image_info.get("modelVersionIds")
|
||||
if isinstance(ids, list) and ids:
|
||||
model_ver_id = ids[0]
|
||||
|
||||
# Inject root-level modelVersionIds into meta so downstream
|
||||
# parsers (CivitaiApiMetadataParser) can discover ALL resources
|
||||
# (checkpoint + LoRAs), not just the first model version ID.
|
||||
# CivitAI API returns modelVersionIds at the root level of
|
||||
# the image response, NOT inside the meta object.
|
||||
mvids = image_info.get("modelVersionIds")
|
||||
if mvids and isinstance(civitai_meta_raw, dict):
|
||||
civitai_meta_raw["modelVersionIds"] = mvids
|
||||
|
||||
return (
|
||||
file_obj.read(),
|
||||
extension,
|
||||
image_info.get("meta") if civitai_image_id and image_info else None,
|
||||
civitai_meta_raw,
|
||||
model_ver_id,
|
||||
)
|
||||
except RecipeDownloadError:
|
||||
raise
|
||||
@@ -1289,6 +1360,226 @@ class RecipeManagementHandler:
|
||||
|
||||
return ""
|
||||
|
||||
async def check_image_exists(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
image_ids_raw = request.query.get("image_ids", "")
|
||||
if not image_ids_raw:
|
||||
return web.json_response({"success": True, "results": {}})
|
||||
|
||||
requested_ids = set()
|
||||
for raw in image_ids_raw.split(","):
|
||||
stripped = raw.strip()
|
||||
if stripped and stripped.isdigit():
|
||||
requested_ids.add(stripped)
|
||||
|
||||
if not requested_ids:
|
||||
return web.json_response({"success": True, "results": {}})
|
||||
|
||||
cache = await recipe_scanner.get_cached_data()
|
||||
|
||||
# Build lookup: image_id -> recipe_id from stored source_path
|
||||
image_to_recipe = {}
|
||||
for recipe in getattr(cache, "raw_data", []):
|
||||
source = recipe.get("source_path")
|
||||
if not source:
|
||||
continue
|
||||
image_id = extract_civitai_image_id(source)
|
||||
if image_id and image_id not in image_to_recipe:
|
||||
image_to_recipe[image_id] = recipe.get("id")
|
||||
|
||||
results = {}
|
||||
for img_id in requested_ids:
|
||||
recipe_id = image_to_recipe.get(img_id)
|
||||
results[img_id] = {
|
||||
"in_library": recipe_id is not None,
|
||||
"recipe_id": recipe_id,
|
||||
}
|
||||
|
||||
return web.json_response({"success": True, "results": results})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error checking image existence: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
async def import_from_url(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
image_url = request.query.get("image_url")
|
||||
if not image_url:
|
||||
raise RecipeValidationError("Missing required field: image_url")
|
||||
|
||||
image_id = extract_civitai_image_id(image_url)
|
||||
if not image_id:
|
||||
raise RecipeValidationError(
|
||||
"Could not extract Civitai image ID from URL"
|
||||
)
|
||||
|
||||
# Check for duplicate (fast, before acquiring semaphore)
|
||||
cache = await recipe_scanner.get_cached_data()
|
||||
for recipe in getattr(cache, "raw_data", []):
|
||||
source = recipe.get("source_path")
|
||||
if source:
|
||||
existing_id = extract_civitai_image_id(source)
|
||||
if existing_id == image_id:
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"recipe_id": recipe.get("id"),
|
||||
"name": recipe.get("title", ""),
|
||||
"already_exists": True,
|
||||
})
|
||||
|
||||
async with self._import_semaphore:
|
||||
return await self._do_import_from_url(image_url, recipe_scanner)
|
||||
except RecipeValidationError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=400)
|
||||
except RecipeDownloadError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=400)
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error importing recipe from URL: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
async def _do_import_from_url(
|
||||
self,
|
||||
image_url: str,
|
||||
recipe_scanner: Any,
|
||||
) -> web.Response:
|
||||
image_id = extract_civitai_image_id(image_url)
|
||||
if not image_id:
|
||||
raise RecipeValidationError(
|
||||
"Could not extract Civitai image ID from URL"
|
||||
)
|
||||
|
||||
image_bytes, extension, civitai_meta_raw, model_version_id = (
|
||||
await self._download_remote_media(image_url)
|
||||
)
|
||||
|
||||
# Extract embedded EXIF metadata
|
||||
embedded_gen_params = {}
|
||||
parsed_embedded = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=extension, delete=False
|
||||
) as temp_img:
|
||||
temp_img.write(image_bytes)
|
||||
temp_img_path = temp_img.name
|
||||
|
||||
try:
|
||||
raw_embedded = await asyncio.to_thread(
|
||||
ExifUtils.extract_image_metadata, temp_img_path
|
||||
)
|
||||
if raw_embedded:
|
||||
parser = (
|
||||
self._analysis_service._recipe_parser_factory.create_parser(
|
||||
raw_embedded
|
||||
)
|
||||
)
|
||||
if parser:
|
||||
parsed_embedded = await parser.parse_metadata(
|
||||
raw_embedded, recipe_scanner=recipe_scanner
|
||||
)
|
||||
if parsed_embedded and "gen_params" in parsed_embedded:
|
||||
embedded_gen_params = parsed_embedded["gen_params"]
|
||||
finally:
|
||||
if os.path.exists(temp_img_path):
|
||||
os.unlink(temp_img_path)
|
||||
except Exception as exc:
|
||||
self._logger.warning(
|
||||
"Failed to extract embedded metadata: %s", exc
|
||||
)
|
||||
|
||||
# Parse CivitAI API meta to discover all resources from modelVersionIds.
|
||||
# Run unconditionally — EXIF parsing succeeds for gen_params but misses
|
||||
# LoRAs (modelVersionIds is NOT in the image EXIF).
|
||||
civitai_parsed = None
|
||||
if civitai_meta_raw:
|
||||
civitai_inner_meta = civitai_meta_raw
|
||||
if isinstance(civitai_meta_raw, dict) and "meta" in civitai_meta_raw:
|
||||
civitai_inner_meta = civitai_meta_raw["meta"]
|
||||
# Propagate modelVersionIds into unwrapped meta — it lives
|
||||
# at the outer meta level in the CivitAI API response.
|
||||
_mvids = civitai_meta_raw.get("modelVersionIds")
|
||||
if _mvids and isinstance(civitai_inner_meta, dict):
|
||||
civitai_inner_meta["modelVersionIds"] = _mvids
|
||||
if isinstance(civitai_inner_meta, dict):
|
||||
parser = self._analysis_service._recipe_parser_factory.create_parser(
|
||||
civitai_inner_meta
|
||||
)
|
||||
if parser:
|
||||
civitai_parsed = await parser.parse_metadata(
|
||||
civitai_inner_meta, recipe_scanner=recipe_scanner
|
||||
)
|
||||
if civitai_parsed and "gen_params" in civitai_parsed:
|
||||
# Merge: API gen_params override EXIF at field level,
|
||||
# EXIF fills in fields the API doesn't have.
|
||||
embedded_gen_params = {
|
||||
**(embedded_gen_params or {}),
|
||||
**civitai_parsed["gen_params"],
|
||||
}
|
||||
|
||||
metadata: Dict[str, Any] = {
|
||||
"base_model": "",
|
||||
"loras": [],
|
||||
"gen_params": embedded_gen_params or {},
|
||||
"source_path": image_url,
|
||||
}
|
||||
|
||||
if civitai_parsed:
|
||||
civitai_loras = civitai_parsed.get("loras", [])
|
||||
if civitai_loras and not metadata.get("loras"):
|
||||
metadata["loras"] = civitai_loras
|
||||
civitai_model = civitai_parsed.get("model")
|
||||
if civitai_model and not metadata.get("checkpoint"):
|
||||
metadata["checkpoint"] = civitai_model
|
||||
elif parsed_embedded:
|
||||
parsed_loras = parsed_embedded.get("loras")
|
||||
if parsed_loras and not metadata.get("loras"):
|
||||
metadata["loras"] = parsed_loras
|
||||
parsed_model = parsed_embedded.get("model")
|
||||
if parsed_model and not metadata.get("checkpoint"):
|
||||
metadata["checkpoint"] = parsed_model
|
||||
|
||||
civitai_client = self._civitai_client_getter()
|
||||
await RecipeEnricher.enrich_recipe(
|
||||
recipe=metadata,
|
||||
civitai_client=civitai_client,
|
||||
request_params={},
|
||||
prefetched_civitai_meta_raw=civitai_meta_raw,
|
||||
prefetched_model_version_id=model_version_id,
|
||||
)
|
||||
|
||||
prompt = (
|
||||
metadata.get("gen_params", {}).get("prompt")
|
||||
or metadata.get("gen_params", {}).get("positivePrompt")
|
||||
or ""
|
||||
)
|
||||
if prompt:
|
||||
name = " ".join(str(prompt).split()[:10])
|
||||
else:
|
||||
name = f"Civitai Image {image_id}"
|
||||
|
||||
result = await self._persistence_service.save_recipe(
|
||||
recipe_scanner=recipe_scanner,
|
||||
image_bytes=image_bytes,
|
||||
image_base64=None,
|
||||
name=name,
|
||||
tags=[],
|
||||
metadata=metadata,
|
||||
extension=extension,
|
||||
)
|
||||
return web.json_response(result.payload, status=result.status)
|
||||
|
||||
|
||||
class RecipeAnalysisHandler:
|
||||
"""Analyze images to extract recipe metadata."""
|
||||
|
||||
@@ -70,6 +70,10 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
"POST", "/api/lm/recipes/batch-import/directory", "start_directory_import"
|
||||
),
|
||||
RouteDefinition("POST", "/api/lm/recipes/browse-directory", "browse_directory"),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/recipes/check-image-exists", "check_image_exists"
|
||||
),
|
||||
RouteDefinition("GET", "/api/lm/recipes/import-from-url", "import_from_url"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
121
py/services/auto_tag_service.py
Normal file
121
py/services/auto_tag_service.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
Auto-tag extraction service for model cards.
|
||||
|
||||
Extracts implicit model attributes (HIGH/LOW, I2V/T2V/TI2V, Lightning, Turbo)
|
||||
from filename, base_model, and CivitAI version name — no manual tagging required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Dict, List, Set
|
||||
|
||||
# ── Tag category definitions ──────────────────────────────────────────
|
||||
# Each category maps a display label to a regex pattern.
|
||||
# Patterns are case-insensitive and matched against filename, base_model,
|
||||
# and civitai version name.
|
||||
|
||||
# Use (?<![a-zA-Z0-9]) and (?![a-zA-Z0-9]) instead of \b because
|
||||
# Python's \b treats underscore as a word character, so \bHIGH\b
|
||||
# won't match '_HIGH_' in filenames.
|
||||
_B = r"(?<![a-zA-Z0-9])" # left boundary
|
||||
_E = r"(?![a-zA-Z0-9])" # right boundary
|
||||
|
||||
AUTO_TAG_CATEGORIES: Dict[str, str] = {
|
||||
"HIGH": _B + r"HIGH" + _E,
|
||||
"LOW": _B + r"(?<!F)LOW" + _E,
|
||||
"I2V": _B + r"I2V" + _E,
|
||||
"T2V": _B + r"T2V" + _E,
|
||||
"TI2V": _B + r"TI2V" + _E,
|
||||
"Lightning": _B + r"Lightning" + _E,
|
||||
"Turbo": _B + r"Turbo" + _E,
|
||||
}
|
||||
|
||||
# Tags that belong to the "mode" group (HIGH/LOW)
|
||||
MODE_TAGS = {"HIGH", "LOW"}
|
||||
|
||||
# Tags that belong to the "video mode" group (I2V/T2V/TI2V)
|
||||
VIDEO_MODE_TAGS = {"I2V", "T2V", "TI2V"}
|
||||
|
||||
# Tags that belong to the "speed/optimization" group
|
||||
SPEED_TAGS = {"Lightning", "Turbo"}
|
||||
|
||||
# ── Display category groups (for settings UI) ─────────────────────────
|
||||
|
||||
AUTO_TAG_GROUPS = {
|
||||
"mode": {"HIGH", "LOW"},
|
||||
"video": {"I2V", "T2V", "TI2V"},
|
||||
"speed": {"Lightning", "Turbo"},
|
||||
}
|
||||
|
||||
# Default enabled categories
|
||||
DEFAULT_ENABLED_GROUPS = {"mode", "video"}
|
||||
|
||||
|
||||
def _collect_sources(model_data: Dict) -> List[str]:
|
||||
"""Collect all text sources from model data for tag matching."""
|
||||
sources: List[str] = []
|
||||
|
||||
file_name = model_data.get("file_name", "")
|
||||
if file_name:
|
||||
sources.append(file_name)
|
||||
|
||||
base_model = model_data.get("base_model", "")
|
||||
if base_model:
|
||||
sources.append(base_model)
|
||||
|
||||
civitai = model_data.get("civitai", {})
|
||||
if isinstance(civitai, dict):
|
||||
version_name = civitai.get("name", "")
|
||||
if version_name:
|
||||
sources.append(version_name)
|
||||
|
||||
return sources
|
||||
|
||||
|
||||
def extract_auto_tags(model_data: Dict) -> List[str]:
|
||||
"""Extract auto-detected tags from model metadata.
|
||||
|
||||
Matches predefined patterns against filename, base_model, and
|
||||
CivitAI version name. Returns a sorted, deduplicated list of tag labels.
|
||||
|
||||
HIGH/LOW tags are only returned when the base_model indicates a Wan
|
||||
family model — no other model architecture uses this distinction.
|
||||
|
||||
Args:
|
||||
model_data: Model metadata dict with keys:
|
||||
file_name, base_model, civitai (with optional 'name' field).
|
||||
|
||||
Returns:
|
||||
Sorted list of unique auto-tag strings (e.g. ["I2V"]).
|
||||
"""
|
||||
sources = _collect_sources(model_data)
|
||||
if not sources:
|
||||
return []
|
||||
|
||||
base_model = model_data.get("base_model", "")
|
||||
is_wan = "wan" in base_model.lower()
|
||||
|
||||
found: Set[str] = set()
|
||||
|
||||
for label, pattern in AUTO_TAG_CATEGORIES.items():
|
||||
# HIGH/LOW are Wan-specific — skip for non-Wan to avoid noise
|
||||
if label in ("HIGH", "LOW"):
|
||||
if not is_wan:
|
||||
continue
|
||||
# Use case-insensitive character class + case-sensitive boundary,
|
||||
# so "HighNoise" (camelCase) matches but "highlight" doesn't.
|
||||
# Boundary: not followed by lowercase letter (= word has ended).
|
||||
ci = "".join(f"[{c.lower()}{c.upper()}]" for c in label)
|
||||
if label == "LOW":
|
||||
regex = re.compile(r"(?<![Ff])" + ci + r"(?![a-z])")
|
||||
else:
|
||||
regex = re.compile(ci + r"(?![a-z])")
|
||||
else:
|
||||
regex = re.compile(pattern, re.IGNORECASE)
|
||||
for source in sources:
|
||||
if regex.search(source):
|
||||
found.add(label)
|
||||
break
|
||||
|
||||
return sorted(found)
|
||||
@@ -77,6 +77,7 @@ class BaseModelService(ABC):
|
||||
base_models: list = None,
|
||||
model_types: list = None,
|
||||
tags: Optional[Dict[str, str]] = None,
|
||||
auto_tags: Optional[Dict[str, str]] = None,
|
||||
search_options: dict = None,
|
||||
hash_filters: dict = None,
|
||||
favorites_only: bool = False,
|
||||
@@ -95,6 +96,11 @@ class BaseModelService(ABC):
|
||||
sorted_data = await self._fetch_with_usage_sort(sort_params)
|
||||
else:
|
||||
sorted_data = await self.cache_repository.fetch_sorted(sort_params)
|
||||
# Pre-compute auto_tags for every item — needed for both filtering
|
||||
# and display. Computation is cheap (string regex on 2-3 fields).
|
||||
from .auto_tag_service import extract_auto_tags
|
||||
for item in sorted_data:
|
||||
item["auto_tags"] = extract_auto_tags(item)
|
||||
fetch_duration = time.perf_counter() - t0
|
||||
initial_count = len(sorted_data)
|
||||
|
||||
@@ -110,6 +116,7 @@ class BaseModelService(ABC):
|
||||
base_models=base_models,
|
||||
model_types=model_types,
|
||||
tags=tags,
|
||||
auto_tags=auto_tags,
|
||||
favorites_only=favorites_only,
|
||||
search_options=search_options,
|
||||
tag_logic=tag_logic,
|
||||
@@ -354,6 +361,7 @@ class BaseModelService(ABC):
|
||||
base_models: list = None,
|
||||
model_types: list = None,
|
||||
tags: Optional[Dict[str, str]] = None,
|
||||
auto_tags: Optional[Dict[str, str]] = None,
|
||||
favorites_only: bool = False,
|
||||
search_options: dict = None,
|
||||
tag_logic: str = "any",
|
||||
@@ -367,6 +375,7 @@ class BaseModelService(ABC):
|
||||
base_models=base_models,
|
||||
model_types=model_types,
|
||||
tags=tags,
|
||||
auto_tags=auto_tags,
|
||||
favorites_only=favorites_only,
|
||||
search_options=normalized_options,
|
||||
tag_logic=tag_logic,
|
||||
@@ -908,6 +917,17 @@ class BaseModelService(ABC):
|
||||
)
|
||||
if should_skip or metadata is None:
|
||||
return None
|
||||
|
||||
# Prune stale example-image metadata entries whose files no longer
|
||||
# exist on disk (e.g. a user deleted the files manually).
|
||||
from ..utils.example_images_metadata import MetadataUpdater
|
||||
|
||||
was_modified = await MetadataUpdater.prune_stale_example_images(metadata)
|
||||
if was_modified:
|
||||
asyncio.create_task(
|
||||
MetadataManager.save_metadata(file_path, metadata)
|
||||
)
|
||||
|
||||
return self.filter_civitai_data(metadata.to_dict().get("civitai", {}))
|
||||
|
||||
async def get_model_description(self, file_path: str) -> Optional[str]:
|
||||
|
||||
@@ -224,7 +224,7 @@ class BatchImportService:
|
||||
return False
|
||||
|
||||
for recipe in getattr(cache, "raw_data", []):
|
||||
source_path = recipe.get("source_path") or recipe.get("source_url")
|
||||
source_path = recipe.get("source_path")
|
||||
if source_path and source_path == source:
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -3,6 +3,7 @@ import logging
|
||||
from typing import Dict
|
||||
|
||||
from .base_model_service import BaseModelService
|
||||
from .auto_tag_service import extract_auto_tags
|
||||
from ..utils.models import CheckpointMetadata
|
||||
from ..config import config
|
||||
|
||||
@@ -45,7 +46,8 @@ class CheckpointService(BaseModelService):
|
||||
"exclude": bool(checkpoint_data.get("exclude", False)),
|
||||
"update_available": bool(checkpoint_data.get("update_available", False)),
|
||||
"skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)),
|
||||
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True)
|
||||
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True),
|
||||
"auto_tags": checkpoint_data.get("auto_tags") or extract_auto_tags(checkpoint_data),
|
||||
}
|
||||
|
||||
def find_duplicate_hashes(self) -> Dict:
|
||||
|
||||
@@ -193,6 +193,9 @@ class CivitaiBaseModelService:
|
||||
"zimageturbo": "ZIT",
|
||||
"zimagebase": "ZIB",
|
||||
"anima": "ANI",
|
||||
"ernie": "ERNI",
|
||||
"ernie turbo": "ETRB",
|
||||
"nucleus": "NUCL",
|
||||
"svd": "SVD",
|
||||
"ltxv": "LTXV",
|
||||
"ltxv2": "LTV2",
|
||||
@@ -418,6 +421,9 @@ class CivitaiBaseModelService:
|
||||
"Kolors",
|
||||
"NoobAI",
|
||||
"Anima",
|
||||
"Ernie",
|
||||
"Ernie Turbo",
|
||||
"Nucleus",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -257,7 +257,7 @@ class CivitaiClient:
|
||||
"GET",
|
||||
f"{self.base_url}/models",
|
||||
use_auth=True,
|
||||
params={"ids": query},
|
||||
params={"ids": query, "nsfw": "true"},
|
||||
)
|
||||
if not success:
|
||||
return None
|
||||
@@ -577,6 +577,59 @@ class CivitaiClient:
|
||||
logger.error(error_msg)
|
||||
return None
|
||||
|
||||
async def get_model_versions_by_hashes(
|
||||
self, hashes: List[str]
|
||||
) -> Optional[List[Dict]]:
|
||||
"""Fetch full version details for up to 100 SHA256 hashes via the batch endpoint.
|
||||
|
||||
Uses POST /api/v1/model-versions/by-hash which returns full version
|
||||
details including ``usageControl`` and ``earlyAccessEndsAt`` that are
|
||||
not available from the model-level API.
|
||||
|
||||
Args:
|
||||
hashes: List of SHA256 hashes (max 100 per batch; auto-split).
|
||||
|
||||
Returns:
|
||||
List of version dicts or None on failure.
|
||||
"""
|
||||
if not hashes:
|
||||
return []
|
||||
|
||||
BATCH_SIZE = 100
|
||||
all_versions: List[Dict] = []
|
||||
|
||||
for start in range(0, len(hashes), BATCH_SIZE):
|
||||
batch = hashes[start : start + BATCH_SIZE]
|
||||
try:
|
||||
success, result = await self._make_request(
|
||||
"POST",
|
||||
f"{self.base_url}/model-versions/by-hash",
|
||||
use_auth=True,
|
||||
json=batch,
|
||||
)
|
||||
if not success:
|
||||
logger.warning(
|
||||
"Batch by-hash request failed for %d hashes: %s",
|
||||
len(batch),
|
||||
result,
|
||||
)
|
||||
continue
|
||||
|
||||
if isinstance(result, list):
|
||||
all_versions.extend(result)
|
||||
else:
|
||||
logger.debug(
|
||||
"Unexpected by-hash response type: %s", type(result)
|
||||
)
|
||||
except RateLimitError:
|
||||
raise
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"Error fetching model versions by hashes: %s", exc
|
||||
)
|
||||
|
||||
return all_versions if all_versions else None
|
||||
|
||||
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
|
||||
"""Fetch all models for a specific Civitai user."""
|
||||
if not username:
|
||||
@@ -587,7 +640,7 @@ class CivitaiClient:
|
||||
"GET",
|
||||
f"{self.base_url}/models",
|
||||
use_auth=True,
|
||||
params={"username": username},
|
||||
params={"username": username, "nsfw": "true"},
|
||||
)
|
||||
|
||||
if not success:
|
||||
|
||||
@@ -206,7 +206,7 @@ class DownloadedVersionHistoryService:
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
async def mark_not_downloaded(self, model_type: str, version_id: int) -> None:
|
||||
async def mark_as_deleted(self, model_type: str, version_id: int) -> None:
|
||||
normalized_type = _normalize_model_type(model_type)
|
||||
normalized_version_id = _normalize_int(version_id)
|
||||
if normalized_type is None or normalized_version_id is None:
|
||||
|
||||
@@ -3,6 +3,7 @@ import logging
|
||||
from typing import Dict
|
||||
|
||||
from .base_model_service import BaseModelService
|
||||
from .auto_tag_service import extract_auto_tags
|
||||
from ..utils.models import EmbeddingMetadata
|
||||
from ..config import config
|
||||
|
||||
@@ -45,7 +46,8 @@ class EmbeddingService(BaseModelService):
|
||||
"exclude": bool(embedding_data.get("exclude", False)),
|
||||
"update_available": bool(embedding_data.get("update_available", False)),
|
||||
"skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)),
|
||||
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True)
|
||||
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True),
|
||||
"auto_tags": embedding_data.get("auto_tags") or extract_auto_tags(embedding_data),
|
||||
}
|
||||
|
||||
def find_duplicate_hashes(self) -> Dict:
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Dict, List, Optional
|
||||
|
||||
from .base_model_service import BaseModelService
|
||||
from .model_query import resolve_sub_type
|
||||
from .auto_tag_service import extract_auto_tags
|
||||
from ..utils.models import LoraMetadata
|
||||
from ..config import config
|
||||
|
||||
@@ -57,6 +58,7 @@ class LoraService(BaseModelService):
|
||||
"civitai": self.filter_civitai_data(
|
||||
lora_data.get("civitai", {}), minimal=True
|
||||
),
|
||||
"auto_tags": lora_data.get("auto_tags") or extract_auto_tags(lora_data),
|
||||
}
|
||||
|
||||
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
|
||||
|
||||
@@ -111,6 +111,11 @@ class ModelLifecycleService:
|
||||
self._scanner._hash_index.remove_by_path(file_path)
|
||||
|
||||
await self._sync_update_for_model(model_id)
|
||||
|
||||
persist_current_cache = getattr(self._scanner, "_persist_current_cache", None)
|
||||
if callable(persist_current_cache):
|
||||
await persist_current_cache()
|
||||
|
||||
return {"success": True, "deleted_files": deleted_files}
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -108,6 +108,18 @@ class ModelMetadataProvider(ABC):
|
||||
) -> Optional[Dict[int, Dict]]:
|
||||
"""Fetch model versions for multiple model ids when supported."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def get_model_versions_by_hashes(
|
||||
self, hashes: List[str]
|
||||
) -> Optional[List[Dict]]:
|
||||
"""Fetch full version details for multiple SHA256 hashes.
|
||||
|
||||
Used specifically to retrieve ``usageControl`` which is only
|
||||
available from the per-version / by-hash API, not from model-level
|
||||
responses. Providers that cannot resolve hashes should let the
|
||||
default ``NotImplementedError`` propagate.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
||||
@@ -140,6 +152,11 @@ class CivitaiModelMetadataProvider(ModelMetadataProvider):
|
||||
self, model_ids: Sequence[int]
|
||||
) -> Optional[Dict[int, Dict]]:
|
||||
return await self.client.get_model_versions_bulk(model_ids)
|
||||
|
||||
async def get_model_versions_by_hashes(
|
||||
self, hashes: List[str]
|
||||
) -> Optional[List[Dict]]:
|
||||
return await self.client.get_model_versions_by_hashes(hashes)
|
||||
|
||||
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
||||
return await self.client.get_model_version(model_id, version_id)
|
||||
@@ -519,6 +536,32 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
||||
continue
|
||||
return None, "No provider could retrieve the data"
|
||||
|
||||
async def get_model_versions_by_hashes(
|
||||
self, hashes: List[str]
|
||||
) -> Optional[List[Dict]]:
|
||||
for provider, label in self._iter_providers():
|
||||
try:
|
||||
result = await self._call_with_rate_limit(
|
||||
label,
|
||||
provider.get_model_versions_by_hashes,
|
||||
hashes,
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
except NotImplementedError:
|
||||
continue
|
||||
except RateLimitError as exc:
|
||||
exc.provider = exc.provider or label
|
||||
raise exc
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Provider %s failed for get_model_versions_by_hashes: %s",
|
||||
label,
|
||||
e,
|
||||
)
|
||||
continue
|
||||
return None
|
||||
|
||||
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
|
||||
for provider, label in self._iter_providers():
|
||||
try:
|
||||
@@ -593,6 +636,15 @@ class RateLimitRetryingProvider(ModelMetadataProvider):
|
||||
model_ids,
|
||||
)
|
||||
|
||||
async def get_model_versions_by_hashes(
|
||||
self, hashes: List[str]
|
||||
) -> Optional[List[Dict]]:
|
||||
return await self._rate_limit_helper.run(
|
||||
self._label,
|
||||
self._provider.get_model_versions_by_hashes,
|
||||
hashes,
|
||||
)
|
||||
|
||||
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
||||
return await self._rate_limit_helper.run(
|
||||
self._label,
|
||||
@@ -669,6 +721,17 @@ class ModelMetadataProviderManager:
|
||||
provider = self._get_provider(provider_name)
|
||||
return await provider.get_model_version_info(version_id)
|
||||
|
||||
async def get_model_versions_by_hashes(
|
||||
self,
|
||||
hashes: List[str],
|
||||
provider_name: str = None,
|
||||
) -> Optional[List[Dict]]:
|
||||
provider = self._get_provider(provider_name)
|
||||
try:
|
||||
return await provider.get_model_versions_by_hashes(hashes)
|
||||
except NotImplementedError:
|
||||
return None
|
||||
|
||||
async def get_user_models(self, username: str, provider_name: str = None) -> Optional[List[Dict]]:
|
||||
"""Fetch models owned by the specified user"""
|
||||
provider = self._get_provider(provider_name)
|
||||
|
||||
@@ -96,6 +96,7 @@ class FilterCriteria:
|
||||
folder_exclude: Optional[Sequence[str]] = None
|
||||
base_models: Optional[Sequence[str]] = None
|
||||
tags: Optional[Dict[str, str]] = None
|
||||
auto_tags: Optional[Dict[str, str]] = None
|
||||
favorites_only: bool = False
|
||||
search_options: Optional[Dict[str, Any]] = None
|
||||
model_types: Optional[Sequence[str]] = None
|
||||
@@ -359,10 +360,37 @@ class ModelFilterSet:
|
||||
]
|
||||
model_types_duration = time.perf_counter() - t0
|
||||
|
||||
auto_tags_duration = 0
|
||||
auto_tag_filters = criteria.auto_tags or {}
|
||||
if auto_tag_filters:
|
||||
t0 = time.perf_counter()
|
||||
include_at = set()
|
||||
exclude_at = set()
|
||||
for tag, state in auto_tag_filters.items():
|
||||
if not tag:
|
||||
continue
|
||||
if state == "exclude":
|
||||
exclude_at.add(tag)
|
||||
else:
|
||||
include_at.add(tag)
|
||||
|
||||
if include_at:
|
||||
items = [
|
||||
item for item in items
|
||||
if any(tag in include_at for tag in (item.get("auto_tags") or []))
|
||||
]
|
||||
|
||||
if exclude_at:
|
||||
items = [
|
||||
item for item in items
|
||||
if not any(tag in exclude_at for tag in (item.get("auto_tags") or []))
|
||||
]
|
||||
auto_tags_duration = time.perf_counter() - t0
|
||||
|
||||
duration = time.perf_counter() - overall_start
|
||||
if duration > 0.1: # Only log if it's potentially slow
|
||||
logger.debug(
|
||||
"ModelFilterSet.apply took %.3fs (sfw: %.3fs, fav: %.3fs, folder: %.3fs, base: %.3fs, tags: %.3fs, types: %.3fs). "
|
||||
"ModelFilterSet.apply took %.3fs (sfw: %.3fs, fav: %.3fs, folder: %.3fs, base: %.3fs, tags: %.3fs, types: %.3fs, auto_tags: %.3fs). "
|
||||
"Count: %d -> %d",
|
||||
duration,
|
||||
sfw_duration,
|
||||
@@ -371,6 +399,7 @@ class ModelFilterSet:
|
||||
base_models_duration,
|
||||
tags_duration,
|
||||
model_types_duration,
|
||||
auto_tags_duration,
|
||||
initial_count,
|
||||
len(items),
|
||||
)
|
||||
|
||||
@@ -989,6 +989,11 @@ class ModelUpdateService:
|
||||
fallback_attempted = True
|
||||
try:
|
||||
response = await metadata_provider.get_model_versions(model_id)
|
||||
if response is not None:
|
||||
await self._enrich_version_entries(
|
||||
metadata_provider,
|
||||
{model_id: response},
|
||||
)
|
||||
except RateLimitError:
|
||||
raise
|
||||
except ResourceNotFoundError as exc:
|
||||
@@ -1083,6 +1088,136 @@ class ModelUpdateService:
|
||||
self._upsert_record(record)
|
||||
return record
|
||||
|
||||
async def _enrich_version_entries(
|
||||
self,
|
||||
metadata_provider,
|
||||
responses_by_model_id: Dict[int, Mapping],
|
||||
) -> None:
|
||||
"""Enrich version entries with ``usageControl`` via batch hash endpoint.
|
||||
|
||||
The model-level API does not include ``usageControl`` on version
|
||||
entries. This method collects SHA256 hashes from every version's
|
||||
primary model file, calls ``POST /api/v1/model-versions/by-hash``
|
||||
(up to 100 hashes per request), and injects ``usageControl`` +
|
||||
``earlyAccessEndsAt`` into each version entry dict in-place.
|
||||
"""
|
||||
if not metadata_provider or not responses_by_model_id:
|
||||
return
|
||||
|
||||
hashes_by_version: Dict[int, str] = {}
|
||||
for response in responses_by_model_id.values():
|
||||
hashes_by_version.update(
|
||||
self._collect_hashes_from_response(response)
|
||||
)
|
||||
|
||||
if not hashes_by_version:
|
||||
return
|
||||
|
||||
version_ids_by_hash: Dict[str, List[int]] = {}
|
||||
for version_id, sha256 in hashes_by_version.items():
|
||||
version_ids_by_hash.setdefault(sha256, []).append(version_id)
|
||||
|
||||
all_hashes = list(version_ids_by_hash.keys())
|
||||
BATCH_SIZE = 100
|
||||
|
||||
enrichment: Dict[int, Dict] = {}
|
||||
try:
|
||||
for start in range(0, len(all_hashes), BATCH_SIZE):
|
||||
batch = all_hashes[start : start + BATCH_SIZE]
|
||||
try:
|
||||
enriched = await metadata_provider.get_model_versions_by_hashes(
|
||||
batch
|
||||
)
|
||||
except NotImplementedError:
|
||||
return
|
||||
except RateLimitError:
|
||||
raise
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not enriched:
|
||||
continue
|
||||
|
||||
for entry in enriched:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
version_id = entry.get("id")
|
||||
if version_id is None:
|
||||
continue
|
||||
enrichment[version_id] = {
|
||||
"usageControl": _normalize_string(
|
||||
entry.get("usageControl")
|
||||
),
|
||||
"earlyAccessEndsAt": _normalize_string(
|
||||
entry.get("earlyAccessEndsAt")
|
||||
),
|
||||
}
|
||||
except RateLimitError:
|
||||
raise
|
||||
|
||||
if not enrichment:
|
||||
return
|
||||
|
||||
for response in responses_by_model_id.values():
|
||||
versions = response.get("modelVersions")
|
||||
if not isinstance(versions, list):
|
||||
continue
|
||||
for version in versions:
|
||||
if not isinstance(version, dict):
|
||||
continue
|
||||
version_id = version.get("id")
|
||||
if version_id not in enrichment:
|
||||
continue
|
||||
extra = enrichment[version_id]
|
||||
if extra.get("usageControl") and not version.get("usageControl"):
|
||||
version["usageControl"] = extra["usageControl"]
|
||||
if extra.get("earlyAccessEndsAt") and not version.get(
|
||||
"earlyAccessEndsAt"
|
||||
):
|
||||
version["earlyAccessEndsAt"] = extra["earlyAccessEndsAt"]
|
||||
|
||||
@staticmethod
|
||||
def _collect_hashes_from_response(response: Mapping) -> Dict[int, str]:
|
||||
"""Extract ``{version_id: sha256}`` from a model-level API response.
|
||||
|
||||
Returns an empty dict if the response structure is unexpected.
|
||||
"""
|
||||
result: Dict[int, str] = {}
|
||||
versions = response.get("modelVersions")
|
||||
if not isinstance(versions, list):
|
||||
return result
|
||||
for entry in versions:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
version_id = _normalize_int(entry.get("id"))
|
||||
if version_id is None:
|
||||
continue
|
||||
sha256 = ModelUpdateService._extract_sha256_from_version_entry(entry)
|
||||
if sha256:
|
||||
result[version_id] = sha256
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _extract_sha256_from_version_entry(entry: Mapping) -> Optional[str]:
|
||||
"""Return the SHA256 hash from the primary model file of a version entry."""
|
||||
files = entry.get("files")
|
||||
if not isinstance(files, list):
|
||||
return None
|
||||
for file_info in files:
|
||||
if not isinstance(file_info, dict):
|
||||
continue
|
||||
if file_info.get("type") != "Model":
|
||||
continue
|
||||
primary = file_info.get("primary")
|
||||
if primary is not True and str(primary).strip().lower() != "true":
|
||||
continue
|
||||
hashes = file_info.get("hashes")
|
||||
if isinstance(hashes, dict):
|
||||
sha256 = hashes.get("SHA256")
|
||||
if sha256:
|
||||
return sha256
|
||||
return None
|
||||
|
||||
async def _fetch_model_versions_bulk(
|
||||
self,
|
||||
metadata_provider,
|
||||
@@ -1134,6 +1269,7 @@ class ModelUpdateService:
|
||||
len(aggregated),
|
||||
provider_name,
|
||||
)
|
||||
await self._enrich_version_entries(metadata_provider, aggregated)
|
||||
return aggregated
|
||||
|
||||
async def _collect_local_versions(
|
||||
@@ -1261,6 +1397,7 @@ class ModelUpdateService:
|
||||
sort_index=sort_map.get(version_id, index),
|
||||
early_access_ends_at=remote_version.early_access_ends_at,
|
||||
is_early_access=remote_version.is_early_access,
|
||||
usage_control=remote_version.usage_control,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ class PersistentRecipeCache:
|
||||
"json_path",
|
||||
"title",
|
||||
"folder",
|
||||
"source_path",
|
||||
"base_model",
|
||||
"fingerprint",
|
||||
"created_date",
|
||||
@@ -334,6 +335,7 @@ class PersistentRecipeCache:
|
||||
json_path TEXT,
|
||||
title TEXT,
|
||||
folder TEXT,
|
||||
source_path TEXT,
|
||||
base_model TEXT,
|
||||
fingerprint TEXT,
|
||||
created_date REAL,
|
||||
@@ -358,6 +360,13 @@ class PersistentRecipeCache:
|
||||
);
|
||||
"""
|
||||
)
|
||||
# Migration: add source_path column to existing databases
|
||||
try:
|
||||
conn.execute(
|
||||
"ALTER TABLE recipes ADD COLUMN source_path TEXT"
|
||||
)
|
||||
except Exception:
|
||||
pass # column already exists
|
||||
conn.commit()
|
||||
self._schema_initialized = True
|
||||
except Exception as exc:
|
||||
@@ -406,6 +415,7 @@ class PersistentRecipeCache:
|
||||
json_path,
|
||||
recipe.get("title"),
|
||||
recipe.get("folder"),
|
||||
recipe.get("source_path"),
|
||||
recipe.get("base_model"),
|
||||
recipe.get("fingerprint"),
|
||||
float(recipe.get("created_date") or 0.0),
|
||||
@@ -456,6 +466,7 @@ class PersistentRecipeCache:
|
||||
"file_path": row["file_path"] or "",
|
||||
"title": row["title"] or "",
|
||||
"folder": row["folder"] or "",
|
||||
"source_path": row["source_path"] or "",
|
||||
"base_model": row["base_model"] or "",
|
||||
"fingerprint": row["fingerprint"] or "",
|
||||
"created_date": row["created_date"] or 0.0,
|
||||
|
||||
@@ -504,6 +504,9 @@ class RecipeScanner:
|
||||
self._cache.raw_data = recipes
|
||||
self._update_folder_metadata(self._cache)
|
||||
self._sort_cache_sync()
|
||||
# Backfill source_path from JSON files if missing (schema migration)
|
||||
if self._backfill_source_path_if_needed(recipes, json_paths):
|
||||
self._persistent_cache.save_cache(recipes, json_paths)
|
||||
return self._cache
|
||||
else:
|
||||
# Partial update: some files changed
|
||||
@@ -514,6 +517,8 @@ class RecipeScanner:
|
||||
self._cache.raw_data = recipes
|
||||
self._update_folder_metadata(self._cache)
|
||||
self._sort_cache_sync()
|
||||
# Backfill source_path from JSON files if missing (schema migration)
|
||||
self._backfill_source_path_if_needed(recipes, json_paths)
|
||||
# Persist updated cache
|
||||
self._persistent_cache.save_cache(recipes, json_paths)
|
||||
return self._cache
|
||||
@@ -642,6 +647,34 @@ class RecipeScanner:
|
||||
|
||||
return recipes, changed, json_paths
|
||||
|
||||
def _backfill_source_path_if_needed(
|
||||
self,
|
||||
recipes: List[Dict],
|
||||
json_paths: Dict[str, str],
|
||||
) -> bool:
|
||||
"""Backfill source_path from recipe JSON files if missing from cache.
|
||||
|
||||
Returns True if any recipes were updated (caller should persist cache).
|
||||
"""
|
||||
updated = False
|
||||
for recipe in recipes:
|
||||
if recipe.get("source_path"):
|
||||
continue
|
||||
recipe_id = str(recipe.get("id", ""))
|
||||
json_path = json_paths.get(recipe_id)
|
||||
if not json_path or not os.path.exists(json_path):
|
||||
continue
|
||||
try:
|
||||
with open(json_path, "r", encoding="utf-8") as f:
|
||||
json_data = json.load(f)
|
||||
file_source_path = json_data.get("source_path")
|
||||
if file_source_path:
|
||||
recipe["source_path"] = file_source_path
|
||||
updated = True
|
||||
except Exception:
|
||||
pass
|
||||
return updated
|
||||
|
||||
def _full_directory_scan_sync(
|
||||
self, recipes_dir: str
|
||||
) -> Tuple[List[Dict], Dict[str, str]]:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
@@ -14,6 +15,7 @@ from PIL import Image
|
||||
|
||||
from ...utils.utils import calculate_recipe_fingerprint
|
||||
from ...utils.civitai_utils import extract_civitai_image_id, rewrite_preview_url
|
||||
from ...recipes.enrichment import RecipeEnricher
|
||||
from .errors import (
|
||||
RecipeDownloadError,
|
||||
RecipeNotFoundError,
|
||||
@@ -170,9 +172,11 @@ class RecipeAnalysisService:
|
||||
await self._download_image(url, temp_path)
|
||||
|
||||
if metadata is None and not is_video:
|
||||
metadata = self._exif_utils.extract_image_metadata(temp_path)
|
||||
metadata = await asyncio.to_thread(
|
||||
self._exif_utils.extract_image_metadata, temp_path
|
||||
)
|
||||
|
||||
return await self._parse_metadata(
|
||||
result = await self._parse_metadata(
|
||||
metadata or {},
|
||||
recipe_scanner=recipe_scanner,
|
||||
image_path=temp_path,
|
||||
@@ -180,6 +184,37 @@ class RecipeAnalysisService:
|
||||
is_video=is_video,
|
||||
extension=extension,
|
||||
)
|
||||
|
||||
if civitai_image_id and image_info and not result.payload.get("error"):
|
||||
mvid = image_info.get("modelVersionId")
|
||||
if not mvid:
|
||||
mvids = image_info.get("modelVersionIds")
|
||||
if isinstance(mvids, list) and mvids:
|
||||
mvid = mvids[0]
|
||||
|
||||
recipe_for_enrich = {
|
||||
"gen_params": result.payload.get("gen_params", {}),
|
||||
"loras": result.payload.get("loras", []),
|
||||
"base_model": result.payload.get("base_model", "") or "",
|
||||
"checkpoint": result.payload.get("checkpoint") or result.payload.get("model"),
|
||||
"source_path": url,
|
||||
}
|
||||
|
||||
await RecipeEnricher.enrich_recipe(
|
||||
recipe=recipe_for_enrich,
|
||||
civitai_client=civitai_client,
|
||||
request_params=None,
|
||||
prefetched_civitai_meta_raw=image_info.get("meta"),
|
||||
prefetched_model_version_id=mvid,
|
||||
)
|
||||
|
||||
result.payload["gen_params"] = recipe_for_enrich["gen_params"]
|
||||
if recipe_for_enrich.get("checkpoint"):
|
||||
result.payload["checkpoint"] = recipe_for_enrich["checkpoint"]
|
||||
if recipe_for_enrich.get("base_model"):
|
||||
result.payload["base_model"] = recipe_for_enrich["base_model"]
|
||||
|
||||
return result
|
||||
finally:
|
||||
if temp_path:
|
||||
self._safe_cleanup(temp_path)
|
||||
@@ -199,7 +234,9 @@ class RecipeAnalysisService:
|
||||
if not os.path.isfile(normalized_path):
|
||||
raise RecipeNotFoundError("File not found")
|
||||
|
||||
metadata = self._exif_utils.extract_image_metadata(normalized_path)
|
||||
metadata = await asyncio.to_thread(
|
||||
self._exif_utils.extract_image_metadata, normalized_path
|
||||
)
|
||||
if not metadata:
|
||||
return self._metadata_not_found_response(normalized_path)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Any, Dict, Iterable, Mapping, Sequence
|
||||
from urllib.parse import parse_qs, urlparse, urlunparse
|
||||
|
||||
|
||||
_SUPPORTED_CIVITAI_PAGE_HOSTS = frozenset({"civitai.com", "civitai.red"})
|
||||
_SUPPORTED_CIVITAI_PAGE_HOSTS = frozenset({"civitai.com", "civitai.red", "civitai.green"})
|
||||
DEFAULT_CIVITAI_PAGE_HOST = "civitai.com"
|
||||
_DEFAULT_ALLOW_COMMERCIAL_USE: Sequence[str] = ("Sell",)
|
||||
_LICENSE_DEFAULTS: Dict[str, Any] = {
|
||||
|
||||
@@ -178,5 +178,8 @@ SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS = frozenset(
|
||||
"Wan Video 2.5 I2V",
|
||||
"Hunyuan Video",
|
||||
"Anima",
|
||||
"Ernie",
|
||||
"Ernie Turbo",
|
||||
"Nucleus",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -452,3 +452,111 @@ class MetadataUpdater:
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing image metadata: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def prune_stale_example_images(metadata) -> bool:
|
||||
"""Remove example-image metadata entries whose files no longer exist on disk.
|
||||
|
||||
Checks ``civitai.customImages`` (by ``id``) and ``civitai.images`` entries
|
||||
that have an empty ``url`` (no remote fallback) against actual files in
|
||||
the model's example-image folder. Stale entries are removed in-place so
|
||||
the caller can persist the cleaned metadata afterwards.
|
||||
|
||||
Args:
|
||||
metadata: A ``BaseModelMetadata`` instance (modified in place).
|
||||
|
||||
Returns:
|
||||
True if at least one entry was removed.
|
||||
"""
|
||||
from ..utils.example_images_paths import get_model_folder
|
||||
|
||||
model_hash = getattr(metadata, "sha256", None)
|
||||
if not model_hash:
|
||||
return False
|
||||
|
||||
model_folder = get_model_folder(model_hash)
|
||||
if not model_folder:
|
||||
return False
|
||||
|
||||
civitai = getattr(metadata, "civitai", None)
|
||||
if not isinstance(civitai, dict):
|
||||
return False
|
||||
|
||||
has_changes = False
|
||||
|
||||
custom_images = civitai.get("customImages")
|
||||
if isinstance(custom_images, list) and custom_images:
|
||||
stale: list[int] = []
|
||||
|
||||
for idx, img in enumerate(custom_images):
|
||||
img_id = img.get("id", "")
|
||||
if not img_id:
|
||||
continue
|
||||
|
||||
if not os.path.isdir(model_folder):
|
||||
stale.append(idx)
|
||||
else:
|
||||
found = False
|
||||
try:
|
||||
prefix = f"custom_{img_id}"
|
||||
for fname in os.listdir(model_folder):
|
||||
if fname.startswith(prefix) and os.path.isfile(
|
||||
os.path.join(model_folder, fname)
|
||||
):
|
||||
found = True
|
||||
break
|
||||
except OSError:
|
||||
stale.append(idx)
|
||||
continue
|
||||
|
||||
if not found:
|
||||
stale.append(idx)
|
||||
|
||||
if stale:
|
||||
for idx in reversed(stale):
|
||||
custom_images.pop(idx)
|
||||
has_changes = True
|
||||
logger.info(
|
||||
"Pruned %d stale custom image(s) for %s",
|
||||
len(stale),
|
||||
getattr(metadata, "model_name", model_hash),
|
||||
)
|
||||
|
||||
images = civitai.get("images")
|
||||
if isinstance(images, list) and images:
|
||||
stale: list[int] = []
|
||||
|
||||
for idx, img in enumerate(images):
|
||||
if img.get("url", ""):
|
||||
# Has a remote fallback – keep it even if the local copy
|
||||
# is gone.
|
||||
continue
|
||||
|
||||
if not os.path.isdir(model_folder):
|
||||
stale.append(idx)
|
||||
else:
|
||||
found = False
|
||||
try:
|
||||
prefix = f"image_{idx}."
|
||||
for fname in os.listdir(model_folder):
|
||||
if fname.startswith(prefix):
|
||||
found = True
|
||||
break
|
||||
except OSError:
|
||||
stale.append(idx)
|
||||
continue
|
||||
|
||||
if not found:
|
||||
stale.append(idx)
|
||||
|
||||
if stale:
|
||||
for idx in reversed(stale):
|
||||
images.pop(idx)
|
||||
has_changes = True
|
||||
logger.info(
|
||||
"Pruned %d stale image entry(ies) for %s",
|
||||
len(stale),
|
||||
getattr(metadata, "model_name", model_hash),
|
||||
)
|
||||
|
||||
return has_changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-lora-manager"
|
||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||
version = "1.0.5"
|
||||
version = "1.0.7"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
|
||||
@@ -507,21 +507,96 @@
|
||||
background: rgba(0,0,0,0.18); /* Optional: subtle background for contrast */
|
||||
}
|
||||
|
||||
/* Version row — flex container for badges + version names */
|
||||
.version-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Badge + version-name binding: they wrap as a single unit */
|
||||
.badge-version-unit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Medium density adjustments for version name */
|
||||
.medium-density .version-name {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.medium-density .badge-version-unit .version-name {
|
||||
max-width: 90px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Compact density adjustments for version name */
|
||||
.compact-density .version-name {
|
||||
font-size: 0.75em;
|
||||
}
|
||||
|
||||
/* Hide civitai version name when setting is disabled */
|
||||
body.hide-card-version .civitai-version {
|
||||
.compact-density .badge-version-unit .version-name {
|
||||
max-width: 70px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.medium-density .version-row {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* HIGH / LOW badges — shown inline before version name in card footer */
|
||||
.hl-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.7em;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
padding: 1px 5px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hl-badge--high {
|
||||
color: oklch(75% 0.12 230);
|
||||
background: oklch(55% 0.15 240 / 0.25);
|
||||
border-color: oklch(60% 0.18 250 / 0.3);
|
||||
}
|
||||
|
||||
.hl-badge--low {
|
||||
color: oklch(78% 0.10 185);
|
||||
background: oklch(50% 0.10 190 / 0.25);
|
||||
border-color: oklch(55% 0.12 195 / 0.3);
|
||||
}
|
||||
|
||||
.medium-density .hl-badge {
|
||||
font-size: 0.65em;
|
||||
}
|
||||
|
||||
.compact-density .hl-badge {
|
||||
font-size: 0.62em;
|
||||
padding: 0px 4px;
|
||||
}
|
||||
|
||||
/* Hide version-related elements when setting is disabled */
|
||||
body.hide-card-version .civitai-version,
|
||||
body.hide-card-version .hl-badge {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Compact density adjustments for version name */
|
||||
.compact-density .version-name {
|
||||
font-size: 0.75em;
|
||||
}
|
||||
|
||||
/* Prevent text selection on cards and interactive elements */
|
||||
.model-card,
|
||||
.model-card *,
|
||||
|
||||
@@ -387,6 +387,10 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.version-action-disabled-wrapper {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.versions-loading-state,
|
||||
.versions-empty,
|
||||
.versions-error {
|
||||
|
||||
124
static/css/components/media-viewer.css
Normal file
124
static/css/components/media-viewer.css
Normal file
@@ -0,0 +1,124 @@
|
||||
.media-viewer-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.media-viewer-overlay.active {
|
||||
background: rgba(0, 0, 0, 0.92);
|
||||
}
|
||||
|
||||
.media-viewer-close {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
z-index: 10001;
|
||||
transition: background 0.2s ease;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.media-viewer-overlay.active .media-viewer-close {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.media-viewer-close:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.media-viewer-content-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-width: 90vw;
|
||||
max-height: 95vh;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.media-viewer-media {
|
||||
display: block;
|
||||
max-width: 90vw;
|
||||
max-height: 85vh;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.media-viewer-video {
|
||||
max-height: 80vh;
|
||||
}
|
||||
|
||||
.media-viewer-counter {
|
||||
margin-top: 8px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 0.85em;
|
||||
text-align: center;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
.media-viewer-title {
|
||||
margin-top: 4px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 0.9em;
|
||||
text-align: center;
|
||||
max-width: 90vw;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.media-viewer-nav {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 48px;
|
||||
height: 80px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
z-index: 10001;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.media-viewer-overlay.active .media-viewer-nav {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.media-viewer-nav:hover {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.media-viewer-prev {
|
||||
left: 16px;
|
||||
}
|
||||
|
||||
.media-viewer-next {
|
||||
right: 16px;
|
||||
}
|
||||
@@ -41,6 +41,63 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Section Headers */
|
||||
.context-menu-section-header {
|
||||
padding: 6px 12px 2px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Submenu */
|
||||
.context-menu-item.has-submenu {
|
||||
position: relative;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.submenu-arrow {
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
width: auto !important;
|
||||
}
|
||||
|
||||
.context-submenu {
|
||||
position: absolute;
|
||||
left: calc(100% - 4px);
|
||||
top: -1px;
|
||||
display: none;
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 0;
|
||||
min-width: 200px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
z-index: 1001;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.context-submenu .context-menu-item {
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.context-submenu .context-menu-item:first-child {
|
||||
padding-top: 9px;
|
||||
}
|
||||
|
||||
.context-submenu .context-menu-item:last-child {
|
||||
padding-bottom: 9px;
|
||||
}
|
||||
|
||||
.context-submenu.flip-left {
|
||||
left: auto;
|
||||
right: 100%;
|
||||
}
|
||||
|
||||
/* NSFW Level Selector */
|
||||
.nsfw-level-selector {
|
||||
position: fixed;
|
||||
|
||||
@@ -4,15 +4,20 @@
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
border-bottom: 1px solid var(--lora-border);
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.recipe-modal-header h2 {
|
||||
font-size: 1.4em; /* Reduced from default h2 size */
|
||||
line-height: 1.3;
|
||||
margin: 0;
|
||||
max-height: 2.6em; /* Limit to 2 lines */
|
||||
margin: 0 0 var(--space-1);
|
||||
padding: var(--space-1);
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 1.5em;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
color: var(--text-color);
|
||||
max-height: 2.8em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
@@ -127,7 +132,7 @@
|
||||
/* Recipe Tags styles */
|
||||
.recipe-tags-container {
|
||||
position: relative;
|
||||
margin-top: 6px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
@@ -225,6 +230,62 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Recipe Header Actions */
|
||||
.recipe-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
margin-bottom: var(--space-1);
|
||||
flex-shrink: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.recipe-header-actions:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.recipe-source-url-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 0.9em;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .recipe-source-url-btn {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
.recipe-source-url-btn:hover {
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||
border-color: var(--lora-accent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.recipe-source-url-btn i {
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (max-height: 860px) {
|
||||
.recipe-header-actions {
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Top Section: Preview and Gen Params */
|
||||
.recipe-top-section {
|
||||
display: grid;
|
||||
@@ -396,14 +457,54 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.recipe-gen-params h3 {
|
||||
margin-top: 0;
|
||||
.gen-params-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-2);
|
||||
font-size: 1.2em;
|
||||
color: var(--text-color);
|
||||
padding-bottom: var(--space-1);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.gen-params-header-row h3 {
|
||||
margin: 0;
|
||||
font-size: 1.2em;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* Inline toggle for lora strip setting */
|
||||
.lora-strip-toggle {
|
||||
flex-shrink: 0;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.lora-strip-toggle .inline-toggle-label {
|
||||
font-size: 0.78em;
|
||||
white-space: nowrap;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.lora-strip-toggle:hover .inline-toggle-label {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.lora-strip-toggle .toggle-switch {
|
||||
width: 32px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.lora-strip-toggle .toggle-slider:before {
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
}
|
||||
|
||||
.lora-strip-toggle .toggle-switch input:checked + .toggle-slider:before {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
.gen-params-container {
|
||||
@@ -1043,13 +1144,13 @@
|
||||
}
|
||||
|
||||
.recipe-modal-header {
|
||||
padding-bottom: 6px;
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: var(--space-1);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.recipe-modal-header h2 {
|
||||
font-size: 1.25em;
|
||||
max-height: 2.5em;
|
||||
font-size: 1.3em;
|
||||
max-height: 2.4em;
|
||||
}
|
||||
|
||||
.recipe-tags-container {
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
@import 'components/keyboard-nav.css'; /* Add keyboard navigation component */
|
||||
@import 'components/statistics.css'; /* Add statistics component */
|
||||
@import 'components/sidebar.css'; /* Add sidebar component */
|
||||
@import 'components/media-viewer.css';
|
||||
|
||||
.initialization-notice {
|
||||
display: flex;
|
||||
|
||||
@@ -978,6 +978,16 @@ export class BaseModelApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
if (pageState.filters.autoTags && Object.keys(pageState.filters.autoTags).length > 0) {
|
||||
Object.entries(pageState.filters.autoTags).forEach(([tag, state]) => {
|
||||
if (state === 'include') {
|
||||
params.append('auto_tag_include', tag);
|
||||
} else if (state === 'exclude') {
|
||||
params.append('auto_tag_exclude', tag);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (pageState.filters.baseModel && pageState.filters.baseModel.length > 0) {
|
||||
// Check for empty wildcard marker - if present, no models should match
|
||||
const EMPTY_WILDCARD_MARKER = '__EMPTY_WILDCARD_RESULT__';
|
||||
|
||||
@@ -3,32 +3,113 @@ export class BaseContextMenu {
|
||||
this.menu = document.getElementById(menuId);
|
||||
this.cardSelector = cardSelector;
|
||||
this.currentCard = null;
|
||||
|
||||
this.submenuTimeout = null;
|
||||
this.openSubmenu = null;
|
||||
|
||||
if (!this.menu) {
|
||||
console.error(`Context menu element with ID ${menuId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Hide menu on regular clicks
|
||||
document.addEventListener('click', () => this.hideMenu());
|
||||
// Hide menu when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!this.menu.contains(e.target)) {
|
||||
this.hideMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle menu item clicks
|
||||
// Handle menu item clicks (including submenu items)
|
||||
this.menu.addEventListener('click', (e) => {
|
||||
const menuItem = e.target.closest('.context-menu-item');
|
||||
if (!menuItem || !this.currentCard) return;
|
||||
|
||||
// Ignore clicks on submenu trigger (has-submenu parent)
|
||||
if (menuItem.classList.contains('has-submenu')) return;
|
||||
|
||||
const action = menuItem.dataset.action;
|
||||
if (!action) return;
|
||||
|
||||
|
||||
this.handleMenuAction(action, menuItem);
|
||||
this.hideMenu();
|
||||
});
|
||||
|
||||
// Submenu hover handling
|
||||
// Use mouseover/mouseout (which bubble) with relatedTarget checks
|
||||
// to reliably detect crossing the .has-submenu boundary
|
||||
this.menu.addEventListener('mouseover', (e) => {
|
||||
const trigger = e.target.closest('.has-submenu');
|
||||
if (!trigger) return;
|
||||
|
||||
// Only act when entering from outside this trigger's tree
|
||||
if (e.relatedTarget && trigger.contains(e.relatedTarget)) return;
|
||||
|
||||
this._openSubmenu(trigger);
|
||||
});
|
||||
|
||||
this.menu.addEventListener('mouseout', (e) => {
|
||||
const trigger = e.target.closest('.has-submenu');
|
||||
if (!trigger) return;
|
||||
|
||||
// Only close when leaving the trigger's tree entirely
|
||||
if (e.relatedTarget && trigger.contains(e.relatedTarget)) return;
|
||||
|
||||
this._scheduleSubmenuClose(trigger);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
_openSubmenu(trigger) {
|
||||
// Clear any pending close
|
||||
if (this.submenuTimeout) {
|
||||
clearTimeout(this.submenuTimeout);
|
||||
this.submenuTimeout = null;
|
||||
}
|
||||
|
||||
// Hide any previously open submenu
|
||||
if (this.openSubmenu && this.openSubmenu !== trigger) {
|
||||
this._hideSubmenu(this.openSubmenu);
|
||||
}
|
||||
|
||||
const submenu = trigger.querySelector('.context-submenu');
|
||||
if (!submenu) return;
|
||||
|
||||
submenu.style.display = 'block';
|
||||
this.openSubmenu = trigger;
|
||||
this._positionSubmenu(submenu);
|
||||
}
|
||||
|
||||
_scheduleSubmenuClose(trigger) {
|
||||
this.submenuTimeout = setTimeout(() => {
|
||||
this._hideSubmenu(trigger);
|
||||
this.submenuTimeout = null;
|
||||
}, 250);
|
||||
}
|
||||
|
||||
_hideSubmenu(trigger) {
|
||||
const submenu = trigger.querySelector('.context-submenu');
|
||||
if (submenu) {
|
||||
submenu.style.display = 'none';
|
||||
submenu.classList.remove('flip-left');
|
||||
}
|
||||
if (this.openSubmenu === trigger) {
|
||||
this.openSubmenu = null;
|
||||
}
|
||||
}
|
||||
|
||||
_positionSubmenu(submenu) {
|
||||
const submenuRect = submenu.getBoundingClientRect();
|
||||
const viewportWidth = document.documentElement.clientWidth;
|
||||
|
||||
if (submenuRect.right > viewportWidth) {
|
||||
submenu.classList.add('flip-left');
|
||||
} else {
|
||||
submenu.classList.remove('flip-left');
|
||||
}
|
||||
}
|
||||
|
||||
handleMenuAction(action, menuItem) {
|
||||
// Override in subclass
|
||||
console.warn('handleMenuAction not implemented');
|
||||
@@ -40,34 +121,41 @@ export class BaseContextMenu {
|
||||
|
||||
// Get menu dimensions
|
||||
const menuRect = this.menu.getBoundingClientRect();
|
||||
|
||||
|
||||
// Get viewport dimensions
|
||||
const viewportWidth = document.documentElement.clientWidth;
|
||||
const viewportHeight = document.documentElement.clientHeight;
|
||||
|
||||
|
||||
// Calculate position
|
||||
let finalX = x;
|
||||
let finalY = y;
|
||||
|
||||
|
||||
// Ensure menu doesn't go offscreen right
|
||||
if (x + menuRect.width > viewportWidth) {
|
||||
finalX = x - menuRect.width;
|
||||
}
|
||||
|
||||
|
||||
// Ensure menu doesn't go offscreen bottom
|
||||
if (y + menuRect.height > viewportHeight) {
|
||||
finalY = y - menuRect.height;
|
||||
}
|
||||
|
||||
|
||||
// Position menu
|
||||
this.menu.style.left = `${finalX}px`;
|
||||
this.menu.style.top = `${finalY}px`;
|
||||
}
|
||||
|
||||
hideMenu() {
|
||||
if (this.submenuTimeout) {
|
||||
clearTimeout(this.submenuTimeout);
|
||||
this.submenuTimeout = null;
|
||||
}
|
||||
if (this.openSubmenu) {
|
||||
this._hideSubmenu(this.openSubmenu);
|
||||
}
|
||||
if (this.menu) {
|
||||
this.menu.style.display = 'none';
|
||||
}
|
||||
this.currentCard = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { bulkManager } from '../../managers/BulkManager.js';
|
||||
import { updateElementText, translate } from '../../utils/i18nHelpers.js';
|
||||
import { bulkMissingLoraDownloadManager } from '../../managers/BulkMissingLoraDownloadManager.js';
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||
|
||||
export class BulkContextMenu extends BaseContextMenu {
|
||||
constructor() {
|
||||
@@ -50,6 +51,14 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
if (copyAllItem) {
|
||||
copyAllItem.style.display = config.copyAll ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
// Submenu parent visibility
|
||||
const sendToWorkflowSubmenu = this.menu.querySelector('[data-has-submenu="send-to-workflow"]');
|
||||
if (sendToWorkflowSubmenu) {
|
||||
const hasWorkflowActions = config.sendToWorkflow || config.copyAll;
|
||||
sendToWorkflowSubmenu.style.display = hasWorkflowActions ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
if (refreshAllItem) {
|
||||
refreshAllItem.style.display = config.refreshAll ? 'flex' : 'none';
|
||||
}
|
||||
@@ -74,11 +83,46 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
if (setContentRatingItem) {
|
||||
setContentRatingItem.style.display = config.setContentRating ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
const setFavoriteItem = this.menu.querySelector('[data-action="set-favorite"]');
|
||||
|
||||
if (setFavoriteItem && config.setFavorite) {
|
||||
setFavoriteItem.style.display = 'flex';
|
||||
|
||||
const total = state.selectedModels.size;
|
||||
const favoritedCount = this.countFavoritedInSelection();
|
||||
const allFavorited = total > 0 && favoritedCount === total;
|
||||
|
||||
const icon = setFavoriteItem.querySelector('i');
|
||||
const label = setFavoriteItem.querySelector('span');
|
||||
|
||||
if (allFavorited) {
|
||||
if (icon) { icon.className = 'far fa-star'; }
|
||||
if (label) { label.textContent = translate('loras.bulkOperations.unfavorite'); }
|
||||
} else {
|
||||
if (icon) { icon.className = 'fas fa-star'; }
|
||||
if (label) {
|
||||
label.textContent = favoritedCount > 0
|
||||
? translate('loras.bulkOperations.setFavoriteCount', { favorited: favoritedCount, total })
|
||||
: translate('loras.bulkOperations.setFavorite');
|
||||
}
|
||||
}
|
||||
} else if (setFavoriteItem) {
|
||||
setFavoriteItem.style.display = 'none';
|
||||
}
|
||||
|
||||
if (downloadMissingLorasItem) {
|
||||
// Only show for recipes page
|
||||
downloadMissingLorasItem.style.display = currentModelType === 'recipes' ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
const downloadExampleImagesItem = this.menu.querySelector('[data-action="download-example-images"]');
|
||||
if (downloadExampleImagesItem) {
|
||||
// Show on model pages (loras, checkpoints, embeddings), hide on recipes
|
||||
const modelPages = ['loras', 'checkpoints', 'embeddings'];
|
||||
downloadExampleImagesItem.style.display = modelPages.includes(currentModelType) ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
const skipMetadataRefreshItem = this.menu.querySelector('[data-action="skip-metadata-refresh"]');
|
||||
const resumeMetadataRefreshItem = this.menu.querySelector('[data-action="resume-metadata-refresh"]');
|
||||
|
||||
@@ -112,6 +156,14 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Hide empty sections
|
||||
this.menu.querySelectorAll('.context-menu-section').forEach(section => {
|
||||
const items = Array.from(section.querySelectorAll('.context-menu-item'))
|
||||
.filter(item => !item.closest('.context-submenu'));
|
||||
const allHidden = items.length > 0 && items.every(item => item.style.display === 'none');
|
||||
section.style.display = allHidden ? 'none' : '';
|
||||
});
|
||||
}
|
||||
|
||||
updateSelectedCountHeader() {
|
||||
@@ -138,6 +190,20 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
return count;
|
||||
}
|
||||
|
||||
countFavoritedInSelection() {
|
||||
let count = 0;
|
||||
for (const filePath of state.selectedModels) {
|
||||
const escapedPath = window.CSS && typeof window.CSS.escape === 'function'
|
||||
? window.CSS.escape(filePath)
|
||||
: filePath.replace(/["\\]/g, '\\$&');
|
||||
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
|
||||
if (card && card.dataset.favorite === 'true') {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
showMenu(x, y, card) {
|
||||
this.updateMenuItemsForModelType();
|
||||
this.updateSelectedCountHeader();
|
||||
@@ -185,9 +251,17 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
case 'delete-all':
|
||||
bulkManager.showBulkDeleteModal();
|
||||
break;
|
||||
case 'set-favorite': {
|
||||
const allFavorited = this.countFavoritedInSelection() === state.selectedModels.size;
|
||||
bulkManager.setBulkFavorites(!allFavorited);
|
||||
break;
|
||||
}
|
||||
case 'download-missing-loras':
|
||||
this.handleDownloadMissingLoras();
|
||||
break;
|
||||
case 'download-example-images':
|
||||
this.handleDownloadExampleImages();
|
||||
break;
|
||||
case 'clear':
|
||||
bulkManager.clearSelection();
|
||||
break;
|
||||
@@ -230,4 +304,31 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
|
||||
await bulkMissingLoraDownloadManager.downloadMissingLoras(selectedRecipes);
|
||||
}
|
||||
|
||||
async handleDownloadExampleImages() {
|
||||
if (state.selectedModels.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hashes = new Set();
|
||||
for (const filePath of state.selectedModels) {
|
||||
const escapedPath = CSS.escape(filePath);
|
||||
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
|
||||
if (card?.dataset?.sha256) {
|
||||
hashes.add(card.dataset.sha256);
|
||||
}
|
||||
}
|
||||
|
||||
if (hashes.size === 0) {
|
||||
showToast('No valid model hashes found in selection', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const apiClient = getModelApiClient();
|
||||
await apiClient.downloadExampleImages([...hashes]);
|
||||
} catch (error) {
|
||||
console.error('Bulk download example images failed:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, openCivitaiByMetadata } from '../utils/uiHelpers.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { setSessionItem, removeSessionItem } from '../utils/storageHelpers.js';
|
||||
import { setSessionItem, removeSessionItem, getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||
import { fetchRecipeDetails, updateRecipeMetadata } from '../api/recipeApi.js';
|
||||
import { downloadManager } from '../managers/DownloadManager.js';
|
||||
import { MODEL_TYPES } from '../api/apiConfig.js';
|
||||
import { openMediaViewer } from './shared/MediaViewer.js';
|
||||
|
||||
const ALLOWED_GEN_PARAM_KEYS = new Set([
|
||||
'prompt',
|
||||
@@ -104,6 +105,7 @@ class RecipeModal {
|
||||
|
||||
init() {
|
||||
this.setupCopyButtons();
|
||||
this.setupStripLoraToggle();
|
||||
this.setupPromptEditors();
|
||||
// Set up tooltip positioning handlers after DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
@@ -112,6 +114,23 @@ class RecipeModal {
|
||||
|
||||
// Set up document click handler to close edit fields
|
||||
document.addEventListener('click', (event) => {
|
||||
const recipeModal = document.getElementById('recipeModal');
|
||||
if (recipeModal && recipeModal.style.display !== 'none') {
|
||||
const mediaEl = event.target.closest('.recipe-preview-media');
|
||||
if (mediaEl && mediaEl.tagName) {
|
||||
event.stopPropagation();
|
||||
const isVideo = mediaEl.tagName === 'VIDEO';
|
||||
const url = mediaEl.src || mediaEl.currentSrc;
|
||||
if (url) {
|
||||
openMediaViewer(url, {
|
||||
type: isVideo ? 'video' : 'image',
|
||||
title: document.getElementById('recipeModalTitle')?.textContent || ''
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle title edit
|
||||
const titleEditor = document.getElementById('recipeTitleEditor');
|
||||
if (titleEditor && titleEditor.classList.contains('active') &&
|
||||
@@ -364,6 +383,7 @@ class RecipeModal {
|
||||
|
||||
this.syncGenerationParams(hydratedRecipe.gen_params);
|
||||
this.syncResourcesSection(hydratedRecipe);
|
||||
this.syncSourceUrlAction();
|
||||
|
||||
// Show the modal
|
||||
modalManager.showModal('recipeModal');
|
||||
@@ -496,6 +516,7 @@ class RecipeModal {
|
||||
} else {
|
||||
this.updateSourceUrlDisplay(this.currentRecipe.source_path || '');
|
||||
}
|
||||
this.syncSourceUrlAction();
|
||||
}
|
||||
|
||||
getPreviewMediaUrl(recipe = {}) {
|
||||
@@ -563,6 +584,30 @@ class RecipeModal {
|
||||
}
|
||||
}
|
||||
|
||||
syncSourceUrlAction() {
|
||||
const actionsContainer = document.getElementById('recipeHeaderActions');
|
||||
if (!actionsContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
actionsContainer.innerHTML = '';
|
||||
|
||||
const sourcePath = this.currentRecipe?.source_path || '';
|
||||
const isValidUrl = sourcePath.startsWith('http://') || sourcePath.startsWith('https://');
|
||||
if (!isValidUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'recipe-source-url-btn';
|
||||
btn.title = sourcePath;
|
||||
btn.innerHTML = '<i class="fas fa-globe"></i> Open Source URL';
|
||||
btn.addEventListener('click', () => {
|
||||
window.open(sourcePath, '_blank');
|
||||
});
|
||||
actionsContainer.appendChild(btn);
|
||||
}
|
||||
|
||||
syncTagsDisplay(tags) {
|
||||
const tagsContainer = document.getElementById('recipeTagsCompact');
|
||||
if (!tagsContainer) {
|
||||
@@ -1297,6 +1342,7 @@ class RecipeModal {
|
||||
// Update source URL in the UI
|
||||
this.commitField('source_path');
|
||||
this.updateSourceUrlDisplay(newSourceUrl, { forceInputSync: true });
|
||||
this.syncSourceUrlAction();
|
||||
|
||||
// Update the current recipe object
|
||||
this.currentRecipe.source_path = newSourceUrl;
|
||||
@@ -1332,14 +1378,20 @@ class RecipeModal {
|
||||
|
||||
if (copyPromptBtn) {
|
||||
copyPromptBtn.addEventListener('click', () => {
|
||||
const promptText = this.currentRecipe?.gen_params?.prompt || '';
|
||||
let promptText = this.currentRecipe?.gen_params?.prompt || '';
|
||||
if (this.shouldStripLoraOnCopy()) {
|
||||
promptText = RecipeModal.stripLoraTags(promptText);
|
||||
}
|
||||
this.copyToClipboard(promptText, 'Prompt copied to clipboard');
|
||||
});
|
||||
}
|
||||
|
||||
if (copyNegativePromptBtn) {
|
||||
copyNegativePromptBtn.addEventListener('click', () => {
|
||||
const negativePromptText = this.currentRecipe?.gen_params?.negative_prompt || '';
|
||||
let negativePromptText = this.currentRecipe?.gen_params?.negative_prompt || '';
|
||||
if (this.shouldStripLoraOnCopy()) {
|
||||
negativePromptText = RecipeModal.stripLoraTags(negativePromptText);
|
||||
}
|
||||
this.copyToClipboard(negativePromptText, 'Negative prompt copied to clipboard');
|
||||
});
|
||||
}
|
||||
@@ -1359,6 +1411,43 @@ class RecipeModal {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip <lora:...> tags from prompt text and clean up residual punctuation/whitespace.
|
||||
* Handles both unescaped (<lora:...>) and HTML-escaped (<lora:...>) variants.
|
||||
* Cleans up artifacts like leading ", ", double commas, and extra whitespace.
|
||||
*/
|
||||
static stripLoraTags(text) {
|
||||
return text
|
||||
.replace(/<lora:[^>]*>/gi, '')
|
||||
.replace(/<lora:[^&]*>/gi, '')
|
||||
.replace(/,(\s*,)+/g, ',')
|
||||
.replace(/^,\s*/, '')
|
||||
.replace(/,\s*$/, '')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
shouldStripLoraOnCopy() {
|
||||
const toggle = document.getElementById('stripLoraOnCopyToggle');
|
||||
return toggle ? toggle.checked : false;
|
||||
}
|
||||
|
||||
setupStripLoraToggle() {
|
||||
const toggle = document.getElementById('stripLoraOnCopyToggle');
|
||||
if (!toggle) return;
|
||||
|
||||
const stored = getStorageItem('strip_lora_on_copy');
|
||||
if (stored !== null) {
|
||||
toggle.checked = stored === true;
|
||||
}
|
||||
|
||||
toggle.addEventListener('change', () => {
|
||||
const checked = toggle.checked;
|
||||
setStorageItem('strip_lora_on_copy', checked);
|
||||
state.global.settings.strip_lora_on_copy = checked;
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch recipe syntax from backend and copy to clipboard
|
||||
async fetchAndCopyRecipeSyntax() {
|
||||
if (!this.recipeId) {
|
||||
|
||||
@@ -166,17 +166,6 @@ export class PageControls {
|
||||
});
|
||||
});
|
||||
|
||||
// Handle quick refresh option
|
||||
const quickRefreshOption = document.querySelector('[data-action="quick-refresh"]');
|
||||
if (quickRefreshOption) {
|
||||
quickRefreshOption.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.refreshModels(false);
|
||||
// Close the dropdown
|
||||
document.querySelector('.dropdown-group.active')?.classList.remove('active');
|
||||
});
|
||||
}
|
||||
|
||||
// Handle full rebuild option
|
||||
const fullRebuildOption = document.querySelector('[data-action="full-rebuild"]');
|
||||
if (fullRebuildOption) {
|
||||
@@ -829,4 +818,4 @@ export class PageControls {
|
||||
this.sidebarManager.cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
204
static/js/components/shared/MediaViewer.js
Normal file
204
static/js/components/shared/MediaViewer.js
Normal file
@@ -0,0 +1,204 @@
|
||||
let activeViewer = null;
|
||||
|
||||
function createMediaElement(item) {
|
||||
const { url, type = 'image' } = item;
|
||||
if (type === 'video') {
|
||||
const el = document.createElement('video');
|
||||
el.controls = true;
|
||||
el.autoplay = true;
|
||||
el.loop = true;
|
||||
el.muted = true;
|
||||
el.className = 'media-viewer-media media-viewer-video';
|
||||
el.src = url;
|
||||
return el;
|
||||
}
|
||||
const el = document.createElement('img');
|
||||
el.className = 'media-viewer-media media-viewer-image';
|
||||
el.src = url;
|
||||
el.alt = 'Full size preview';
|
||||
el.draggable = false;
|
||||
return el;
|
||||
}
|
||||
|
||||
function preloadAdjacent(items, index) {
|
||||
[index - 1, index + 1].forEach(i => {
|
||||
if (i >= 0 && i < items.length && items[i].type !== 'video') {
|
||||
const preload = new Image();
|
||||
preload.src = items[i].url;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function openMediaViewer(arg1, arg2, arg3) {
|
||||
closeMediaViewer();
|
||||
|
||||
let items, currentIndex, title = '';
|
||||
|
||||
if (Array.isArray(arg1)) {
|
||||
items = arg1;
|
||||
currentIndex = typeof arg2 === 'number' ? arg2 : 0;
|
||||
title = (arg3 && arg3.title) || '';
|
||||
} else {
|
||||
items = [{ url: arg1, type: (arg2 && arg2.type) || 'image' }];
|
||||
currentIndex = 0;
|
||||
title = (arg2 && arg2.title) || '';
|
||||
}
|
||||
|
||||
if (currentIndex < 0 || currentIndex >= items.length) currentIndex = 0;
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'media-viewer-overlay';
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-label', title || 'Media viewer');
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'media-viewer-close';
|
||||
closeBtn.innerHTML = '<i class="fas fa-times"></i>';
|
||||
closeBtn.title = 'Close (Esc)';
|
||||
closeBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
closeMediaViewer();
|
||||
});
|
||||
|
||||
const contentContainer = document.createElement('div');
|
||||
contentContainer.className = 'media-viewer-content-container';
|
||||
|
||||
let mediaElement = createMediaElement(items[currentIndex]);
|
||||
contentContainer.appendChild(mediaElement);
|
||||
|
||||
const hasNavigation = items.length > 1;
|
||||
|
||||
const counter = document.createElement('div');
|
||||
counter.className = 'media-viewer-counter';
|
||||
counter.textContent = hasNavigation ? `${currentIndex + 1} / ${items.length}` : '';
|
||||
contentContainer.appendChild(counter);
|
||||
|
||||
if (title) {
|
||||
const titleBar = document.createElement('div');
|
||||
titleBar.className = 'media-viewer-title';
|
||||
titleBar.textContent = title;
|
||||
contentContainer.appendChild(titleBar);
|
||||
}
|
||||
|
||||
let prevBtn, nextBtn;
|
||||
if (hasNavigation) {
|
||||
prevBtn = document.createElement('button');
|
||||
prevBtn.className = 'media-viewer-nav media-viewer-prev';
|
||||
prevBtn.innerHTML = '<i class="fas fa-chevron-left"></i>';
|
||||
prevBtn.title = 'Previous (←)';
|
||||
nextBtn = document.createElement('button');
|
||||
nextBtn.className = 'media-viewer-nav media-viewer-next';
|
||||
nextBtn.innerHTML = '<i class="fas fa-chevron-right"></i>';
|
||||
nextBtn.title = 'Next (→)';
|
||||
|
||||
const navigate = (delta) => {
|
||||
const newIndex = (currentIndex + delta + items.length) % items.length;
|
||||
currentIndex = newIndex;
|
||||
|
||||
const oldMedia = contentContainer.querySelector('.media-viewer-media');
|
||||
const newMedia = createMediaElement(items[currentIndex]);
|
||||
|
||||
if (oldMedia) {
|
||||
if (oldMedia.tagName === 'VIDEO') {
|
||||
oldMedia.pause();
|
||||
oldMedia.src = '';
|
||||
}
|
||||
oldMedia.replaceWith(newMedia);
|
||||
}
|
||||
mediaElement = newMedia;
|
||||
|
||||
counter.textContent = `${currentIndex + 1} / ${items.length}`;
|
||||
preloadAdjacent(items, currentIndex);
|
||||
};
|
||||
|
||||
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
|
||||
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(1); });
|
||||
|
||||
overlay.appendChild(prevBtn);
|
||||
overlay.appendChild(nextBtn);
|
||||
}
|
||||
|
||||
overlay.appendChild(closeBtn);
|
||||
overlay.appendChild(contentContainer);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
overlay.classList.add('active');
|
||||
});
|
||||
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) {
|
||||
closeMediaViewer();
|
||||
}
|
||||
});
|
||||
|
||||
const keyHandler = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeMediaViewer();
|
||||
return;
|
||||
}
|
||||
if (hasNavigation) {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
prevBtn.click();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
nextBtn.click();
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', keyHandler, true);
|
||||
|
||||
activeViewer = { overlay, keyHandler };
|
||||
preloadAdjacent(items, currentIndex);
|
||||
|
||||
if (items[currentIndex].type === 'video') {
|
||||
const recipeVideo = document.getElementById('recipeModalVideo');
|
||||
if (recipeVideo && !recipeVideo.paused) {
|
||||
recipeVideo.pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function closeMediaViewer() {
|
||||
if (!activeViewer) return;
|
||||
|
||||
const { overlay, keyHandler } = activeViewer;
|
||||
|
||||
const video = overlay.querySelector('video');
|
||||
if (video) {
|
||||
video.pause();
|
||||
video.src = '';
|
||||
}
|
||||
|
||||
const img = overlay.querySelector('img');
|
||||
if (img) {
|
||||
img.src = '';
|
||||
}
|
||||
|
||||
document.removeEventListener('keydown', keyHandler, true);
|
||||
|
||||
overlay.classList.remove('active');
|
||||
overlay.addEventListener('transitionend', () => {
|
||||
if (overlay.parentNode) {
|
||||
overlay.parentNode.removeChild(overlay);
|
||||
}
|
||||
}, { once: true });
|
||||
|
||||
setTimeout(() => {
|
||||
if (overlay.parentNode) {
|
||||
overlay.parentNode.removeChild(overlay);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
activeViewer = null;
|
||||
}
|
||||
|
||||
export function isMediaViewerOpen() {
|
||||
return activeViewer !== null;
|
||||
}
|
||||
@@ -644,8 +644,23 @@ export function createModelCard(model, modelType) {
|
||||
<div class="card-footer">
|
||||
<div class="model-info">
|
||||
<span class="model-name" title="${getDisplayName(model).replace(/"/g, '"')}">${getDisplayName(model)}</span>
|
||||
<div>
|
||||
${model.civitai?.name ? `<span class="version-name civitai-version">${model.civitai.name}</span>` : ''}
|
||||
<div class="version-row">
|
||||
${(() => {
|
||||
const autoTags = model.auto_tags || [];
|
||||
const hlTags = autoTags.filter(t => t === 'HIGH' || t === 'LOW');
|
||||
const hasVersionName = model.civitai?.name;
|
||||
if (!hlTags.length && !hasVersionName) return '';
|
||||
const density = state.global.settings.display_density || 'default';
|
||||
const shortLabels = density === 'medium' || density === 'compact';
|
||||
const badges = hlTags.map(t => {
|
||||
const cls = t === 'HIGH' ? 'hl-badge hl-badge--high' : 'hl-badge hl-badge--low';
|
||||
const label = shortLabels ? (t === 'HIGH' ? 'H' : 'L') : t;
|
||||
const titleAttr = shortLabels ? ` title="${t}"` : '';
|
||||
return `<span class="${cls}"${titleAttr}>${label}</span>`;
|
||||
}).join('');
|
||||
const versionHtml = hasVersionName ? `<span class="version-name civitai-version">${model.civitai.name}</span>` : '';
|
||||
return `<span class="badge-version-unit">${badges}${versionHtml}</span>`;
|
||||
})()}
|
||||
${hasUsageCount ? `<span class="version-name" title="${translate('modelCard.usage.timesUsed', {}, 'Times used')}">${model.usage_count}×</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -241,7 +241,7 @@ function buildActionButton(label, variant, action, options = {}) {
|
||||
if (action) {
|
||||
attributes.push(`data-version-action="${escapeHtml(action)}"`);
|
||||
}
|
||||
if (options.title) {
|
||||
if (!options.disabled && options.title) {
|
||||
attributes.push(`title="${escapeHtml(options.title)}"`);
|
||||
attributes.push(`aria-label="${escapeHtml(options.title)}"`);
|
||||
}
|
||||
@@ -251,7 +251,11 @@ function buildActionButton(label, variant, action, options = {}) {
|
||||
if (options.extraAttributes) {
|
||||
attributes.push(options.extraAttributes);
|
||||
}
|
||||
return `<button ${attributes.join(' ')}>${options.iconMarkup || ''}${escapeHtml(label)}</button>`;
|
||||
const buttonHtml = `<button ${attributes.join(' ')}>${options.iconMarkup || ''}${escapeHtml(label)}</button>`;
|
||||
if (options.disabled && options.title) {
|
||||
return `<span class="version-action-disabled-wrapper" title="${escapeHtml(options.title)}" aria-label="${escapeHtml(options.title)}">${buttonHtml}</span>`;
|
||||
}
|
||||
return buttonHtml;
|
||||
}
|
||||
|
||||
const DISPLAY_FILTER_MODES = Object.freeze({
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { generateMetadataPanel } from './MetadataPanel.js';
|
||||
import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js';
|
||||
import { getShowcaseUrl } from '../../../utils/civitaiUtils.js';
|
||||
import { openMediaViewer } from '../MediaViewer.js';
|
||||
|
||||
export const showcaseListenerMetrics = {
|
||||
wheelListeners: 0,
|
||||
@@ -640,6 +641,27 @@ export function initShowcaseContent(carousel) {
|
||||
initMediaControlHandlers(carousel);
|
||||
positionAllMediaControls(carousel);
|
||||
|
||||
// Click-to-view: open full-size media viewer when clicking showcase images/videos
|
||||
const viewerElements = carousel.querySelectorAll('.media-wrapper img, .media-wrapper video');
|
||||
const allItems = [];
|
||||
const elementIndexMap = new Map();
|
||||
viewerElements.forEach((el) => {
|
||||
const isVideo = el.tagName === 'VIDEO';
|
||||
const url = el.src || el.dataset.localSrc || el.dataset.remoteSrc;
|
||||
if (url) {
|
||||
elementIndexMap.set(el, allItems.length);
|
||||
allItems.push({ url, type: isVideo ? 'video' : 'image' });
|
||||
}
|
||||
});
|
||||
viewerElements.forEach((mediaEl) => {
|
||||
const idx = elementIndexMap.get(mediaEl);
|
||||
if (idx === undefined) return;
|
||||
mediaEl.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
openMediaViewer(allItems, idx);
|
||||
});
|
||||
});
|
||||
|
||||
// Bind scroll-indicator click events
|
||||
bindScrollIndicatorEvents(carousel);
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { showToast, copyToClipboard, sendLoraToWorkflow, buildLoraSyntax, getNSF
|
||||
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
|
||||
import { modalManager } from './ModalManager.js';
|
||||
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||
import { RecipeSidebarApiClient } from '../api/recipeApi.js';
|
||||
import { RecipeSidebarApiClient, updateRecipeMetadata } from '../api/recipeApi.js';
|
||||
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
|
||||
import { BASE_MODEL_CATEGORIES } from '../utils/constants.js';
|
||||
import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js';
|
||||
@@ -41,7 +41,9 @@ export class BulkManager {
|
||||
autoOrganize: true,
|
||||
deleteAll: true,
|
||||
setContentRating: true,
|
||||
skipMetadataRefresh: true
|
||||
skipMetadataRefresh: true,
|
||||
setFavorite: true,
|
||||
unfavorite: true
|
||||
},
|
||||
[MODEL_TYPES.EMBEDDING]: {
|
||||
addTags: true,
|
||||
@@ -53,7 +55,9 @@ export class BulkManager {
|
||||
autoOrganize: true,
|
||||
deleteAll: true,
|
||||
setContentRating: false,
|
||||
skipMetadataRefresh: true
|
||||
skipMetadataRefresh: true,
|
||||
setFavorite: true,
|
||||
unfavorite: true
|
||||
},
|
||||
[MODEL_TYPES.CHECKPOINT]: {
|
||||
addTags: true,
|
||||
@@ -65,7 +69,9 @@ export class BulkManager {
|
||||
autoOrganize: true,
|
||||
deleteAll: true,
|
||||
setContentRating: true,
|
||||
skipMetadataRefresh: true
|
||||
skipMetadataRefresh: true,
|
||||
setFavorite: true,
|
||||
unfavorite: true
|
||||
},
|
||||
recipes: {
|
||||
addTags: false,
|
||||
@@ -77,7 +83,9 @@ export class BulkManager {
|
||||
autoOrganize: false,
|
||||
deleteAll: true,
|
||||
setContentRating: false,
|
||||
skipMetadataRefresh: false
|
||||
skipMetadataRefresh: false,
|
||||
setFavorite: true,
|
||||
unfavorite: true
|
||||
}
|
||||
};
|
||||
|
||||
@@ -538,9 +546,23 @@ export class BulkManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const countElement = document.getElementById('bulkDeleteCount');
|
||||
if (countElement) {
|
||||
countElement.textContent = state.selectedModels.size;
|
||||
const count = state.selectedModels.size;
|
||||
const isRecipes = state.currentPageType === 'recipes';
|
||||
const keyPrefix = isRecipes ? 'modals.bulkDeleteRecipes' : 'modals.bulkDelete';
|
||||
|
||||
const titleEl = document.querySelector('#bulkDeleteModal h2');
|
||||
if (titleEl) {
|
||||
titleEl.textContent = translate(`${keyPrefix}.title`);
|
||||
}
|
||||
|
||||
const messageEl = document.querySelector('#bulkDeleteModal .delete-message');
|
||||
if (messageEl) {
|
||||
messageEl.textContent = translate(`${keyPrefix}.message`);
|
||||
}
|
||||
|
||||
const countInfoEl = document.querySelector('#bulkDeleteModal .delete-model-info p');
|
||||
if (countInfoEl) {
|
||||
countInfoEl.innerHTML = `<span id="bulkDeleteCount">${count}</span> ${translate(`${keyPrefix}.countMessage`)}`;
|
||||
}
|
||||
|
||||
modalManager.showModal('bulkDeleteModal');
|
||||
@@ -1090,6 +1112,60 @@ export class BulkManager {
|
||||
}
|
||||
}
|
||||
|
||||
async setBulkFavorites(value) {
|
||||
if (state.selectedModels.size === 0) {
|
||||
showToast('toast.models.noModelsSelected', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const totalCount = state.selectedModels.size;
|
||||
const isRecipesPage = state.currentPageType === 'recipes';
|
||||
|
||||
state.loadingManager.showSimpleLoading(
|
||||
translate(value ? 'toast.models.bulkFavoriteUpdating' : 'toast.models.bulkUnfavoriteUpdating', { count: totalCount })
|
||||
);
|
||||
let cancelled = false;
|
||||
state.loadingManager.showCancelButton(() => {
|
||||
cancelled = true;
|
||||
});
|
||||
|
||||
let successCount = 0;
|
||||
let failureCount = 0;
|
||||
|
||||
try {
|
||||
for (const filePath of state.selectedModels) {
|
||||
if (cancelled) {
|
||||
showToast('toast.api.operationCancelled', {}, 'info');
|
||||
break;
|
||||
}
|
||||
try {
|
||||
if (isRecipesPage) {
|
||||
await updateRecipeMetadata(filePath, { favorite: value });
|
||||
} else {
|
||||
const apiClient = getModelApiClient();
|
||||
await apiClient.saveModelMetadata(filePath, { favorite: value });
|
||||
}
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
failureCount++;
|
||||
console.error(`Failed to set favorite=${value} for ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
state.loadingManager?.hide?.();
|
||||
}
|
||||
|
||||
if (successCount === totalCount) {
|
||||
const toastKey = value ? 'modelCard.favorites.added' : 'modelCard.favorites.removed';
|
||||
showToast(toastKey, {}, 'success');
|
||||
} else if (successCount > 0) {
|
||||
const toastKey = value ? 'toast.models.bulkFavoritePartialAdded' : 'toast.models.bulkFavoritePartialRemoved';
|
||||
showToast(toastKey, { success: successCount, failed: failureCount }, 'warning');
|
||||
} else {
|
||||
showToast('toast.models.bulkFavoriteFailed', {}, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show bulk base model modal
|
||||
*/
|
||||
|
||||
@@ -70,6 +70,9 @@ export class FilterManager {
|
||||
// Initialize tag logic toggle
|
||||
this.initializeTagLogicToggle();
|
||||
|
||||
// Create auto-tag filter section (I2V, T2V, TI2V, Lightning, Turbo)
|
||||
this.createAutoTagFilters();
|
||||
|
||||
// Add click handler for filter button
|
||||
if (this.filterButton) {
|
||||
this.filterButton.addEventListener('click', () => {
|
||||
@@ -480,6 +483,58 @@ export class FilterManager {
|
||||
}
|
||||
}
|
||||
|
||||
AUTO_TAG_FILTER_TAGS = ['I2V', 'T2V', 'TI2V', 'Lightning', 'Turbo'];
|
||||
|
||||
createAutoTagFilters() {
|
||||
const container = document.getElementById('autoTagFilterTags');
|
||||
if (container) return;
|
||||
|
||||
const modelTypeSection = document.getElementById('modelTypeTags')?.closest('.filter-section');
|
||||
if (!modelTypeSection) return;
|
||||
|
||||
const section = document.createElement('div');
|
||||
section.className = 'filter-section';
|
||||
section.innerHTML = `
|
||||
<h4>${translate('header.filter.autoTags', {}, 'Auto Tags')}</h4>
|
||||
<div class="filter-tags" id="autoTagFilterTags"></div>
|
||||
`;
|
||||
modelTypeSection.parentNode.insertBefore(section, modelTypeSection.nextSibling);
|
||||
|
||||
const tagsContainer = document.getElementById('autoTagFilterTags');
|
||||
this.AUTO_TAG_FILTER_TAGS.forEach(tag => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'filter-tag auto-tag-filter';
|
||||
el.dataset.autoTag = tag;
|
||||
el.textContent = tag;
|
||||
|
||||
// Restore previous state
|
||||
const state = (this.filters.autoTags && this.filters.autoTags[tag]) || 'none';
|
||||
this._applyTriState(el, state);
|
||||
|
||||
el.addEventListener('click', async () => {
|
||||
const current = (this.filters.autoTags && this.filters.autoTags[tag]) || 'none';
|
||||
const next = current === 'none' ? 'include' : current === 'include' ? 'exclude' : 'none';
|
||||
if (!this.filters.autoTags) this.filters.autoTags = {};
|
||||
if (next === 'none') {
|
||||
delete this.filters.autoTags[tag];
|
||||
} else {
|
||||
this.filters.autoTags[tag] = next;
|
||||
}
|
||||
this._applyTriState(el, next);
|
||||
this.updateActiveFiltersCount();
|
||||
await this.applyFilters(false);
|
||||
});
|
||||
|
||||
tagsContainer.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
_applyTriState(el, state) {
|
||||
el.classList.remove('active', 'exclude');
|
||||
if (state === 'include') el.classList.add('active');
|
||||
else if (state === 'exclude') el.classList.add('exclude');
|
||||
}
|
||||
|
||||
toggleFilterPanel() {
|
||||
if (this.filterPanel) {
|
||||
const isHidden = this.filterPanel.classList.contains('hidden');
|
||||
@@ -540,6 +595,13 @@ export class FilterManager {
|
||||
this.updateLicenseSelections();
|
||||
}
|
||||
this.updateModelTypeSelections();
|
||||
|
||||
const autoTagEls = document.querySelectorAll('.auto-tag-filter');
|
||||
autoTagEls.forEach(el => {
|
||||
const tag = el.dataset.autoTag;
|
||||
const state = (this.filters.autoTags && this.filters.autoTags[tag]) || 'none';
|
||||
this._applyTriState(el, state);
|
||||
});
|
||||
}
|
||||
|
||||
updateModelTypeSelections() {
|
||||
@@ -556,11 +618,12 @@ export class FilterManager {
|
||||
|
||||
updateActiveFiltersCount() {
|
||||
const tagFilterCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0;
|
||||
const autoTagFilterCount = this.filters.autoTags ? Object.keys(this.filters.autoTags).length : 0;
|
||||
const licenseFilterCount = this.filters.license ? Object.keys(this.filters.license).length : 0;
|
||||
const modelTypeFilterCount = this.filters.modelTypes.length;
|
||||
// Exclude EMPTY_WILDCARD_MARKER from base model count
|
||||
const baseModelCount = this.filters.baseModel.filter(m => m !== EMPTY_WILDCARD_MARKER).length;
|
||||
const totalActiveFilters = baseModelCount + tagFilterCount + licenseFilterCount + modelTypeFilterCount;
|
||||
const totalActiveFilters = baseModelCount + tagFilterCount + autoTagFilterCount + licenseFilterCount + modelTypeFilterCount;
|
||||
|
||||
if (this.activeFiltersCount) {
|
||||
if (totalActiveFilters > 0) {
|
||||
@@ -652,6 +715,7 @@ export class FilterManager {
|
||||
...this.filters,
|
||||
baseModel: [],
|
||||
tags: {},
|
||||
autoTags: {},
|
||||
license: {},
|
||||
modelTypes: [],
|
||||
tagLogic: 'any'
|
||||
@@ -721,6 +785,7 @@ export class FilterManager {
|
||||
|
||||
hasActiveFilters() {
|
||||
const tagCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0;
|
||||
const autoTagCount = this.filters.autoTags ? Object.keys(this.filters.autoTags).length : 0;
|
||||
const licenseCount = this.filters.license ? Object.keys(this.filters.license).length : 0;
|
||||
const modelTypeCount = this.filters.modelTypes.length;
|
||||
// Exclude EMPTY_WILDCARD_MARKER from base model count
|
||||
@@ -728,6 +793,7 @@ export class FilterManager {
|
||||
return (
|
||||
baseModelCount > 0 ||
|
||||
tagCount > 0 ||
|
||||
autoTagCount > 0 ||
|
||||
licenseCount > 0 ||
|
||||
modelTypeCount > 0
|
||||
);
|
||||
@@ -739,6 +805,7 @@ export class FilterManager {
|
||||
...source,
|
||||
baseModel: Array.isArray(source.baseModel) ? [...source.baseModel] : [],
|
||||
tags: this.normalizeTagFilters(source.tags),
|
||||
autoTags: this.normalizeTagFilters(source.autoTags),
|
||||
license: this.shouldShowLicenseFilters() ? this.normalizeLicenseFilters(source.license) : {},
|
||||
modelTypes: this.normalizeModelTypeFilters(source.modelTypes),
|
||||
tagLogic: source.tagLogic || 'any'
|
||||
@@ -822,6 +889,7 @@ export class FilterManager {
|
||||
...this.filters,
|
||||
baseModel: [...(this.filters.baseModel || [])],
|
||||
tags: { ...(this.filters.tags || {}) },
|
||||
autoTags: { ...(this.filters.autoTags || {}) },
|
||||
license: { ...(this.filters.license || {}) },
|
||||
modelTypes: [...(this.filters.modelTypes || [])],
|
||||
tagLogic: this.filters.tagLogic || 'any'
|
||||
|
||||
@@ -286,16 +286,6 @@ class RecipeManager {
|
||||
});
|
||||
});
|
||||
|
||||
// Handle quick refresh option (Sync Changes)
|
||||
const quickRefreshOption = document.querySelector('[data-action="quick-refresh"]');
|
||||
if (quickRefreshOption) {
|
||||
quickRefreshOption.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.pageControls.refreshModels(false);
|
||||
this.closeDropdowns();
|
||||
});
|
||||
}
|
||||
|
||||
// Handle full rebuild option (Rebuild Cache)
|
||||
const fullRebuildOption = document.querySelector('[data-action="full-rebuild"]');
|
||||
if (fullRebuildOption) {
|
||||
@@ -407,4 +397,4 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
});
|
||||
|
||||
// Export for use in other modules
|
||||
export { RecipeManager };
|
||||
export { RecipeManager };
|
||||
@@ -50,6 +50,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||
download_skip_base_models: [],
|
||||
backup_auto_enabled: true,
|
||||
backup_retention_count: 5,
|
||||
strip_lora_on_copy: false,
|
||||
});
|
||||
|
||||
export function createDefaultSettings() {
|
||||
|
||||
@@ -66,6 +66,9 @@ export const BASE_MODELS = {
|
||||
HUNYUAN_VIDEO: "Hunyuan Video",
|
||||
// Other models
|
||||
ANIMA: "Anima",
|
||||
ERNIE: "Ernie",
|
||||
ERNIE_TURBO: "Ernie Turbo",
|
||||
NUCLEUS: "Nucleus",
|
||||
PONY_V7: "Pony V7",
|
||||
// Default
|
||||
UNKNOWN: "Other"
|
||||
@@ -191,6 +194,9 @@ export const BASE_MODEL_ABBREVIATIONS = {
|
||||
[BASE_MODELS.ZIMAGE_TURBO]: 'ZIT',
|
||||
[BASE_MODELS.ZIMAGE_BASE]: 'ZIB',
|
||||
[BASE_MODELS.ANIMA]: 'ANI',
|
||||
[BASE_MODELS.ERNIE]: 'ERNI',
|
||||
[BASE_MODELS.ERNIE_TURBO]: 'ETRB',
|
||||
[BASE_MODELS.NUCLEUS]: 'NUCL',
|
||||
|
||||
// Default
|
||||
[BASE_MODELS.UNKNOWN]: 'OTH'
|
||||
@@ -394,6 +400,7 @@ export const BASE_MODEL_CATEGORIES = {
|
||||
BASE_MODELS.QWEN, BASE_MODELS.AURAFLOW, BASE_MODELS.CHROMA, BASE_MODELS.ZIMAGE_TURBO, BASE_MODELS.ZIMAGE_BASE,
|
||||
BASE_MODELS.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1,
|
||||
BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI, BASE_MODELS.ANIMA,
|
||||
BASE_MODELS.ERNIE, BASE_MODELS.ERNIE_TURBO, BASE_MODELS.NUCLEUS,
|
||||
BASE_MODELS.UNKNOWN
|
||||
]
|
||||
};
|
||||
@@ -493,6 +500,18 @@ export function clearDynamicBaseModels() {
|
||||
dynamicBaseModelsTimestamp = null;
|
||||
}
|
||||
|
||||
export const AUTO_TAG_GROUPS = {
|
||||
mode: new Set(['HIGH', 'LOW']),
|
||||
video: new Set(['I2V', 'T2V', 'TI2V']),
|
||||
speed: new Set(['Lightning', 'Turbo']),
|
||||
};
|
||||
|
||||
export const AUTO_TAG_GROUP_LABELS = {
|
||||
mode: 'High / Low',
|
||||
video: 'I2V / T2V / TI2V',
|
||||
speed: 'Lightning / Turbo',
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if dynamic base models cache is valid
|
||||
* @returns {boolean}
|
||||
|
||||
@@ -53,46 +53,74 @@
|
||||
<span>{{ t('loras.bulkOperations.selected', {'count': 0}) }}</span>
|
||||
</div>
|
||||
<div class="context-menu-separator"></div>
|
||||
<div class="context-menu-item" data-action="refresh-all">
|
||||
<i class="fas fa-sync-alt"></i> <span>{{ t('loras.bulkOperations.refreshAll') }}</span>
|
||||
<div class="context-menu-section" data-section="workflow">
|
||||
<div class="context-menu-section-header">{{ t('loras.bulkOperations.sections.workflow') }}</div>
|
||||
<div class="context-menu-item has-submenu" data-has-submenu="send-to-workflow">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
<span>{{ t('loras.bulkOperations.sendToWorkflow') }}</span>
|
||||
<i class="fas fa-chevron-right submenu-arrow"></i>
|
||||
<div class="context-submenu">
|
||||
<div class="context-menu-item" data-action="send-to-workflow-append">
|
||||
<i class="fas fa-paper-plane"></i> <span>{{ t('loras.contextMenu.sendToWorkflowAppend') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="send-to-workflow-replace">
|
||||
<i class="fas fa-exchange-alt"></i> <span>{{ t('loras.contextMenu.sendToWorkflowReplace') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="copy-all">
|
||||
<i class="fas fa-copy"></i> <span>{{ t('loras.bulkOperations.copyAll') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="check-updates">
|
||||
<i class="fas fa-bell"></i> <span>{{ t('loras.bulkOperations.checkUpdates') }}</span>
|
||||
<div class="context-menu-section" data-section="metadata">
|
||||
<div class="context-menu-section-header">{{ t('loras.bulkOperations.sections.metadata') }}</div>
|
||||
<div class="context-menu-item" data-action="refresh-all">
|
||||
<i class="fas fa-sync-alt"></i> <span>{{ t('loras.bulkOperations.refreshAll') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="check-updates">
|
||||
<i class="fas fa-bell"></i> <span>{{ t('loras.bulkOperations.checkUpdates') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="skip-metadata-refresh">
|
||||
<i class="fas fa-ban"></i> <span>{{ t('loras.bulkOperations.skipMetadataRefresh') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="resume-metadata-refresh">
|
||||
<i class="fas fa-redo"></i> <span>{{ t('loras.bulkOperations.resumeMetadataRefresh') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="copy-all">
|
||||
<i class="fas fa-copy"></i> <span>{{ t('loras.bulkOperations.copyAll') }}</span>
|
||||
<div class="context-menu-section" data-section="attributes">
|
||||
<div class="context-menu-section-header">{{ t('loras.bulkOperations.sections.attributes') }}</div>
|
||||
<div class="context-menu-item" data-action="add-tags">
|
||||
<i class="fas fa-tags"></i> <span>{{ t('loras.bulkOperations.addTags') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="set-base-model">
|
||||
<i class="fas fa-layer-group"></i> <span>{{ t('loras.bulkOperations.setBaseModel') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="set-favorite">
|
||||
<i class="fas fa-star"></i> <span>{{ t('loras.bulkOperations.setFavorite') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="set-content-rating">
|
||||
<i class="fas fa-exclamation-triangle"></i> <span>{{ t('loras.bulkOperations.setContentRating') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="send-to-workflow-append">
|
||||
<i class="fas fa-paper-plane"></i> <span>{{ t('loras.contextMenu.sendToWorkflowAppend') }}</span>
|
||||
<div class="context-menu-section" data-section="organize">
|
||||
<div class="context-menu-section-header">{{ t('loras.bulkOperations.sections.organize') }}</div>
|
||||
<div class="context-menu-item" data-action="auto-organize">
|
||||
<i class="fas fa-magic"></i> <span>{{ t('loras.bulkOperations.autoOrganize') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="move-all">
|
||||
<i class="fas fa-folder-open"></i> <span>{{ t('loras.bulkOperations.moveAll') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="send-to-workflow-replace">
|
||||
<i class="fas fa-exchange-alt"></i> <span>{{ t('loras.contextMenu.sendToWorkflowReplace') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="auto-organize">
|
||||
<i class="fas fa-magic"></i> <span>{{ t('loras.bulkOperations.autoOrganize') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="add-tags">
|
||||
<i class="fas fa-tags"></i> <span>{{ t('loras.bulkOperations.addTags') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="set-base-model">
|
||||
<i class="fas fa-layer-group"></i> <span>{{ t('loras.bulkOperations.setBaseModel') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="set-content-rating">
|
||||
<i class="fas fa-exclamation-triangle"></i> <span>{{ t('loras.bulkOperations.setContentRating') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="skip-metadata-refresh">
|
||||
<i class="fas fa-ban"></i> <span>{{ t('loras.bulkOperations.skipMetadataRefresh') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="resume-metadata-refresh">
|
||||
<i class="fas fa-redo"></i> <span>{{ t('loras.bulkOperations.resumeMetadataRefresh') }}</span>
|
||||
<div class="context-menu-section" data-section="download">
|
||||
<div class="context-menu-section-header">{{ t('loras.bulkOperations.sections.download') }}</div>
|
||||
<div class="context-menu-item" data-action="download-example-images">
|
||||
<i class="fas fa-download"></i> <span>{{ t('loras.bulkOperations.downloadExamples') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="download-missing-loras">
|
||||
<i class="fas fa-download"></i> <span>{{ t('loras.bulkOperations.downloadMissingLoras') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="context-menu-separator"></div>
|
||||
<div class="context-menu-item" data-action="download-missing-loras">
|
||||
<i class="fas fa-download"></i> <span>{{ t('loras.bulkOperations.downloadMissingLoras') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="move-all">
|
||||
<i class="fas fa-folder-open"></i> <span>{{ t('loras.bulkOperations.moveAll') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item delete-item" data-action="delete-all">
|
||||
<i class="fas fa-trash"></i> <span>{{ t('loras.bulkOperations.deleteAll') }}</span>
|
||||
</div>
|
||||
|
||||
@@ -41,9 +41,6 @@
|
||||
<i class="fas fa-caret-down"></i>
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<div class="dropdown-item" data-action="quick-refresh" title="{{ t('loras.controls.refresh.quickTooltip') }}">
|
||||
<i class="fas fa-bolt"></i> <span>{{ t('loras.controls.refresh.quick') }}</span>
|
||||
</div>
|
||||
<div class="dropdown-item" data-action="full-rebuild" title="{{ t('loras.controls.refresh.fullTooltip') }}">
|
||||
<i class="fas fa-tools"></i> <span>{{ t('loras.controls.refresh.full') }}</span>
|
||||
</div>
|
||||
@@ -129,4 +126,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
<header class="recipe-modal-header">
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<!-- Header Actions: populated dynamically in RecipeModal.js -->
|
||||
<div class="recipe-header-actions" id="recipeHeaderActions"></div>
|
||||
<!-- Recipe Tags Container -->
|
||||
<div class="recipe-tags-container">
|
||||
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
||||
@@ -22,7 +24,16 @@
|
||||
</div>
|
||||
|
||||
<div class="info-section recipe-gen-params">
|
||||
<h3>Generation Parameters</h3>
|
||||
<div class="gen-params-header-row">
|
||||
<h3>Generation Parameters</h3>
|
||||
<label class="inline-toggle-container lora-strip-toggle" title="When enabled, <lora:...> tags are removed from prompt text when copying">
|
||||
<span class="inline-toggle-label">Strip <lora:></span>
|
||||
<div class="toggle-switch">
|
||||
<input type="checkbox" id="stripLoraOnCopyToggle">
|
||||
<span class="toggle-slider"></span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="gen-params-container">
|
||||
<!-- Prompt -->
|
||||
|
||||
@@ -75,9 +75,6 @@
|
||||
<i class="fas fa-caret-down"></i>
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<div class="dropdown-item" data-action="quick-refresh" title="{{ t('recipes.controls.refresh.quickTooltip', default='Sync changes - quick refresh without rebuilding cache') }}">
|
||||
<i class="fas fa-bolt"></i> <span>{{ t('loras.controls.refresh.quick', default='Sync Changes') }}</span>
|
||||
</div>
|
||||
<div class="dropdown-item" data-action="full-rebuild" title="{{ t('recipes.controls.refresh.fullTooltip', default='Rebuild cache - full rescan of all recipe files') }}">
|
||||
<i class="fas fa-tools"></i> <span>{{ t('loras.controls.refresh.full', default='Rebuild Cache') }}</span>
|
||||
</div>
|
||||
@@ -196,4 +193,4 @@
|
||||
|
||||
{% block main_script %}
|
||||
<script type="module" src="/loras_static/js/recipes.js?v={{ version }}"></script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
@@ -114,7 +114,8 @@ describe('LoRA widget drag interactions', () => {
|
||||
dragEl.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1 }));
|
||||
expect(document.body.classList.contains('lm-lora-strength-dragging')).toBe(false);
|
||||
expect(onDragEnd).toHaveBeenCalledTimes(1);
|
||||
expect(renderSpy).toHaveBeenCalledWith(widget.value, widget);
|
||||
// 454210a4 replaced renderFunction() with widget.value setter + widget.callback()
|
||||
expect(widget.callback).toHaveBeenCalledWith(widget.value);
|
||||
});
|
||||
|
||||
it('deletes the selected LoRA when backspace is pressed outside of strength inputs', async () => {
|
||||
|
||||
@@ -135,7 +135,6 @@ function renderControlsDom(pageKey) {
|
||||
<button data-action="refresh" class="dropdown-main"></button>
|
||||
<button class="dropdown-toggle"></button>
|
||||
<div class="dropdown-menu">
|
||||
<div class="dropdown-item" data-action="quick-refresh"></div>
|
||||
<div class="dropdown-item" data-action="full-rebuild"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -930,4 +929,4 @@ describe('PageControls favorites, sorting, and duplicates scenarios', () => {
|
||||
expect(stateModule.state.bulkMode).toBe(true);
|
||||
expect(pageState.duplicatesMode).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -79,7 +79,7 @@ class FakeDownloadHistoryService:
|
||||
async def mark_downloaded(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
async def mark_not_downloaded(self, *_args, **_kwargs):
|
||||
async def mark_as_deleted(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -903,7 +903,7 @@ class FakeDownloadHistoryService:
|
||||
(model_type, version_id, model_id, source, file_path)
|
||||
)
|
||||
|
||||
async def mark_not_downloaded(self, model_type, version_id):
|
||||
async def mark_as_deleted(self, model_type, version_id):
|
||||
self.marked_not_downloaded.append((model_type, version_id))
|
||||
|
||||
|
||||
|
||||
@@ -785,10 +785,16 @@ async def test_import_remote_recipe_merges_metadata(
|
||||
async def parse_metadata(self, raw, recipe_scanner=None):
|
||||
return json.loads(raw[len("Recipe metadata: ") :])
|
||||
|
||||
class MockApiParser:
|
||||
async def parse_metadata(self, raw, recipe_scanner=None):
|
||||
return {"gen_params": raw, "loras": []}
|
||||
|
||||
class MockFactory:
|
||||
def create_parser(self, raw):
|
||||
if raw.startswith("Recipe metadata: "):
|
||||
if isinstance(raw, str) and raw.startswith("Recipe metadata: "):
|
||||
return MockParser()
|
||||
if isinstance(raw, dict):
|
||||
return MockApiParser()
|
||||
return None
|
||||
|
||||
# 4. Setup Harness and run test
|
||||
|
||||
@@ -222,7 +222,7 @@ async def test_get_model_versions_raises_on_other_errors(monkeypatch, downloader
|
||||
async def test_get_model_versions_bulk_success(monkeypatch, downloader):
|
||||
async def fake_make_request(method, url, use_auth=True, **kwargs):
|
||||
assert url.endswith("/models")
|
||||
assert kwargs.get("params") == {"ids": "1,2"}
|
||||
assert kwargs.get("params") == {"ids": "1,2", "nsfw": "true"}
|
||||
return True, {
|
||||
"items": [
|
||||
{
|
||||
|
||||
@@ -30,7 +30,7 @@ async def test_download_history_roundtrip_and_manual_override(tmp_path: Path) ->
|
||||
assert await service.has_been_downloaded("lora", 101) is True
|
||||
assert await service.get_downloaded_version_ids("lora", 11) == [101]
|
||||
|
||||
await service.mark_not_downloaded("lora", 101)
|
||||
await service.mark_as_deleted("lora", 101)
|
||||
assert await service.has_been_downloaded("lora", 101) is False
|
||||
assert await service.get_downloaded_version_ids("lora", 11) == []
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ async def test_repair_all_recipes_with_enriched_checkpoint_id(setup_scanner):
|
||||
recipe = {
|
||||
"id": "r1",
|
||||
"title": "Old Recipe",
|
||||
"source_url": "https://civitai.com/images/12345",
|
||||
"source_path": "https://civitai.com/images/12345",
|
||||
"checkpoint": None,
|
||||
"gen_params": {"prompt": ""}
|
||||
}
|
||||
@@ -127,7 +127,7 @@ async def test_repair_all_recipes_supports_civitai_red_source_url(setup_scanner)
|
||||
recipe = {
|
||||
"id": "r1",
|
||||
"title": "Red Recipe",
|
||||
"source_url": "https://civitai.red/images/12345",
|
||||
"source_path": "https://civitai.red/images/12345",
|
||||
"checkpoint": None,
|
||||
"gen_params": {"prompt": ""},
|
||||
}
|
||||
|
||||
151
tests/test_auto_tag_service.py
Normal file
151
tests/test_auto_tag_service.py
Normal file
@@ -0,0 +1,151 @@
|
||||
import pytest
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "py"))
|
||||
|
||||
from services.auto_tag_service import extract_auto_tags, AUTO_TAG_CATEGORIES
|
||||
|
||||
|
||||
class TestExtractAutoTags:
|
||||
def test_file_name_high_i2v(self):
|
||||
result = extract_auto_tags({
|
||||
"file_name": "Shirt_lift_Wan2.2_14B_I2V_HIGH_v1.0",
|
||||
"base_model": "Wan Video 2.2 I2V-A14B",
|
||||
"civitai": {},
|
||||
})
|
||||
assert set(result) == {"HIGH", "I2V"}
|
||||
|
||||
def test_file_name_t2v_low(self):
|
||||
result = extract_auto_tags({
|
||||
"file_name": "my_wan_t2v_low_v2",
|
||||
"base_model": "Wan 2.1",
|
||||
"civitai": {},
|
||||
})
|
||||
assert set(result) == {"LOW", "T2V"}
|
||||
|
||||
def test_file_name_ti2v_high(self):
|
||||
result = extract_auto_tags({
|
||||
"file_name": "wan_ti2v_high_quality",
|
||||
"base_model": "Wan 2.2",
|
||||
"civitai": {},
|
||||
})
|
||||
assert set(result) == {"HIGH", "TI2V"}
|
||||
|
||||
def test_file_name_lightning_turbo(self):
|
||||
result = extract_auto_tags({
|
||||
"file_name": "sdxl_lightning_turbo_v3",
|
||||
"base_model": "SDXL",
|
||||
"civitai": {},
|
||||
})
|
||||
assert set(result) == {"Lightning", "Turbo"}
|
||||
|
||||
def test_base_model_source(self):
|
||||
result = extract_auto_tags({
|
||||
"file_name": "my_lora_v1",
|
||||
"base_model": "Wan Video 2.2 I2V-A14B",
|
||||
"civitai": {},
|
||||
})
|
||||
assert "I2V" in result
|
||||
|
||||
def test_civitai_name_source(self):
|
||||
result = extract_auto_tags({
|
||||
"file_name": "model_v1",
|
||||
"base_model": "Wan",
|
||||
"civitai": {"name": "HIGH Quality"},
|
||||
})
|
||||
assert "HIGH" in result
|
||||
|
||||
def test_no_false_match_flow(self):
|
||||
result = extract_auto_tags({
|
||||
"file_name": "flux_dev_model",
|
||||
"base_model": "Flux.1 D",
|
||||
"civitai": {},
|
||||
})
|
||||
assert "LOW" not in result
|
||||
|
||||
def test_no_false_match_glow(self):
|
||||
result = extract_auto_tags({
|
||||
"file_name": "glow_style_lora",
|
||||
"base_model": "SDXL",
|
||||
"civitai": {},
|
||||
})
|
||||
assert "LOW" not in result
|
||||
|
||||
def test_high_low_only_for_wan(self):
|
||||
"""HIGH/LOW should not appear for non-Wan models even in filename."""
|
||||
result = extract_auto_tags({
|
||||
"file_name": "my_model_high_quality_v2",
|
||||
"base_model": "Flux.1 D",
|
||||
"civitai": {"name": "HIGH"},
|
||||
})
|
||||
assert "HIGH" not in result
|
||||
assert "LOW" not in result
|
||||
|
||||
def test_no_distilled(self):
|
||||
result = extract_auto_tags({
|
||||
"file_name": "ltx-2.3-22b-distilled-lora-384",
|
||||
"base_model": "LTXV 2.3",
|
||||
"civitai": {},
|
||||
})
|
||||
assert result == []
|
||||
|
||||
def test_empty(self):
|
||||
result = extract_auto_tags({
|
||||
"file_name": "generic_lora_v1",
|
||||
"base_model": "SDXL",
|
||||
"civitai": {},
|
||||
})
|
||||
assert result == []
|
||||
|
||||
def test_missing_fields(self):
|
||||
result = extract_auto_tags({})
|
||||
assert result == []
|
||||
|
||||
def test_dash_separated(self):
|
||||
result = extract_auto_tags({
|
||||
"file_name": "wan-i2v-high-v2",
|
||||
"base_model": "Wan 2.2",
|
||||
"civitai": {},
|
||||
})
|
||||
assert set(result) == {"HIGH", "I2V"}
|
||||
|
||||
def test_dot_separated(self):
|
||||
result = extract_auto_tags({
|
||||
"file_name": "wan.i2v.high.v2",
|
||||
"base_model": "Wan 2.2",
|
||||
"civitai": {},
|
||||
})
|
||||
assert set(result) == {"HIGH", "I2V"}
|
||||
|
||||
def test_case_insensitive(self):
|
||||
result = extract_auto_tags({
|
||||
"file_name": "WAN_i2v_High",
|
||||
"base_model": "Wan 2.2",
|
||||
"civitai": {},
|
||||
})
|
||||
assert set(result) == {"HIGH", "I2V"}
|
||||
|
||||
|
||||
class TestAutoTagCategories:
|
||||
def test_all_patterns_compile(self):
|
||||
import re
|
||||
for label, pattern in AUTO_TAG_CATEGORIES.items():
|
||||
re.compile(pattern, re.IGNORECASE)
|
||||
|
||||
def test_mode_group_tags(self):
|
||||
from services.auto_tag_service import MODE_TAGS
|
||||
assert "HIGH" in MODE_TAGS
|
||||
assert "LOW" in MODE_TAGS
|
||||
|
||||
def test_video_group_tags(self):
|
||||
from services.auto_tag_service import VIDEO_MODE_TAGS
|
||||
assert "I2V" in VIDEO_MODE_TAGS
|
||||
assert "T2V" in VIDEO_MODE_TAGS
|
||||
assert "TI2V" in VIDEO_MODE_TAGS
|
||||
|
||||
def test_default_enabled_groups(self):
|
||||
from services.auto_tag_service import DEFAULT_ENABLED_GROUPS
|
||||
assert "mode" in DEFAULT_ENABLED_GROUPS
|
||||
assert "video" in DEFAULT_ENABLED_GROUPS
|
||||
assert "speed" not in DEFAULT_ENABLED_GROUPS
|
||||
@@ -658,32 +658,34 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
|
||||
textEl.style.maxWidth = "140px";
|
||||
}
|
||||
|
||||
const countBadge = document.createElement("span");
|
||||
countBadge.className = "lm-trigger-count-badge";
|
||||
countBadge.textContent = `${groupState.activeChildren}/${groupState.totalChildren}`;
|
||||
Object.assign(countBadge.style, {
|
||||
fontSize: "11px",
|
||||
padding: "1px 6px",
|
||||
borderRadius: "999px",
|
||||
backgroundColor: "rgba(255,255,255,0.12)",
|
||||
color: "inherit",
|
||||
flexShrink: "0",
|
||||
boxSizing: "border-box",
|
||||
minWidth: "42px",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
lineHeight: "1",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
});
|
||||
if (groupState.hasInactiveChildren) {
|
||||
countBadge.classList.add("lm-trigger-count-badge--edited");
|
||||
if (tagData.items.length > 1) {
|
||||
const countBadge = document.createElement("span");
|
||||
countBadge.className = "lm-trigger-count-badge";
|
||||
countBadge.textContent = `${groupState.activeChildren}/${groupState.totalChildren}`;
|
||||
Object.assign(countBadge.style, {
|
||||
backgroundColor: "rgba(255,255,255,0.08)",
|
||||
boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.28)",
|
||||
fontSize: "11px",
|
||||
padding: "1px 6px",
|
||||
borderRadius: "999px",
|
||||
backgroundColor: "rgba(255,255,255,0.12)",
|
||||
color: "inherit",
|
||||
flexShrink: "0",
|
||||
boxSizing: "border-box",
|
||||
minWidth: "42px",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
lineHeight: "1",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
});
|
||||
if (groupState.hasInactiveChildren) {
|
||||
countBadge.classList.add("lm-trigger-count-badge--edited");
|
||||
Object.assign(countBadge.style, {
|
||||
backgroundColor: "rgba(255,255,255,0.08)",
|
||||
boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.28)",
|
||||
});
|
||||
}
|
||||
groupChip.appendChild(countBadge);
|
||||
}
|
||||
groupChip.appendChild(countBadge);
|
||||
|
||||
if (showStrengthInfo) {
|
||||
const strengthBadge = createStrengthBadge();
|
||||
@@ -697,39 +699,43 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
|
||||
groupChip.title = activePreview ? `${tagData.text}\nActive: ${activePreview}` : tagData.text;
|
||||
}
|
||||
|
||||
const editButton = document.createElement("button");
|
||||
editButton.type = "button";
|
||||
editButton.className = "lm-trigger-group-edit-button";
|
||||
editButton.textContent = "⋯";
|
||||
Object.assign(editButton.style, {
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: "inherit",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px",
|
||||
lineHeight: "1",
|
||||
padding: "0 2px",
|
||||
marginLeft: "2px",
|
||||
opacity: groupState.hasInactiveChildren ? "0.9" : "0.72",
|
||||
flexShrink: "0",
|
||||
});
|
||||
editButton.title = "Edit group tags";
|
||||
let editButton = null;
|
||||
|
||||
const openEditor = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
toggleGroupEditor(widget, index, groupChip);
|
||||
renderGroupEditor(widget, tagData, index);
|
||||
};
|
||||
if (tagData.items.length > 1) {
|
||||
editButton = document.createElement("button");
|
||||
editButton.type = "button";
|
||||
editButton.className = "lm-trigger-group-edit-button";
|
||||
editButton.textContent = "⋯";
|
||||
Object.assign(editButton.style, {
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: "inherit",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px",
|
||||
lineHeight: "1",
|
||||
padding: "0 2px",
|
||||
marginLeft: "2px",
|
||||
opacity: groupState.hasInactiveChildren ? "0.9" : "0.72",
|
||||
flexShrink: "0",
|
||||
});
|
||||
editButton.title = "Edit group tags";
|
||||
|
||||
editButton.addEventListener("click", openEditor);
|
||||
groupChip.addEventListener("contextmenu", openEditor);
|
||||
const openEditor = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
toggleGroupEditor(widget, index, groupChip);
|
||||
renderGroupEditor(widget, tagData, index);
|
||||
};
|
||||
|
||||
groupChip.appendChild(editButton);
|
||||
editButton.addEventListener("click", openEditor);
|
||||
groupChip.addEventListener("contextmenu", openEditor);
|
||||
|
||||
groupChip.appendChild(editButton);
|
||||
}
|
||||
|
||||
groupChip.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (e.target === editButton) {
|
||||
if (editButton && e.target === editButton) {
|
||||
return;
|
||||
}
|
||||
updateWidgetValue(widget, (updatedTags) => {
|
||||
@@ -740,7 +746,7 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
|
||||
|
||||
if (showStrengthInfo) {
|
||||
groupChip.addEventListener("wheel", (e) => {
|
||||
if (e.target === editButton) {
|
||||
if (editButton && e.target === editButton) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
|
||||
@@ -303,6 +303,8 @@ app.registerExtension({
|
||||
return;
|
||||
}
|
||||
|
||||
const groupMode = groupModeWidget?.value ?? false;
|
||||
|
||||
const updatedTags = node.tagWidget.value.map((tag) => {
|
||||
if (!Array.isArray(tag.items)) {
|
||||
return {
|
||||
@@ -311,6 +313,15 @@ app.registerExtension({
|
||||
};
|
||||
}
|
||||
|
||||
// In group mode, default_active only controls the group-level switch.
|
||||
// Children's individual active states are managed exclusively via the group editor.
|
||||
if (groupMode) {
|
||||
return {
|
||||
...tag,
|
||||
active: value,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...tag,
|
||||
active: value,
|
||||
@@ -320,7 +331,6 @@ app.registerExtension({
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
node.tagWidget.value = updatedTags;
|
||||
node.applyTriggerHighlightState?.();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user