mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-26 05:24:09 -03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3555ddb588 | ||
|
|
ede15032ce | ||
|
|
c48feeddb6 | ||
|
|
8a80f82d93 | ||
|
|
c4676183b5 | ||
|
|
8b7ba59263 | ||
|
|
067e605e75 | ||
|
|
dae18b3d1d | ||
|
|
2f9bd3ee7d | ||
|
|
755e1a5bca | ||
|
|
c202654d49 | ||
|
|
74736f7560 | ||
|
|
0ada32d0c7 | ||
|
|
e9aff35957 | ||
|
|
521531111a | ||
|
|
474da1b264 | ||
|
|
78d38b449e | ||
|
|
2bc9860b24 | ||
|
|
327da0465b | ||
|
|
c8c84bfc54 | ||
|
|
3b9e8efb3d | ||
|
|
d45a523fb5 | ||
|
|
6dc9f34f7d | ||
|
|
5adfa3be36 | ||
|
|
d4b82d98b2 | ||
|
|
8c1c1691e3 | ||
|
|
e14a084f0d | ||
|
|
c55c6f0a41 | ||
|
|
7d963b27b5 | ||
|
|
eba03800b9 | ||
|
|
bf497d5144 | ||
|
|
369613f811 | ||
|
|
9eeebac40b | ||
|
|
b9a516c9f8 | ||
|
|
ef7fa7d3dd | ||
|
|
9c67dbbf15 |
@@ -15,6 +15,7 @@ node_modules/
|
||||
coverage/
|
||||
.coverage
|
||||
model_cache/
|
||||
recipe_cache/
|
||||
|
||||
# agent / dev tooling
|
||||
.opencode/
|
||||
|
||||
@@ -192,6 +192,15 @@ The system runs in two modes:
|
||||
- Auto-saves paths to `settings.json` in ComfyUI mode
|
||||
- `settings.json.example` is intentionally minimal (see Important Notes); all
|
||||
other defaults live in `DEFAULT_SETTINGS` (`py/services/settings_manager.py`)
|
||||
- **`folder_paths` vs `extra_folder_paths` — different purposes, do not conflate:**
|
||||
- `folder_paths` (primary model roots): in ComfyUI plugin mode these come
|
||||
from the ComfyUI host; in standalone mode they are the ONLY source of
|
||||
model library paths and are currently edited by hand in `settings.json`.
|
||||
- `extra_folder_paths` is a **ComfyUI-plugin-mode feature**: paths visible
|
||||
ONLY to LoRA Manager, not to ComfyUI. Its motivation is that a very large
|
||||
model library slows ComfyUI itself down, while LoRA Manager handles large
|
||||
libraries without performance issues — so users keep ComfyUI's library
|
||||
small and add the bulk via `extra_folder_paths`.
|
||||
|
||||
### Frontend UI Architecture
|
||||
|
||||
@@ -252,6 +261,11 @@ If a cross-layer issue ever needs a live server, the sandboxed helpers live in
|
||||
## Important Notes
|
||||
|
||||
- ALWAYS use English for comments (per copilot-instructions.md)
|
||||
- **`.civitai.info` files are NOT LoRA Manager sidecars.** They are written by
|
||||
third-party apps; LoRA Manager treats them as read-only and only consumes
|
||||
them during migration/import. Never write, modify, or delete them, and never
|
||||
propose doing so as a fix — LoRA Manager's own metadata lives in the
|
||||
`.metadata.json` sidecar it owns.
|
||||
- **`settings.json.example` must stay minimal**: only `use_portable_settings`,
|
||||
`civitai_api_key`, and the four core `folder_paths` keys (`loras`,
|
||||
`checkpoints`, `unet`, `embeddings`). Do NOT add optional/default keys
|
||||
|
||||
@@ -18,6 +18,7 @@ try: # pragma: no cover - import fallback for pytest collection
|
||||
from .py.nodes.lora_info import LoraInfoLM
|
||||
from .py.nodes.lora_syntax_to_path import LoraSyntaxToPath
|
||||
from .py.nodes.create_hook_lora import CreateHookLoraLM
|
||||
from .py.nodes.load_image_metadata import LoadImageMetadataLM
|
||||
from .py.nodes.metadata_overwrite import MetadataOverwriteLM
|
||||
from .py.metadata_collector import init as init_metadata_collector
|
||||
except (
|
||||
@@ -70,6 +71,7 @@ except (
|
||||
MetadataOverwriteLM = importlib.import_module(
|
||||
"py.nodes.metadata_overwrite"
|
||||
).MetadataOverwriteLM
|
||||
LoadImageMetadataLM = importlib.import_module("py.nodes.load_image_metadata").LoadImageMetadataLM
|
||||
init_metadata_collector = importlib.import_module("py.metadata_collector").init
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
@@ -93,6 +95,7 @@ NODE_CLASS_MAPPINGS = {
|
||||
LoraSyntaxToPath.NAME: LoraSyntaxToPath,
|
||||
CreateHookLoraLM.NAME: CreateHookLoraLM,
|
||||
MetadataOverwriteLM.NAME: MetadataOverwriteLM,
|
||||
LoadImageMetadataLM.NAME: LoadImageMetadataLM,
|
||||
}
|
||||
|
||||
WEB_DIRECTORY = "./web/comfyui"
|
||||
|
||||
+214
-203
@@ -6,36 +6,46 @@
|
||||
"Scott R"
|
||||
],
|
||||
"allSupporters": [
|
||||
"2018cfh",
|
||||
"Takkan",
|
||||
"Charles Blakemore",
|
||||
"Rob Williams",
|
||||
"megakirbs",
|
||||
"Brennok",
|
||||
"2018cfh",
|
||||
"Rob Williams",
|
||||
"Charles Blakemore",
|
||||
"Arlecchino Shion",
|
||||
"Insomnia Art Designs",
|
||||
"Skalabananen",
|
||||
"Mozzel",
|
||||
"Gingko Biloba",
|
||||
"stone9k",
|
||||
"Kiba",
|
||||
"onesecondinosaur",
|
||||
"Skalabananen",
|
||||
"Sterilized",
|
||||
"Polymorphic Indeterminate",
|
||||
"Marc Whiffen",
|
||||
"stone9k",
|
||||
"Rosenthal",
|
||||
"Francisco Tatis",
|
||||
"Kiba",
|
||||
"Birdy",
|
||||
"onesecondinosaur",
|
||||
"Reno Lam",
|
||||
"Sterilized",
|
||||
"Liam MacDougal",
|
||||
"sig",
|
||||
"Christian Byrne",
|
||||
"DM",
|
||||
"Sen314",
|
||||
"Estragon",
|
||||
"Rosenthal",
|
||||
"J\\B/ 8r0wns0n",
|
||||
"ClockDaemon",
|
||||
"Francisco Tatis",
|
||||
"KD",
|
||||
"Omnidex",
|
||||
"Tobi_Swagg",
|
||||
"SG",
|
||||
"James Dooley",
|
||||
"zenbound",
|
||||
"jmack",
|
||||
"Andrew Wilson",
|
||||
"Greybush",
|
||||
"Ricky Carter",
|
||||
"James Todd",
|
||||
"JongWon Han",
|
||||
"VantAI",
|
||||
"レプサイ",
|
||||
@@ -45,28 +55,28 @@
|
||||
"JackieWang",
|
||||
"FreelancerZ",
|
||||
"fnkylove",
|
||||
"Vik71it",
|
||||
"Echo",
|
||||
"Lilleman",
|
||||
"Robert Stacey",
|
||||
"PM",
|
||||
"Marc Whiffen",
|
||||
"Dogwalkerbr",
|
||||
"Birdy",
|
||||
"quarz",
|
||||
"$MetaSamsara",
|
||||
"Greg",
|
||||
"jean jahren",
|
||||
"Reno Lam",
|
||||
"Aleksander Wujczyk",
|
||||
"AM Kuro",
|
||||
"JSST",
|
||||
"sig",
|
||||
"J\\B/ 8r0wns0n",
|
||||
"Snaggwort",
|
||||
"lmsupporter",
|
||||
"wfpearl",
|
||||
"jeaness",
|
||||
"Anthony+Rizzo",
|
||||
"W+K+White",
|
||||
"Baekdoosixt",
|
||||
"Jonathan Ross",
|
||||
"KD",
|
||||
"Omnidex",
|
||||
"Jack B Nimble",
|
||||
"Nolife_M",
|
||||
"Melville Parrish",
|
||||
"daniel dove",
|
||||
@@ -75,30 +85,32 @@
|
||||
"Release Cabrakan",
|
||||
"JW Sin",
|
||||
"Alex",
|
||||
"bh",
|
||||
"carozzz",
|
||||
"Marlon Daniels",
|
||||
"James Dooley",
|
||||
"zenbound",
|
||||
"Buzzard",
|
||||
"Aaron Bleuer",
|
||||
"LacesOut!",
|
||||
"Adam Shaw",
|
||||
"Mark Corneglio",
|
||||
"RedrockVP",
|
||||
"James Todd",
|
||||
"Wicked Choices by ASLPro3D",
|
||||
"Jacob Hoehler",
|
||||
"FinalyFree",
|
||||
"Weasyl",
|
||||
"Fyf",
|
||||
"Timmy",
|
||||
"Johnny",
|
||||
"Cory Paza",
|
||||
"Tak",
|
||||
"Lisster",
|
||||
"Big Red",
|
||||
"whudunit",
|
||||
"Luc Job",
|
||||
"Philip Hempel",
|
||||
"corde",
|
||||
"Yushio",
|
||||
"Vik71it",
|
||||
"Bishoujoker",
|
||||
"Echo",
|
||||
"Todd Keck",
|
||||
"Briton Heilbrun",
|
||||
"wildnut",
|
||||
@@ -106,104 +118,99 @@
|
||||
"BadassArabianMofo",
|
||||
"MiraiKuriyamaSy",
|
||||
"Pascal Dahle",
|
||||
"Greg",
|
||||
"Sangheili460",
|
||||
"MagnaInsomnia",
|
||||
"Akira HentAI",
|
||||
"Karl P.",
|
||||
"otaku fra",
|
||||
"lmsupporter",
|
||||
"andrew.tappan",
|
||||
"N/A",
|
||||
"The Spawn",
|
||||
"wackop",
|
||||
"Phil",
|
||||
"graysock",
|
||||
"Greenmoustache",
|
||||
"Carl G.",
|
||||
"wfpearl",
|
||||
"jeaness",
|
||||
"fancypants",
|
||||
"Dsperado",
|
||||
"Jack B Nimble",
|
||||
"bh",
|
||||
"JaxMax",
|
||||
"Jwk0205",
|
||||
"Starkselle",
|
||||
"carey6409",
|
||||
"Olive",
|
||||
"Aaron Bleuer",
|
||||
"LacesOut!",
|
||||
"greebles",
|
||||
"SarcasticHashtag",
|
||||
"Some Guy Named Barry",
|
||||
"M Postkasse",
|
||||
"Jacob Hoehler",
|
||||
"AELOX",
|
||||
"Nicfit23",
|
||||
"wamekukyouzin",
|
||||
"drum matthieu",
|
||||
"DogmaR34",
|
||||
"Matt Wenzel",
|
||||
"Weasyl",
|
||||
"Lex Song",
|
||||
"Cory Paza",
|
||||
"Christopher Michel",
|
||||
"Gonzalo Andre Allendes Lopez",
|
||||
"Serge Bekenkamp",
|
||||
"AIJimmy",
|
||||
"Philip Hempel",
|
||||
"LeoZero",
|
||||
"Dustin Chen",
|
||||
"dan",
|
||||
"aai",
|
||||
"Mouthlessman",
|
||||
"Ran C",
|
||||
"ViperC",
|
||||
"itismyelement",
|
||||
"Sangheili460",
|
||||
"MagnaInsomnia",
|
||||
"Karl P.",
|
||||
"LarsesFPC",
|
||||
"Weird_With_A_Beard",
|
||||
"N/A",
|
||||
"The Spawn",
|
||||
"graysock",
|
||||
"Pozadine1",
|
||||
"Qarob",
|
||||
"AIGooner",
|
||||
"Luc",
|
||||
"ProtonPrince",
|
||||
"DiffDuck",
|
||||
"fancypants",
|
||||
"elu3199",
|
||||
"Hasturkun",
|
||||
"Ubivis",
|
||||
"griffin+dahlberg",
|
||||
"John+Edwards",
|
||||
"Joboshy",
|
||||
"Digital",
|
||||
"JaxMax",
|
||||
"Bohemian Corporal",
|
||||
"Dan",
|
||||
"Bro Xie",
|
||||
"seed123_AIart",
|
||||
"batblue",
|
||||
"carey6409",
|
||||
"Error_Rule34_Not_found",
|
||||
"太郎 ゲーム",
|
||||
"Roslynd",
|
||||
"jinxedx",
|
||||
"AELOX",
|
||||
"Neco28",
|
||||
"David Ortega",
|
||||
"Dankin-Pics",
|
||||
"Nicfit23",
|
||||
"Cristian Vazquez",
|
||||
"wamekukyouzin",
|
||||
"drum matthieu",
|
||||
"DogmaR34",
|
||||
"Frank Nitty",
|
||||
"The Magic Noob",
|
||||
"Christopher Michel",
|
||||
"runte3221",
|
||||
"DougPeterson",
|
||||
"LeoZero",
|
||||
"dl0901dm",
|
||||
"Antonio Pontes",
|
||||
"Bruce",
|
||||
"kushiroK9",
|
||||
"Kevin John Duck",
|
||||
"Dustin Chen",
|
||||
"Kevin Christopher",
|
||||
"Blackfish95",
|
||||
"Tori",
|
||||
"Mouthlessman",
|
||||
"dd",
|
||||
"Paul Kroll",
|
||||
"Fraser Cross",
|
||||
"Bas Imagineer",
|
||||
"John Statham",
|
||||
"Dušan Ryban",
|
||||
"Adam Taylor",
|
||||
"AlexDuKaNa",
|
||||
"decoy",
|
||||
"elu3199",
|
||||
"Hasturkun",
|
||||
"Jon Sandman",
|
||||
"Ubivis",
|
||||
"zounic",
|
||||
"CloudValley",
|
||||
"thesoftwaredruid",
|
||||
@@ -215,37 +222,39 @@
|
||||
"Gus",
|
||||
"MJG",
|
||||
"linnfrey",
|
||||
"griffin+dahlberg",
|
||||
"ae",
|
||||
"Tr4shP4nda",
|
||||
"capn",
|
||||
"truethug",
|
||||
"yukina",
|
||||
"ElitaSSJ4",
|
||||
"Matt+J",
|
||||
"Brian M",
|
||||
"Josef Lanzl",
|
||||
"New folder (1)",
|
||||
"sanborondon",
|
||||
"Error_Rule34_Not_found",
|
||||
"Thought2Form",
|
||||
"jcay015",
|
||||
"Erik Lopez",
|
||||
"Mateo Curić",
|
||||
"Geolog",
|
||||
"Neco28",
|
||||
"Eris3D",
|
||||
"Resist's Creations - Spicy Edition 🔥",
|
||||
"David Ortega",
|
||||
"Wolffen",
|
||||
"m",
|
||||
"Pierce McBride",
|
||||
"Jamie Ogletree",
|
||||
"a _",
|
||||
"Jeff",
|
||||
"nwalker94",
|
||||
"James Coleman",
|
||||
"Kevin Christopher",
|
||||
"Ouro Boros",
|
||||
"Chad Idk",
|
||||
"dd",
|
||||
"Sam",
|
||||
"sjon kreutz",
|
||||
"Ace Ventura",
|
||||
"Metryman55",
|
||||
"AlexDuKaNa",
|
||||
"ae",
|
||||
"Tr4shP4nda",
|
||||
"Gamalonia",
|
||||
"capn",
|
||||
"Joseph",
|
||||
"Mirko Katzula",
|
||||
"dan",
|
||||
@@ -256,56 +265,57 @@
|
||||
"Kland",
|
||||
"Hailshem",
|
||||
"Naomi Hale Danchi",
|
||||
"ken",
|
||||
"epicgamer0020690",
|
||||
"Joshua Porrata",
|
||||
"Andrew",
|
||||
"Brian M",
|
||||
"Robert Wegemund",
|
||||
"Littlehuggy",
|
||||
"Brian Buie",
|
||||
"Thought2Form",
|
||||
"RAIDiation",
|
||||
"Sadlip",
|
||||
"Gooohokrbe",
|
||||
"m",
|
||||
"OldBones",
|
||||
"Pierce McBride",
|
||||
"Zach Gonser",
|
||||
"Mikko Hemilä",
|
||||
"Jacob McDaniel",
|
||||
"Jamie Ogletree",
|
||||
"Temikus",
|
||||
"Artokun",
|
||||
"Michael Taylor",
|
||||
"Martial",
|
||||
"Emil Andersson",
|
||||
"Ouro Boros",
|
||||
"Atilla Berke Pekduyar",
|
||||
"Decx _",
|
||||
"Yuji Kaneko",
|
||||
"Rops Alot",
|
||||
"Penfore",
|
||||
"Gordon Cole",
|
||||
"Ace Ventura",
|
||||
"AbstractAss",
|
||||
"David LaVallee",
|
||||
"ken",
|
||||
"Crocket",
|
||||
"keemun",
|
||||
"SuBu",
|
||||
"RedPIXel",
|
||||
"Wind",
|
||||
"Jackthemind",
|
||||
"Nexus",
|
||||
"Ramneek“Guy”Ashok",
|
||||
"squid_actually",
|
||||
"Nat_20",
|
||||
"Edward Weeks",
|
||||
"kyoumei",
|
||||
"RadStorm04",
|
||||
"JohnDoe42054",
|
||||
"BillyHill",
|
||||
"emyth",
|
||||
"gzmzmvp",
|
||||
"Andrew",
|
||||
"Robert Wegemund",
|
||||
"Littlehuggy",
|
||||
"Brian Buie",
|
||||
"RAIDiation",
|
||||
"Sadlip",
|
||||
"Eric Whitney",
|
||||
"Joey Callahan",
|
||||
"Ivan Tadic",
|
||||
"Mike Simone",
|
||||
"Gooohokrbe",
|
||||
"OldBones",
|
||||
"Morgandel",
|
||||
"Zach Gonser",
|
||||
"Mikko Hemilä",
|
||||
"Jacob McDaniel",
|
||||
"X",
|
||||
"SloanSteddyAI",
|
||||
"Temikus",
|
||||
"Artokun",
|
||||
"Michael Taylor",
|
||||
"Derek Baker",
|
||||
"Martial",
|
||||
"Emil Andersson",
|
||||
"Atilla Berke Pekduyar",
|
||||
"Decx _",
|
||||
"Rops Alot",
|
||||
"Penfore",
|
||||
"Gordon Cole",
|
||||
"AbstractAss",
|
||||
"David LaVallee",
|
||||
"Crocket",
|
||||
"Jackthemind",
|
||||
"Edward Weeks",
|
||||
"KitKatM",
|
||||
"socrasteeze",
|
||||
"MudkipMedkitz",
|
||||
@@ -316,26 +326,32 @@
|
||||
"InformedViewz",
|
||||
"Bubbafett",
|
||||
"leaf",
|
||||
"Skyfire83",
|
||||
"Adam Rinehart",
|
||||
"gzmzmvp",
|
||||
"Pitpe11",
|
||||
"TheD1rtyD03",
|
||||
"moonpetal",
|
||||
"g9p0o",
|
||||
"TheHolySheep",
|
||||
"Monte Won",
|
||||
"SpringBootisTrash",
|
||||
"carsten",
|
||||
"D",
|
||||
"takyamtom",
|
||||
"Aberr",
|
||||
"Gregory Kozhemiak",
|
||||
"elleshar666",
|
||||
"aezin",
|
||||
"Eric Whitney",
|
||||
"Joey Callahan",
|
||||
"Ivan Tadic",
|
||||
"Mike Simone",
|
||||
"ACTUALLY_the_Real_Willem_Dafoe",
|
||||
"FloPro4Sho",
|
||||
"John J Linehan",
|
||||
"Elliot E",
|
||||
"Morgandel",
|
||||
"Theerat Jiramate",
|
||||
"X",
|
||||
"SloanSteddyAI",
|
||||
"Vane Holzer",
|
||||
"Steven Owens",
|
||||
"hexxish",
|
||||
"Derek Baker",
|
||||
"Michael Anthony Scott",
|
||||
"notedfakes",
|
||||
"NICHOLAS BAXLEY",
|
||||
"Ed Wang",
|
||||
"Saya",
|
||||
@@ -347,18 +363,13 @@
|
||||
"chriphost",
|
||||
"ResidentDeviant",
|
||||
"Ginnie",
|
||||
"Skyfire83",
|
||||
"Pitpe11",
|
||||
"IamAyam",
|
||||
"TheD1rtyD03",
|
||||
"moonpetal",
|
||||
"g9p0o",
|
||||
"Pkrsky",
|
||||
"TheHolySheep",
|
||||
"Monte Won",
|
||||
"SpringBootisTrash",
|
||||
"carsten",
|
||||
"nanana",
|
||||
"ikok",
|
||||
"Doug+Rintoul",
|
||||
"Noor",
|
||||
"Yorunai",
|
||||
"quantenmecha",
|
||||
"Jason+Nash",
|
||||
"DarkRoast",
|
||||
@@ -368,36 +379,34 @@
|
||||
"Duk3+Rand0m",
|
||||
"Nathen+Choi",
|
||||
"T",
|
||||
"D",
|
||||
"David Schenck",
|
||||
"Wolfe7D1",
|
||||
"Andrew Marshall",
|
||||
"Taylor Funk",
|
||||
"elleshar666",
|
||||
"Gerald Welly",
|
||||
"Tee Gee",
|
||||
"ACTUALLY_the_Real_Willem_Dafoe",
|
||||
"Михал Михалыч",
|
||||
"Matt",
|
||||
"tarek helmi",
|
||||
"Kauffy",
|
||||
"Max Marklund",
|
||||
"SPJ",
|
||||
"Joshua Gray",
|
||||
"Edward Kennedy",
|
||||
"Nick Kage",
|
||||
"Vane Holzer",
|
||||
"psytrax",
|
||||
"Cyrus Fett",
|
||||
"lh qwe",
|
||||
"conner",
|
||||
"Xenon Xue",
|
||||
"Michael Anthony Scott",
|
||||
"notedfakes",
|
||||
"Edward Ten Eyck",
|
||||
"Princess Bright Eyes",
|
||||
"Michael Scott",
|
||||
"Solixer",
|
||||
"Jimmy Borup",
|
||||
"Wes Sims",
|
||||
"Donor4115",
|
||||
"Manu Thetug",
|
||||
"Filippo Ferrari",
|
||||
"Douglas Gaspar",
|
||||
"George",
|
||||
@@ -406,11 +415,19 @@
|
||||
"momokai",
|
||||
"몽타주",
|
||||
"kudari",
|
||||
"dc7431",
|
||||
"Inversity",
|
||||
"Whitepinetrader",
|
||||
"OrganicArtifact",
|
||||
"Raku",
|
||||
"CHKeeho80",
|
||||
"nanana",
|
||||
"Flob",
|
||||
"ShiroSenpai",
|
||||
"Gumbyte",
|
||||
"Tan+Huynh",
|
||||
"Bob+Barker",
|
||||
"Dark_Pest",
|
||||
"Eldithor",
|
||||
"Alex",
|
||||
"Karru",
|
||||
"ChaChanoKo",
|
||||
@@ -425,25 +442,28 @@
|
||||
"Alan+Cano",
|
||||
"FeralOpticsAI",
|
||||
"Pavlaki",
|
||||
"Doug+Rintoul",
|
||||
"Noor",
|
||||
"Yorunai",
|
||||
"Richard",
|
||||
"奚明 刘",
|
||||
"Kalli Core",
|
||||
"준희 김",
|
||||
"Ronan Delevacq",
|
||||
"りん あめ",
|
||||
"Matt",
|
||||
"Tomohiro Baba",
|
||||
"Dave Abraham",
|
||||
"Joaquin Hierrezuelo",
|
||||
"Noora",
|
||||
"Frogmilk",
|
||||
"SPJ",
|
||||
"StudOx Tech",
|
||||
"Jarrid Lee",
|
||||
"Kor",
|
||||
"John Rednoulf",
|
||||
"Bryan Rutkowski",
|
||||
"Boba Smith",
|
||||
"Noah",
|
||||
"Sauv",
|
||||
"TenaciousD",
|
||||
"Dmitry Ryzhov",
|
||||
"DarkSunset",
|
||||
"Edward Ten Eyck",
|
||||
"Steam Steam",
|
||||
"CryptoTraderJK",
|
||||
"Davaitamin",
|
||||
@@ -454,17 +474,23 @@
|
||||
"jinksta187",
|
||||
"Fotek Design",
|
||||
"Maxim",
|
||||
"Manu Thetug",
|
||||
"Lyavph",
|
||||
"Nihongasuki",
|
||||
"MadSpin",
|
||||
"inbijiburu",
|
||||
"Nick “Loadstone” D",
|
||||
"Marcus thronico",
|
||||
"地獄の禄",
|
||||
"starbugx",
|
||||
"dc7431",
|
||||
"Inversity",
|
||||
"Vir",
|
||||
"Kachac",
|
||||
"Alex+Zaw",
|
||||
"Rune+Osnes",
|
||||
"PoorStudent",
|
||||
"Supporter",
|
||||
"ExLightSaber",
|
||||
"vinter",
|
||||
"YaboiRay",
|
||||
"Sildoren",
|
||||
"Darv",
|
||||
"Seon+Song",
|
||||
@@ -480,58 +506,50 @@
|
||||
"YassineKhaled",
|
||||
"Y",
|
||||
"MatteKey",
|
||||
"Flob",
|
||||
"ShiroSenpai",
|
||||
"Inkognito",
|
||||
"Gumbyte",
|
||||
"Tan+Huynh",
|
||||
"Bob+Barker",
|
||||
"Dark_Pest",
|
||||
"Eldithor",
|
||||
"Ko-fi+Supporter",
|
||||
"lrdchs2",
|
||||
"Obsidian.Studios",
|
||||
"Tú Nguyễn Lý Hoàng",
|
||||
"shira1011",
|
||||
"Kalli Core",
|
||||
"Neko Desco",
|
||||
"Ben D",
|
||||
"Draven T",
|
||||
"marioandluigi",
|
||||
"G",
|
||||
"Ronan Delevacq",
|
||||
"Vinarus",
|
||||
"Leslie Andrew Ridings",
|
||||
"Aquatic Coffee",
|
||||
"Dave Abraham",
|
||||
"Joaquin Hierrezuelo",
|
||||
"Locrospiel",
|
||||
"StudOx Tech",
|
||||
"yves.poezevara",
|
||||
"Jarrid Lee",
|
||||
"Poophead27 Blyat",
|
||||
"Joseph Hanson",
|
||||
"John Rednoulf",
|
||||
"Focuschannel",
|
||||
"Boba Smith",
|
||||
"matt",
|
||||
"somethingtosay8",
|
||||
"Terminuz",
|
||||
"ivistorm",
|
||||
"Anthony Faxlandez",
|
||||
"Sauv",
|
||||
"Borte",
|
||||
"Ted Cart",
|
||||
"Sage Himeros",
|
||||
"Zeeble",
|
||||
"Pat Hen",
|
||||
"SkibidiRizzler",
|
||||
"Jack Lawfield",
|
||||
"Draconach",
|
||||
"Kalle Björk",
|
||||
"Tigon",
|
||||
"ItsGeneralButtNaked",
|
||||
"Jordan Shaw",
|
||||
"g unit",
|
||||
"Nacho Ferrando",
|
||||
"Dkom22",
|
||||
"Marcos Tortosa Carmona",
|
||||
"Distortik",
|
||||
"JC",
|
||||
"Prompt Pirate",
|
||||
"uwutismxd",
|
||||
"Marcus thronico",
|
||||
"zenobeus",
|
||||
"ryoma",
|
||||
"dg",
|
||||
@@ -540,6 +558,11 @@
|
||||
"Menard",
|
||||
"SomeDude",
|
||||
"raf8osz",
|
||||
"Jasper",
|
||||
"megameganck",
|
||||
"thomasand01",
|
||||
"Shiba+Sama",
|
||||
"Celestial+Kitten",
|
||||
"Gold_miner_ego",
|
||||
"bakeliteboy",
|
||||
"TequiTequi",
|
||||
@@ -556,32 +579,26 @@
|
||||
"imer",
|
||||
"Akkas+Haque",
|
||||
"AZ+Party+Oasis",
|
||||
"Alex+Zaw",
|
||||
"Kachac",
|
||||
"Kevin+Isom",
|
||||
"Rune+Osnes",
|
||||
"PoorStudent",
|
||||
"vinter",
|
||||
"Supporter",
|
||||
"Mobius2020",
|
||||
"ExLightSaber",
|
||||
"YaboiRay",
|
||||
"boston666",
|
||||
"Adam+Spreer",
|
||||
"cocona",
|
||||
"Obsidian.Studios",
|
||||
"Welkor",
|
||||
"Zomba Mann",
|
||||
"Aquaneo",
|
||||
"blikkies",
|
||||
"JBsuede",
|
||||
"Wolf and Fox Legends",
|
||||
"ゼクス、六",
|
||||
"Neko Desco",
|
||||
"Vinarus",
|
||||
"Josh Snyder",
|
||||
"Shock Shockor",
|
||||
"Goldwaters",
|
||||
"swra",
|
||||
"JollRodrigo",
|
||||
"Zude",
|
||||
"Room Light",
|
||||
"Patryk Serious",
|
||||
"Kyler",
|
||||
"Justin Blaylock",
|
||||
"aRtFuL_DodGeR",
|
||||
@@ -589,23 +606,23 @@
|
||||
"TheFusion",
|
||||
"MR.Bear",
|
||||
"3zS4QNQ4",
|
||||
"Terminuz",
|
||||
"Matt M.",
|
||||
"Ivan Imes",
|
||||
"J M",
|
||||
"Slacks",
|
||||
"Steven",
|
||||
"Borte",
|
||||
"Khánh Đặng",
|
||||
"Homero Banda",
|
||||
"yyuvuvu",
|
||||
"Billy Gladky",
|
||||
"Nomki",
|
||||
"Probis",
|
||||
"Jack Lawfield",
|
||||
"SkibidiRizzler",
|
||||
"Never_M",
|
||||
"Maxon - Plans",
|
||||
"Kalle Björk",
|
||||
"Rudeff VonRod",
|
||||
"Karlanx",
|
||||
"operationancut",
|
||||
"Nacho Ferrando",
|
||||
"deadwishd",
|
||||
"Youguang",
|
||||
"andrewzpong",
|
||||
"BossGame",
|
||||
@@ -616,6 +633,12 @@
|
||||
"Kevinj",
|
||||
"Mitchell Robson",
|
||||
"POPPIN",
|
||||
"Lorabitch",
|
||||
"21omen",
|
||||
"NopeNahGoodTy",
|
||||
"BG",
|
||||
"plonk",
|
||||
"Kotetsu",
|
||||
"meatyalien",
|
||||
"Tony+V",
|
||||
"draganjankovic1975dj528",
|
||||
@@ -627,59 +650,50 @@
|
||||
"JACKY",
|
||||
"Otokomyouri+",
|
||||
"d",
|
||||
"Jasper",
|
||||
"megameganck",
|
||||
"thomasand01",
|
||||
"Shiba+Sama",
|
||||
"Celestial+Kitten",
|
||||
"IshouI;_;",
|
||||
"SAVEagleBasement",
|
||||
"Adam+Spreer",
|
||||
"BillyBoy84",
|
||||
"Buecyb99",
|
||||
"Welkor",
|
||||
"dubious1one",
|
||||
"Brandon Thomas",
|
||||
"BakunyuuWaifu",
|
||||
"Dustin Hendel",
|
||||
"moranqianlong",
|
||||
"Liberation",
|
||||
"Ninja Tom",
|
||||
"75marc",
|
||||
"Elemnt",
|
||||
"tafapayo",
|
||||
"Bradley Turner",
|
||||
"swra",
|
||||
"JollRodrigo",
|
||||
"Oliverfish",
|
||||
"uruksayshi",
|
||||
"Patryk Serious",
|
||||
"nk8",
|
||||
"Kyron Mahan",
|
||||
"Mythspire",
|
||||
"Nimhloth",
|
||||
"Justin Defer",
|
||||
"TBitz33",
|
||||
"Anonym dkjglfleeoeldldldlkf",
|
||||
"Tsani Prodanov",
|
||||
"V Bj",
|
||||
"Ezokewn",
|
||||
"Rj Joplin",
|
||||
"SendingRavens",
|
||||
"Slacks",
|
||||
"Myrthrac",
|
||||
"Taylor Dominy",
|
||||
"Glenn Hoetker",
|
||||
"JackJohnnyJim",
|
||||
"Khánh Đặng",
|
||||
"Michael Hicks",
|
||||
"Homero Banda",
|
||||
"Michael Docherty",
|
||||
"MadGod",
|
||||
"GhostyGhost",
|
||||
"Paul Hartsuyker",
|
||||
"elitassj",
|
||||
"Never_M",
|
||||
"Jacob Winter",
|
||||
"Rudeff VonRod",
|
||||
"Andrew Wilkinson",
|
||||
"David",
|
||||
"floeki75pad",
|
||||
"TheJohnes",
|
||||
"deadwishd",
|
||||
"shinonomeiro",
|
||||
"Snille",
|
||||
"MaartenAlbers",
|
||||
@@ -696,6 +710,7 @@
|
||||
"Scott",
|
||||
"Muratoraccio",
|
||||
"D",
|
||||
"yukina",
|
||||
"Daevalus",
|
||||
"Milky+Mai",
|
||||
"Krash",
|
||||
@@ -719,13 +734,7 @@
|
||||
"MackeMan",
|
||||
"conkisdonkis",
|
||||
"badnews",
|
||||
"Lorabitch",
|
||||
"21omen",
|
||||
"NopeNahGoodTy",
|
||||
"Brandon+G",
|
||||
"plonk",
|
||||
"Anvil+Girl",
|
||||
"Kotetsu",
|
||||
"miduzza",
|
||||
"Somebody",
|
||||
"てぃんてぃんひーろー",
|
||||
@@ -744,11 +753,10 @@
|
||||
"hayden",
|
||||
"ahoystan",
|
||||
"Civitaier",
|
||||
"BakunyuuWaifu",
|
||||
"edk",
|
||||
"Super Sigma Reborne",
|
||||
"Joey Leto",
|
||||
"Anagra Nouma",
|
||||
"tafapayo",
|
||||
"ja s",
|
||||
"Doug Mason",
|
||||
"scoreswazey",
|
||||
@@ -756,24 +764,21 @@
|
||||
"Owen Gwosdz",
|
||||
"GJT",
|
||||
"Manuel Reyes",
|
||||
"Xae Phiel",
|
||||
"FinoRulez",
|
||||
"CHEL_C",
|
||||
"Gentle Sartori",
|
||||
"Caleb Larson",
|
||||
"David Murcko",
|
||||
"Justin Defer",
|
||||
"Ben Brogger",
|
||||
"Jack Dole",
|
||||
"dsffsdfsdfsdfsdfsdf",
|
||||
"V Bj",
|
||||
"Rj Joplin",
|
||||
"Kurt",
|
||||
"max blo",
|
||||
"Myrthrac",
|
||||
"Taylor Dominy",
|
||||
"Faith",
|
||||
"Bouya shaka",
|
||||
"Maso",
|
||||
"BigBoss",
|
||||
"Kevin Wallace",
|
||||
"ChicRic",
|
||||
"Bastard-Sama",
|
||||
@@ -832,6 +837,14 @@
|
||||
"SelfishMedic",
|
||||
"adderleighn",
|
||||
"EnragedAntelope",
|
||||
"D3aty",
|
||||
"Somebody",
|
||||
"(ᵕ+˶•́﹏•̀˶+)",
|
||||
"Brian+Harvey",
|
||||
"JustDrewIt",
|
||||
"Sumoninja",
|
||||
"FrostByte404",
|
||||
"Eita",
|
||||
"mcmalt",
|
||||
"cesasol",
|
||||
"Null",
|
||||
@@ -892,9 +905,9 @@
|
||||
"Hans Meier",
|
||||
"jboul",
|
||||
"Michael Eid",
|
||||
"Super Sigma Reborne",
|
||||
"Veloce",
|
||||
"Bob barker",
|
||||
"Even",
|
||||
"Michael Rivera",
|
||||
"karim ben brik",
|
||||
"Vincent",
|
||||
@@ -907,13 +920,12 @@
|
||||
"John C",
|
||||
"beltaloth",
|
||||
"Rim",
|
||||
"Daniel Bennett",
|
||||
"yfx507",
|
||||
"Jairus Knudsen",
|
||||
"Xan Dionysus",
|
||||
"Mario Cano",
|
||||
"Nathan lee",
|
||||
"lylepaul",
|
||||
"Xae Phiel",
|
||||
"DafmanD2",
|
||||
"Middo",
|
||||
"Smokey Jesus",
|
||||
@@ -921,7 +933,6 @@
|
||||
"forbiddenatelierofficial",
|
||||
"Thomas Sankowski",
|
||||
"ThreadingReality",
|
||||
"DrB",
|
||||
"wknight",
|
||||
"Moneymaker412K",
|
||||
"Jacid",
|
||||
@@ -933,10 +944,10 @@
|
||||
"fal",
|
||||
"Andrew Ly",
|
||||
"john Greene",
|
||||
"Knives909",
|
||||
"jimyjomson",
|
||||
"JaeHyun Jang",
|
||||
"sbone",
|
||||
"BigBoss",
|
||||
"Chase Kwon",
|
||||
"Bob Ling",
|
||||
"Inyoshu",
|
||||
@@ -968,5 +979,5 @@
|
||||
"Somebody",
|
||||
"CK"
|
||||
],
|
||||
"totalCount": 965
|
||||
"totalCount": 976
|
||||
}
|
||||
@@ -57,6 +57,27 @@ Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
|
||||
> prototyped and removed because it collided with the browser's Alt + Arrow handling and the
|
||||
> modal's arrow-key navigation.
|
||||
|
||||
> **Status (2026-09, standalone no-paths guidance):** the standalone branch of the
|
||||
> `other.noPaths` empty state now shows the real `settings.json` path plus an
|
||||
> `other.noPaths.openSettingsFolder` button (each locale reuses its
|
||||
> `settings.openSettingsFileLocation.label` rendering), and `descriptionStandalone` was
|
||||
> reworded in `en.json` — from "none of the configured folders exist on disk" to "no
|
||||
> other-model folders were found; add the folder keys you need to the `folder_paths`
|
||||
> section" — and re-translated in all 9 locales. The `on disk` phrase now survives only in
|
||||
> the ComfyUI variant (`descriptionComfyUI`).
|
||||
|
||||
> **Status (2026-09, settings Organization tab):** the settings modal split its overloaded
|
||||
> Library tab, adding the single `settings.nav.organization` key (renderings in §2,
|
||||
> "Settings Organization tab"). All 9 locales are translated, so the "no remaining
|
||||
> placeholders" claim holds again.
|
||||
|
||||
> **Status (2026-09, filename templates):** the Filename Templates feature (per-model-type
|
||||
> download filename templates + bulk "Apply to Library Now" rename, with an empty template
|
||||
> restoring recorded original filenames) added 26 keys across `settings.filenameTemplates.*`,
|
||||
> `loras.bulkOperations.filenameTemplateProgress.*`, `modals.filenameTemplateConfirm.*` and
|
||||
> the `toast.loras.filenameTemplate*` / `toast.settings.filenameTemplates*` toasts. All 9
|
||||
> locales are translated (terminology in §2, "Filename Templates feature").
|
||||
|
||||
---
|
||||
|
||||
## 1. Hard rules (do not violate)
|
||||
@@ -344,6 +365,20 @@ in `en`, not "Enrich HF Metadata": they cover ModelScope as well, so no locale m
|
||||
an `HF` qualifier in `loras.contextMenu.enrichHfAgent` / `loras.bulkOperations.enrichHfAgent`
|
||||
(the key names keep the historical `Hf`; only the values changed).
|
||||
|
||||
The gated/private-repository download support added `settings.huggingfaceApiKey*` (label,
|
||||
placeholder, help, and the three status strings). "Access token" renderings, and the status
|
||||
strings reuse each locale's existing `civitaiApiKey*` forms ("Configured" / "Not configured" /
|
||||
"Set up") verbatim:
|
||||
|
||||
| Term | Rendering |
|
||||
|---|---|
|
||||
| access token | zh-CN 访问令牌 · zh-TW 存取權杖 · ja アクセストークン · ko 액세스 토큰 · fr jeton d'accès · de Access Token (Latin, like `CivitAI API Key`) · es token de acceso · ru токен доступа · he אסימון גישה |
|
||||
| gated repository | zh-CN 受限(gated)仓库 · zh-TW 受限(gated)倉庫 · ja ゲート付きリポジトリ · ko 게이트가 설정된 저장소 · fr dépôt restreint (gated) · de gated Repository (loanword) · es repositorio restringido (gated) · ru закрытый (gated) репозиторий · he מאגר מוגבל (gated) |
|
||||
|
||||
The help text tells the user to create a **read-only** token at
|
||||
`huggingface.co/settings/tokens` and to accept the repository's terms on its page first —
|
||||
keep both clauses: a token alone does not unlock a gated repository.
|
||||
|
||||
### Folder sidebar feature (create / rename / delete folders, empty folders, view options)
|
||||
|
||||
The model-root sidebar manages on-disk folders. "Folder" reuses the noun already fixed in §2
|
||||
@@ -362,6 +397,44 @@ clause (and its `—`) when the copy is edited. The `{name}` / `{count}` / `{mes
|
||||
`sidebar.createFolderResult.*`, `sidebar.deleteFolderResult.*` and `sidebar.renameFolderResult.*`
|
||||
are verbatim §1-R2 placeholders; `successWithFiles` is the only key carrying `{count}`.
|
||||
|
||||
### Settings Organization tab
|
||||
|
||||
The settings modal's fourth nav tab groups everything about how files are arranged on
|
||||
disk: download path templates, priority tags, and auto-organize exclusions. The label is
|
||||
the **noun for arranging files**, matching each locale's existing
|
||||
`settings.sections.autoOrganize` rendering minus the "auto":
|
||||
|
||||
| Locale | `settings.nav.organization` |
|
||||
|---|---|
|
||||
| fr | Organisation |
|
||||
| zh-CN | 整理 |
|
||||
| zh-TW | 整理 |
|
||||
| ja | 整理 |
|
||||
| ko | 정리 |
|
||||
| de | Organisation |
|
||||
| es | Organización |
|
||||
| ru | Организация |
|
||||
| he | ארגון |
|
||||
|
||||
zh-CN/zh-TW use 整理 ("tidying/arranging"), not 组织/組織 (an organization as a group).
|
||||
|
||||
### Filename Templates feature
|
||||
|
||||
Per-model-type templates that name downloaded model files; "Apply to Library Now"
|
||||
bulk-renames existing files, and an **empty template restores the recorded original
|
||||
filenames** (recorded in each model's metadata at its first rename). "Template" follows
|
||||
each locale's existing download-path-template noun (zh-CN 模板 vs zh-TW 範本 — note the
|
||||
split); progress strings mirror `loras.bulkOperations.autoOrganizeProgress` verbatim with
|
||||
the locale's "moved" verb swapped for its "renamed" verb, and the toasts mirror the
|
||||
`autoOrganize*` / `downloadTemplates*` toast shapes.
|
||||
|
||||
| Term | Rendering |
|
||||
|---|---|
|
||||
| filename template(s) | zh-CN 文件名模板 · zh-TW 檔案名稱範本 · ja ファイル名テンプレート · ko 파일명 템플릿 · fr modèle(s) de nom de fichier · de Dateinamen-Vorlage(n) · es plantilla(s) de nombres de archivo · ru шаблон(ы) имён файлов · he תבנית שם קובץ / תבניות שמות קבצים |
|
||||
| Apply to Library Now (button) | zh-CN 立即应用到库 · zh-TW 立即套用至模型庫 · ja ライブラリに今すぐ適用 · ko 지금 라이브러리에 적용 · fr Appliquer à la bibliothèque maintenant · de Jetzt auf Bibliothek anwenden · es Aplicar a la biblioteca ahora · ru Применить к библиотеке сейчас · he החל על הספרייה כעת |
|
||||
| Restore original filenames (modal title / button) | zh-CN 恢复原始文件名?/ 恢复原始文件名 · zh-TW 要還原原始檔案名稱嗎?/ 還原原始檔案名稱 · ja 元のファイル名を復元しますか?/ 元のファイル名を復元 · ko 원본 파일명을 복원하시겠습니까? / 원본 파일명 복원 · fr Restaurer les noms de fichier d'origine ? / Restaurer les noms de fichier d'origine · de Ursprüngliche Dateinamen wiederherstellen? / Ursprüngliche Dateinamen wiederherstellen · es ¿Restaurar los nombres de archivo originales? / Restaurar nombres de archivo originales · ru Восстановить исходные имена файлов? / Восстановить исходные имена файлов · he לשחזר שמות קבצים מקוריים? / שחזר שמות קבצים מקוריים |
|
||||
| "renamed" (progress/toast counter) | zh-CN 已重命名 · zh-TW 已重新命名 · ja リネーム · ko 이름 변경 · fr renommés · de umbenannt · es renombrados · ru переименовано · he שונו שמותם |
|
||||
|
||||
### Chip reordering (model tags / trigger words)
|
||||
|
||||
Model tags and trigger-word chips share a single reorder affordance (drag the chip, or its
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
# Load Image Metadata (LoraManager)
|
||||
|
||||
Load a source image and reuse its prompts, local models, LoRAs, and sampling settings.
|
||||
The node lives under **Lora Manager → loaders**. Restart ComfyUI after installing
|
||||
this change and refresh the page. This Python node needs no Vue widget build.
|
||||
|
||||
## Wiring a checkpoint workflow
|
||||
|
||||
1. Upload/select an image in **Load Image Metadata (LoraManager)**.
|
||||
2. Convert `ckpt_name` on **Checkpoint Loader (LoraManager)** to an input and
|
||||
connect `model_name`. Leave its randomization control fixed.
|
||||
3. Connect the checkpoint's MODEL and CLIP to **Lora Loader (LoraManager)**.
|
||||
Connect the metadata node's `lora_stack` to that loader. Leave its LoRA widget
|
||||
empty unless you intentionally want additional LoRAs.
|
||||
4. Connect the LoRA loader's CLIP to two CLIP Text Encode nodes. Connect metadata
|
||||
`positive` and `negative` to their text inputs, and their conditioning outputs
|
||||
to KSampler. Connect the LoRA loader's MODEL to KSampler.
|
||||
5. Convert KSampler's seed, steps, cfg, sampler_name, scheduler, and denoise
|
||||
widgets to inputs and connect the corresponding metadata outputs.
|
||||
6. For text-to-image, connect width/height to an appropriate Empty Latent node.
|
||||
For img2img, encode the `image` output with the appropriate VAE instead.
|
||||
7. Connect KSampler's samples and the checkpoint's VAE to VAE Decode, then Save Image.
|
||||
8. Connect `readable_report` to a text display node for prompts, sampling settings,
|
||||
model/LoRA names, local resolution status and warnings. The original `report`
|
||||
output remains notes followed by formatted JSON; it is not a pure JSON string.
|
||||
|
||||
`model_name`, `sampler_name`, and `scheduler` use COMBO outputs for converted
|
||||
dropdown inputs in current ComfyUI. `model_name` contains the matching local
|
||||
checkpoint or diffusion-model filename. The report identifies the resolved type;
|
||||
connect it to the appropriate loader. Lookup searches both categories regardless
|
||||
of how the original metadata labels the model.
|
||||
|
||||
For a diffusion-model workflow, connect `model_name` to **Unet Loader
|
||||
(LoraManager)** and select the correct text encoder(s), VAE, latent node and
|
||||
architecture-specific conditioning separately. These settings do not reconstruct
|
||||
an entire workflow or guarantee pixel-identical reproduction.
|
||||
|
||||
## Selection and overrides
|
||||
|
||||
`prefer_saved_image_metadata` is enabled by default. It prefers the saved
|
||||
A1111-style generation parameters (including ComfyUI exports in that format)
|
||||
over the workflow. The report identifies this source; `sampler_node_id` is
|
||||
ignored in this mode when valid saved parameters are available. If saved
|
||||
parameters are absent or malformed, the node tries workflow metadata and
|
||||
reports any parsing failure.
|
||||
|
||||
Disable the flag to prefer workflow extraction. Only active samplers are
|
||||
eligible: muted/bypassed sampler nodes and samplers inside muted/bypassed
|
||||
subgraph instances are excluded. This uses the saved UI workflow's mode flags
|
||||
when available, including nested subgraphs, and any modes in the API graph.
|
||||
Explicitly selecting an inactive sampler produces an error report and the
|
||||
usual saved-parameter/default recovery; it never extracts that inactive stage.
|
||||
|
||||
With one supported active sampler, leave `sampler_node_id` blank. With several, enter
|
||||
its original node ID. Reports list candidate IDs when selection is ambiguous.
|
||||
Native subgraphs in API prompt metadata use colon-qualified paths: `1481:1783`
|
||||
means node 1783 inside subgraph instance 1481. Nested paths such as `10:20:30`
|
||||
are supported; slash notation (`1481/1783`) is also accepted. A container ID
|
||||
(`1481`) or leaf ID (`1783`) is accepted only if it identifies one sampler.
|
||||
An exact sampler ID takes precedence over abbreviated matching.
|
||||
|
||||
Selection follows that sampler's graph, rather than mixing branches. Supported
|
||||
sampling nodes include KSampler, KSamplerAdvanced and SamplerCustomAdvanced with
|
||||
standard RandomNoise, CFGGuider/BasicGuider, BasicScheduler and KSamplerSelect
|
||||
components. BasicGuider's CFG is 1; its architecture-specific lack of negative
|
||||
conditioning is reported. Known Image Saver parameter/selector outputs and
|
||||
rgthree seed values can be read without executing those nodes.
|
||||
|
||||
Detail Daemon's underlying sampler name is recovered, but its sampling effects
|
||||
are explicitly unsupported. Other custom model/conditioning nodes can still
|
||||
require defaults or overrides. If a requested stage cannot be read and global
|
||||
image parameters are used instead, the report explicitly says those parameters
|
||||
cannot verify the selected stage. Subgraph traversal requires the expanded API
|
||||
prompt; UI-workflow-only subgraph definitions are not expanded or executed.
|
||||
|
||||
Extraction errors do not stop this node. If an API prompt uses unsupported
|
||||
samplers, the node first tries the image's saved generation parameters. Any
|
||||
remaining unavailable or invalid extracted fields use the SDXL starter defaults
|
||||
(width/height fall back to the source image dimensions instead);
|
||||
valid extracted fields are preserved. `readable_report` starts with **❌ ERROR**
|
||||
and explains each recovery or substitution. This also applies to existing nodes
|
||||
saved with `missing_settings=strict`; that legacy option no longer blocks
|
||||
extraction recovery. New nodes default to `use_defaults`.
|
||||
|
||||
The report uses emoji section markers (🖼️ image, 📦 model, ⚙️ sampling, 🧩 LoRAs,
|
||||
➕/➖ prompts) and ❌/⚠️/ℹ️ status markers. It is plain text, so colors depend on the
|
||||
connected display node. Missing/ambiguous local files still appear in
|
||||
`missing_files`. An empty model output requires selecting a local model manually.
|
||||
Invalid explicit overrides and unreadable image files remain execution errors.
|
||||
|
||||
`overrides_json` replaces extracted values, for example:
|
||||
|
||||
```json
|
||||
{
|
||||
"scheduler": "normal",
|
||||
"model_name": "portraits/model.safetensors",
|
||||
"seed": 12345,
|
||||
"loras": [["styles/ink.safetensors", 0.7, 0.3]]
|
||||
}
|
||||
```
|
||||
|
||||
Supported keys: `positive`, `negative`, `model_name`, `seed`,
|
||||
`steps`, `cfg`, `sampler_name`, `scheduler`, `width`, `height`, `denoise`, `loras`.
|
||||
LoRA entries are `[name, model_strength, clip_strength]`; `"loras": []` explicitly
|
||||
clears the extracted stack. Legacy `checkpoint_name` and `unet_name` override
|
||||
keys remain accepted as aliases for `model_name`; supply only one model key.
|
||||
Exact relative or absolute local
|
||||
business paths disambiguate duplicate basenames. Matching falls back to a unique
|
||||
filename or extensionless filename, then an exact unique catalog `file_name` or
|
||||
`model_name` alias. Version dots are preserved when stripping known file
|
||||
extensions. It never downloads or fuzzy-matches models, and stale entries whose
|
||||
files no longer exist are excluded.
|
||||
|
||||
Images with no metadata automatically use a bottle-inspired SDXL starter preset,
|
||||
even with an existing saved `strict` setting: a glass-bottle/galaxy landscape
|
||||
prompt, negative `text, watermark`, seed 0, 20 steps, CFG 7, Euler/normal,
|
||||
1024×1024 and denoise 1, with no LoRAs. These settings are clearly identified as
|
||||
synthetic defaults in both reports. Source image pixels and mask are unchanged.
|
||||
Overrides take precedence. The node selects `sd_xl_base_1.0.safetensors` only
|
||||
when uniquely indexed; otherwise choose an SDXL checkpoint manually or supply
|
||||
`model_name`. Malformed or unsupported metadata also recovers with an explicit ERROR report.
|
||||
|
||||
## Supported metadata and limits
|
||||
|
||||
- PNG API prompt metadata; JPEG/WebP EXIF parameter comments; ComfyUI WebP
|
||||
`prompt:`/`workflow:` EXIF fields.
|
||||
- Standard KSampler, core checkpoint/UNet/LoRA loaders, LoRA Manager checkpoint,
|
||||
UNet, LoRA/text loaders and LoRA stacks. LoRA application order and separate
|
||||
model/CLIP strengths are preserved, including intentional repeated entries.
|
||||
Different LoRA chains on model and prompt CLIP branches require an explicit
|
||||
stack override rather than being silently merged.
|
||||
- Literal CLIPTextEncode text and supported primitive value connections. Prompt
|
||||
polarity comes from sampler wiring, never from words such as “ugly”.
|
||||
- A1111/Forge generation text with explicit sampler alias mappings. Recognized
|
||||
LoRA directives become stack entries and are removed from prompt text. Literal
|
||||
tags in ComfyUI encoder text remain literal; graph loaders determine its stack.
|
||||
- A1111 `Automatic`/absent schedules do not reliably identify a ComfyUI schedule.
|
||||
The node substitutes `normal` and reports the missing information as an ERROR;
|
||||
an explicit override can select a different schedule.
|
||||
- UI-workflow-only fallback supports known core widget layouts, with a report
|
||||
warning. Saved widgets can differ from executed values (for example a seed
|
||||
randomized after generation). Custom widget layouts are not guessed.
|
||||
- KSamplerAdvanced partial/noise settings require an explicit denoise override;
|
||||
this is an intentional approximation, not a reconstruction of those controls.
|
||||
- Distinct SDXL/Flux encoder prompts, combined/regional/zeroed conditioning,
|
||||
arbitrary custom nodes, dynamic wildcards and unsupported custom sampling components are
|
||||
not automatically reconstructed. Supply explicit overrides or retain the
|
||||
original workflow for those cases.
|
||||
- Width/height come from a recognized latent source or fall back to source-image
|
||||
dimensions; resized/upscaled images can therefore need dimension overrides.
|
||||
Only the synthetic starter preset for metadata-free images uses a fixed
|
||||
1024×1024 regardless of the source image size.
|
||||
- VAE, text encoder choice, CLIP skip, ControlNet and architecture-specific
|
||||
conditioning still need the appropriate nodes. No embedded code is executed
|
||||
and no external metadata service is contacted.
|
||||
|
||||
LoRA Manager must have indexed the required models. Library resolution includes
|
||||
its configured extra folders and preserves business paths through symlinks.
|
||||
|
||||
## Extraction without a local catalog
|
||||
|
||||
The parser extracts names before attempting local resolution. In recovery mode,
|
||||
`report` includes `source_resources` with original model names, LoRA names and
|
||||
strengths, and embedded resource hashes even when none are installed. The model
|
||||
output sockets remain empty and the resolved stack excludes missing files.
|
||||
|
||||
Combined sampler labels such as `Euler a SGM Uniform`, `Euler Normal` and
|
||||
`er_sde simple` are split into sampler and scheduler. Multiline parameter blocks
|
||||
and their nested JSON resource lists are supported. If prompt LoRA tags are
|
||||
absent, one hash-name entry and one weighted resource can be matched offline;
|
||||
multiple entries require an explicit mapping rather than guessing from order.
|
||||
A single resource also disambiguates duplicated identical prompt tags.
|
||||
|
||||
The `Model` field in A1111-style metadata does not distinguish checkpoints from
|
||||
standalone diffusion models. The node searches both indexed categories by name,
|
||||
then reports the matched type. Local model type and filename cannot be verified
|
||||
without an indexed library. Multiple equally good matches are reported as
|
||||
ambiguous; specify a relative path through `model_name` to disambiguate.
|
||||
|
||||
## “Image contains no supported generation metadata”
|
||||
|
||||
For older versions, this means extraction failed before any library lookup.
|
||||
The current node uses the starter preset when metadata is entirely absent. The error identifies the
|
||||
actual server file, its format, byte size and metadata keys. PNG text chunks are
|
||||
read both before and after pixel data. If no generation metadata remains, upload
|
||||
the original saved file: clipboard copies and re-encoded/exported images may
|
||||
lose it. `use_defaults` supplies replacement settings; it does not recover the
|
||||
original prompts or seed.
|
||||
|
||||
## Missing local resources
|
||||
|
||||
`missing_files` is a text output listing unresolved checkpoints/UNets and LoRAs.
|
||||
LoRA entries include both model and CLIP weights and the resolution failure.
|
||||
It is empty when all requested resources resolve. Missing and ambiguous LoRAs
|
||||
are excluded from `lora_stack`, including in strict mode, so downstream loaders
|
||||
receive only resolved files. Valid entries keep their original order and weights.
|
||||
Unresolved model-name sockets are empty: select a model manually or override its
|
||||
name before connecting that socket to a loader.
|
||||
|
||||
## Output layout and upgrade
|
||||
|
||||
The outputs start with `image`, `mask`, `positive`, `negative`, **`model_name`**,
|
||||
**`lora_stack`**, **`lora_stack_text`**, followed by the sampling settings and reports.
|
||||
`lora_stack_text` lists each resolved stack path with model and CLIP weights in
|
||||
application order. It is empty for an empty stack; unresolved files appear only
|
||||
in `missing_files`, with their requested weights.
|
||||
|
||||
This replaces the former separate checkpoint/UNet sockets and renames `lost_list`
|
||||
to `missing_files`. Restart ComfyUI, refresh, and recreate existing instances of
|
||||
this node; reconnect the model and stack outputs to avoid stale saved slot indices.
|
||||
Sampling and report output indices remain unchanged. No Vue build is required.
|
||||
@@ -0,0 +1,107 @@
|
||||
# Plan: Filename Template Follow-ups
|
||||
|
||||
**Issue:** [#1071 — Lora Renaming](https://github.com/willmiao/ComfyUI-Lora-Manager/issues/1071)
|
||||
**Status:** Core feature **implemented** (2026-09-19, commit `2bc9860b`,
|
||||
preceded by the settings-tab split in `327da046`). Follow-ups 1 and 2 were
|
||||
resolved together on 2026-09-19 by redefining the empty template as
|
||||
"revert to recorded original filename" (see below). Follow-up 3 remains open.
|
||||
|
||||
## What shipped in `2bc9860b`
|
||||
|
||||
- Per-model-type `download_filename_templates` setting (empty = keep current
|
||||
filename; opt-in). Placeholders: `{model_name}`, `{version_name}`,
|
||||
`{base_model}`, `{author}`, `{first_tag}`, `{hash_short}`,
|
||||
`{original_name}`.
|
||||
- `calculate_filename_for_model()` in `py/utils/utils.py` renders the
|
||||
template; templates containing path separators are rejected.
|
||||
- Downloads apply the template post-download
|
||||
(`DownloadManager._apply_download_filename_template`); rename conflicts
|
||||
keep the original name and never fail the download.
|
||||
- `ModelLifecycleService.rename_model` records `original_file_name` in the
|
||||
`.metadata.json` sidecar (first rename wins via `setdefault`).
|
||||
- Bulk apply: `GET|POST /api/lm/{prefix}/apply-filename-template`
|
||||
(`FilenameTemplateUseCase`, shares the auto-organize lock, WS progress type
|
||||
`filename_template_progress`).
|
||||
- Settings UI: "Filename Templates" subsection in the new **Organization**
|
||||
settings tab (`templates/components/modals/settings/organization.html`),
|
||||
with validation, live preview, and per-type "Apply to Library Now".
|
||||
|
||||
Sandbox E2E verified: rename incl. companion files (previews, sidecars),
|
||||
metadata pointer updates, `original_file_name` recording, idempotency,
|
||||
conflict handling (failure counted, batch continues), empty-template no-op,
|
||||
GET variant.
|
||||
|
||||
## Follow-ups 1 & 2 — RESOLVED: empty template = revert to recorded original
|
||||
|
||||
Follow-up 1 asked to reword the ambiguous "Valid (keep original filename)"
|
||||
empty-template message; Follow-up 2 asked for a bulk revert to the recorded
|
||||
`original_file_name`. Both were resolved by a single semantic change: **an
|
||||
empty template now means "restore the recorded original filename"** instead of
|
||||
"leave the current filename untouched".
|
||||
|
||||
Rationale: for never-renamed models a revert is a no-op (no recorded
|
||||
original), for renamed models it restores the pre-rename name, and new
|
||||
downloads with an empty template keep the download name as before — so the
|
||||
two contexts (download path and bulk apply) share one coherent meaning, and
|
||||
no separate revert feature or `{recorded_original}` placeholder is needed.
|
||||
|
||||
Implemented changes:
|
||||
|
||||
- `FilenameTemplateUseCase._process_model`: an empty template now resolves
|
||||
the target name from the sidecar's `original_file_name` via the injected
|
||||
`metadata_loader` (default `load_local_metadata`); models without a
|
||||
recorded original or whose original matches the current name are skipped.
|
||||
Cache entries do not project `original_file_name`, so the sidecar is read
|
||||
per model.
|
||||
- `SettingsManager.js`: removed the empty-template early return and the
|
||||
apply-button disable (`updateFilenameTemplateApplyButton` deleted — the
|
||||
button is now always enabled). The browser-native `confirm()` was replaced
|
||||
with `filenameTemplateConfirmModal`
|
||||
(`templates/components/modals/confirm_modals.html`), a **self-managed**
|
||||
modal (like `DirectoryPickerModal`, NOT registered with ModalManager):
|
||||
ModalManager's "close current modal on open" behavior would kill the
|
||||
settings modal underneath. It stacks via `z-index: 10010`
|
||||
(`delete-modal.css`), handles ESC in capture phase with
|
||||
`stopPropagation`, and shows apply vs revert wording
|
||||
(`modals.filenameTemplateConfirm.titleApply` / `titleRevert` /
|
||||
`revertButton`; messages reuse `settings.filenameTemplates.confirmApply` /
|
||||
`confirmRevert`).
|
||||
- `locales/en.json`: reworded `help` / `applyHelp`, replaced
|
||||
`validation.keepOriginal` with `validation.restoreOriginal`
|
||||
("Valid (empty template restores original filenames)"), added
|
||||
`confirmRevert`, removed the now-unused `emptyTemplateInfo`. Other locales
|
||||
re-synced with `[TODO: Translate]` placeholders — retranslation waits for
|
||||
the feature owner's request per `docs/i18n-translation-guidelines.md` §7.
|
||||
- Tests: revert / no-record-skip / same-name-skip cases in
|
||||
`tests/services/test_use_cases.py`; modal confirm-and-revert and
|
||||
cancel paths in
|
||||
`tests/frontend/managers/settingsManager.filenameTemplates.test.js`.
|
||||
|
||||
Sandbox E2E verified (standalone server, sandboxed settings + library under
|
||||
`/tmp`, 2026-09-19): template apply renames and records
|
||||
`original_file_name`; empty-template apply reverts to the recorded name;
|
||||
revert target occupied by a newer file counts as failure and keeps the
|
||||
current name; models without a recorded original are skipped;
|
||||
apply → revert → re-apply cycles repeat cleanly.
|
||||
|
||||
Standing caveats (unchanged):
|
||||
|
||||
- The revert target may collide with an existing file — the existing conflict
|
||||
handling (count as failure, keep current name) covers this.
|
||||
- `original_file_name` only exists for models renamed after `2bc9860b`;
|
||||
older renames have no recorded original and are skipped.
|
||||
- `original_file_name` is kept (not cleared) after a revert, so
|
||||
apply → revert → re-apply stays repeatable.
|
||||
|
||||
## Follow-up 3 — Cross-page refresh after bulk apply
|
||||
|
||||
**Problem:** the settings-modal "Apply to Library Now" button calls
|
||||
`resetAndReload(true)`, which refreshes only the page type currently open.
|
||||
Applying the checkpoint template while on the loras page leaves the loras
|
||||
view refreshed but does not touch the checkpoints page state (same
|
||||
limitation as the existing bulk auto-organize flow in
|
||||
`static/js/managers/SettingsManager.js#applyFilenameTemplate`).
|
||||
|
||||
**Fix options:** broadcast a generic "library changed" event that every
|
||||
page's state listens to, or accept the limitation (the other page reloads
|
||||
its cache on next visit). Low priority.
|
||||
@@ -30,7 +30,9 @@ Aliases live inside `()` and are separated with `|`. The canonical name is what
|
||||
When your path template contains `{first_tag}`, the app picks a folder name based on your priority list and the model’s own tags:
|
||||
|
||||
- It checks the priority list from top to bottom. If a canonical tag or any of its aliases appear in the model tags, that canonical name becomes the folder name.
|
||||
- If no priority tags are found but the model has tags, the very first model tag is used.
|
||||
- If no priority tags are found but the model has tags, the first tag that can be used as a folder name is chosen.
|
||||
- Tags that contain a comma, or that are longer than 50 characters, are treated as unusable and skipped: some uploaders pack their whole keyword list into a single tag. If every tag is unusable, the folder falls back to `no tags`.
|
||||
- Civitai's structural labels, such as `base model`, describe the listing rather than the model, so the automatic fallback skips them too. Add one to your priority list if you really want it as a folder name.
|
||||
- If the model has no tags at all, the folder falls back to `no tags`.
|
||||
|
||||
### Example
|
||||
@@ -42,6 +44,8 @@ With a template like `/{model_type}/{first_tag}` and the priority entry list `ch
|
||||
| `["chars", "female"]` | `character` | `chars` matches the `character` alias, so the canonical wins. |
|
||||
| `["anime", "portrait"]` | `style` | `anime` hits the `style` entry, so its canonical label is used. |
|
||||
| `["portrait", "bw"]` | `portrait` | No priority match, so the first model tag is used. |
|
||||
| `["lora, character, rosie, ... face"]` | `no tags` | The only tag is a keyword dump, so it is skipped. |
|
||||
| `["lora, character, ... face", "base model"]` | `no tags` | A keyword dump plus a Civitai label: nothing usable is left. |
|
||||
| `[]` | `no tags` | Nothing to match, so the fallback is applied. |
|
||||
|
||||
## 3. Save the Settings
|
||||
@@ -61,10 +65,12 @@ After editing the entry list, press **Enter** to save. Use **Shift+Enter** whene
|
||||
- Keep canonical names short and meaningful—they become folder names.
|
||||
- Place the most important categories first; the first match wins.
|
||||
- Avoid duplicate canonical names within the same list; only the first instance is used.
|
||||
- Folder names built from tags are sanitized for filesystem safety and truncated to 50 characters.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Unexpected folder name?** Check that the canonical name you want is placed before other matches.
|
||||
- **Folder named `no tags`?** Every model tag was either missing or unusable (a comma-separated keyword dump, or longer than 50 characters). Add the tags you care about to your priority list so they match by name instead.
|
||||
- **Alias not working?** Ensure the alias is inside parentheses and separated with `|`, e.g. `character(char|chars)`.
|
||||
- **Validation error?** Look for missing parentheses or stray commas. Each entry must follow the `canonical(alias|alias)` pattern or just `canonical`.
|
||||
|
||||
|
||||
+93
-4
@@ -325,6 +325,12 @@
|
||||
"civitaiApiKeyConfigured": "Konfiguriert",
|
||||
"civitaiApiKeyNotConfigured": "Nicht konfiguriert",
|
||||
"civitaiApiKeySet": "Einrichten",
|
||||
"huggingfaceApiKey": "Hugging Face Access Token",
|
||||
"huggingfaceApiKeyPlaceholder": "Geben Sie Ihren Hugging Face Access Token ein",
|
||||
"huggingfaceApiKeyHelp": "Erforderlich für Downloads aus gated oder privaten Hugging Face Repositories. Erstellen Sie ein Read-only-Token unter huggingface.co/settings/tokens und akzeptieren Sie zuerst die Nutzungsbedingungen des Repositories auf dessen Seite.",
|
||||
"huggingfaceApiKeyConfigured": "Konfiguriert",
|
||||
"huggingfaceApiKeyNotConfigured": "Nicht konfiguriert",
|
||||
"huggingfaceApiKeySet": "Einrichten",
|
||||
"civitaiHost": {
|
||||
"label": "CivitAI-Host",
|
||||
"help": "Wählen Sie aus, welche CivitAI-Seite geöffnet wird, wenn Sie „View on CivitAI“-Links verwenden.",
|
||||
@@ -382,7 +388,9 @@
|
||||
"nav": {
|
||||
"general": "Allgemein",
|
||||
"interface": "Oberfläche",
|
||||
"library": "Bibliothek"
|
||||
"library": "Bibliothek",
|
||||
"organization": "Organisation",
|
||||
"modelPaths": "Modellpfade"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Einstellungen durchsuchen...",
|
||||
@@ -583,6 +591,46 @@
|
||||
"checkpointUnetOverlapInline": "Dieser Pfad wird bereits für einen anderen Modelltyp verwendet. Bitte verwenden Sie separate Ordner für Checkpoints und Diffusionsmodelle."
|
||||
}
|
||||
},
|
||||
"modelPaths": {
|
||||
"title": "Modellbibliothek-Pfade",
|
||||
"description": "Stammordner, die LoRA Manager nach Ihren Modellen durchsucht. Dies sind die primären Modellspeicherorte, die im Standalone-Modus aus der settings.json gelesen werden.",
|
||||
"restartRequired": "Neustart erforderlich, damit die Änderung wirksam wird",
|
||||
"coreTypes": "Kern-Modelltypen",
|
||||
"otherTypes": "Weitere Modelltypen",
|
||||
"otherTypesDisabledHint": "Es sind keine weiteren Modelltypen aktiviert. Aktivieren Sie oben die benötigten Typen, um deren Ordner zu konfigurieren.",
|
||||
"saveSuccessRestart": "Modellbibliothek-Pfade aktualisiert. Neustart erforderlich, um Änderungen anzuwenden.",
|
||||
"pendingRestartNotice": "Pfadänderungen gespeichert. Starten Sie LoRA Manager neu, damit sie wirksam werden.",
|
||||
"pendingRestartBannerTitle": "Neustart erforderlich, um Pfadänderungen anzuwenden",
|
||||
"pendingRestartBannerMessage": "Die Modellbibliothek-Pfade wurden aktualisiert. Starten Sie den LoRA Manager-Server neu, um die neuen Ordner zu scannen.",
|
||||
"folderKeys": {
|
||||
"loras": "LoRA-Pfade",
|
||||
"checkpoints": "Checkpoint-Pfade",
|
||||
"unet": "Diffusionsmodell-Pfade",
|
||||
"embeddings": "Embedding-Pfade",
|
||||
"vae": "VAE-Pfade",
|
||||
"upscale_models": "Upscaler-Pfade",
|
||||
"text_encoders": "Text-Encoder-Pfade",
|
||||
"clip": "CLIP-Pfade (Legacy)",
|
||||
"clip_vision": "CLIP-Vision-Pfade",
|
||||
"controlnet": "ControlNet-Pfade"
|
||||
}
|
||||
},
|
||||
"directoryPicker": {
|
||||
"title": "Ordner durchsuchen",
|
||||
"selectFolder": "Diesen Ordner auswählen",
|
||||
"goUp": "Nach oben",
|
||||
"pathPlaceholder": "Pfad eingeben...",
|
||||
"go": "Los",
|
||||
"emptyFolder": "Keine Unterordner",
|
||||
"loadError": "Verzeichnis konnte nicht geladen werden"
|
||||
},
|
||||
"pathValidation": {
|
||||
"valid": "Pfad ist gültig",
|
||||
"pathNotFound": "Pfad existiert nicht",
|
||||
"notADirectory": "Kein Verzeichnis",
|
||||
"notReadable": "Pfad ist nicht lesbar",
|
||||
"notWritable": "Pfad ist nicht beschreibbar"
|
||||
},
|
||||
"priorityTags": {
|
||||
"title": "Prioritäts-Tags",
|
||||
"description": "Passen Sie die Tag-Prioritätsreihenfolge für jeden Modelltyp an (z. B. character, concept, style(toon|toon_style))",
|
||||
@@ -639,6 +687,22 @@
|
||||
"validTemplate": "Gültige Vorlage"
|
||||
}
|
||||
},
|
||||
"filenameTemplates": {
|
||||
"title": "Dateinamen-Vorlagen",
|
||||
"help": "Konfigurieren Sie Dateinamen für heruntergeladene Modelle pro Modelltyp. Leer lassen, um den ursprünglichen Dateinamen zu behalten. Der ursprüngliche Dateiname bleibt immer in den Metadaten des Modells erhalten.",
|
||||
"availablePlaceholders": "Verfügbare Platzhalter:",
|
||||
"templatePlaceholder": "Dateinamen-Vorlage eingeben (z.B. {base_model}-{model_name}-{version_name})",
|
||||
"applyButton": "Jetzt auf Bibliothek anwenden",
|
||||
"applyHelp": "Benennt alle vorhandenen Dateien dieses Modelltyps gemäß der Vorlage um. Warnung: Das Umbenennen ändert den relativen Pfad, den ComfyUI-Loader sehen; vorhandene Workflows, die den alten Dateinamen referenzieren, müssen möglicherweise aktualisiert werden. Der ursprüngliche Dateiname bleibt in den Metadaten jedes Modells erhalten.",
|
||||
"confirmApply": "Alle vorhandenen Dateien dieses Modelltyps gemäß der Dateinamen-Vorlage umbenennen? Dies ändert den relativen Pfad, den ComfyUI-Loader sehen. Der ursprüngliche Dateiname bleibt in den Metadaten jedes Modells erhalten.",
|
||||
"confirmRevert": "Die gespeicherten ursprünglichen Dateinamen aller zuvor umbenannten Dateien dieses Modelltyps wiederherstellen? Dies ändert den relativen Pfad, den ComfyUI-Loader sehen. Dateien ohne gespeicherten ursprünglichen Dateinamen werden übersprungen.",
|
||||
"validation": {
|
||||
"restoreOriginal": "Gültig (leere Vorlage stellt ursprüngliche Dateinamen wieder her)",
|
||||
"invalidChars": "Ungültige Zeichen erkannt (ein Dateiname darf / \\ < > : \" | ? * nicht enthalten)",
|
||||
"invalidPlaceholder": "Ungültiger Platzhalter: {placeholder}",
|
||||
"validTemplate": "Gültige Vorlage"
|
||||
}
|
||||
},
|
||||
"exampleImages": {
|
||||
"downloadLocation": "Download-Speicherort",
|
||||
"downloadLocationPlaceholder": "Ordnerpfad für Beispielbilder eingeben",
|
||||
@@ -871,6 +935,14 @@
|
||||
"complete": "Automatische Organisation abgeschlossen",
|
||||
"error": "Fehler: {error}"
|
||||
},
|
||||
"filenameTemplateProgress": {
|
||||
"initializing": "Anwendung der Dateinamen-Vorlage wird initialisiert...",
|
||||
"starting": "Dateinamen-Vorlage wird auf {type} angewendet...",
|
||||
"processing": "Verarbeitung ({processed}/{total}) – {success} umbenannt, {skipped} übersprungen, {failures} fehlgeschlagen",
|
||||
"completed": "Abgeschlossen: {success} umbenannt, {skipped} übersprungen, {failures} fehlgeschlagen",
|
||||
"complete": "Anwendung der Dateinamen-Vorlage abgeschlossen",
|
||||
"error": "Fehler: {error}"
|
||||
},
|
||||
"enrichHfAgent": "Metadaten mit KI anreichern"
|
||||
},
|
||||
"contextMenu": {
|
||||
@@ -1241,11 +1313,13 @@
|
||||
},
|
||||
"noPaths": {
|
||||
"title": "Keine Ordner für weitere Modelle gefunden",
|
||||
"descriptionStandalone": "Die Verwaltung weiterer Modelle ist aktiviert, aber keiner der konfigurierten Modellordner existiert auf dem Datenträger. Fügen Sie die unten stehenden Ordnerpfade zu settings.json hinzu und starten Sie LoRA Manager neu.",
|
||||
"hintStandalone": "Nur die oben aufgeführten Ordnerschlüssel werden gescannt; nicht benötigte Schlüssel können weggelassen werden.",
|
||||
"descriptionStandalone": "Die Verwaltung weiterer Modelle ist aktiviert, aber es wurden keine Ordner für weitere Modelle gefunden. Fügen Sie Ihre Modellordner unter Einstellungen → Modellpfade hinzu und starten Sie LoRA Manager anschließend neu.",
|
||||
"hintStandalone": "Es werden nur aktivierte Modelltypen gescannt. Aktivieren Sie die benötigten Typen unter Bibliothek → Standard-Roots.",
|
||||
"descriptionComfyUI": "Die Verwaltung weiterer Modelle ist aktiviert, aber keiner der konfigurierten Modellordner existiert auf dem Datenträger. Fügen Sie die entsprechenden Modellordner zu Ihren ComfyUI-Modellpfaden hinzu und laden Sie diese Seite neu.",
|
||||
"hintComfyUI": "Weitere Modelle werden aus den Ordnern vae, upscale_models, text_encoders, clip_vision und controlnet von ComfyUI gelesen.",
|
||||
"openSettings": "Einstellungen öffnen"
|
||||
"openSettings": "Einstellungen öffnen",
|
||||
"openModelPaths": "Modellordner konfigurieren",
|
||||
"openSettingsFolder": "Einstellungsordner öffnen"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
@@ -1558,6 +1632,11 @@
|
||||
"tip": "Möchten Sie in Etappen prüfen? Wechseln Sie in den Massenmodus, wählen Sie die benötigten Modelle aus und nutzen Sie anschließend \"Auswahl auf Updates prüfen\".",
|
||||
"action": "Alles prüfen"
|
||||
},
|
||||
"filenameTemplateConfirm": {
|
||||
"titleApply": "Dateinamen-Vorlage auf Bibliothek anwenden?",
|
||||
"titleRevert": "Ursprüngliche Dateinamen wiederherstellen?",
|
||||
"revertButton": "Ursprüngliche Dateinamen wiederherstellen"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Tags zu mehreren Modellen hinzufügen",
|
||||
"description": "Tags hinzufügen zu",
|
||||
@@ -2267,6 +2346,9 @@
|
||||
"autoOrganizeSuccess": "Automatische Organisation für {count} {type} erfolgreich abgeschlossen",
|
||||
"autoOrganizePartialSuccess": "Automatische Organisation abgeschlossen: {success} verschoben, {failures} fehlgeschlagen von insgesamt {total} Modellen",
|
||||
"autoOrganizeFailed": "Automatische Organisation fehlgeschlagen: {error}",
|
||||
"filenameTemplateSuccess": "Dateinamen-Vorlage erfolgreich für {count} {type} angewendet",
|
||||
"filenameTemplatePartialSuccess": "Dateinamen-Vorlage angewendet: {success} umbenannt, {failures} von {total} Modellen fehlgeschlagen",
|
||||
"filenameTemplateFailed": "Anwendung der Dateinamen-Vorlage fehlgeschlagen: {error}",
|
||||
"noModelsSelected": "Keine Modelle ausgewählt"
|
||||
},
|
||||
"recipes": {
|
||||
@@ -2433,6 +2515,8 @@
|
||||
"mappingSaveFailed": "Fehler beim Speichern der Basismodell-Zuordnungen: {message}",
|
||||
"downloadTemplatesUpdated": "Download-Pfad-Vorlagen aktualisiert",
|
||||
"downloadTemplatesFailed": "Fehler beim Speichern der Download-Pfad-Vorlagen: {message}",
|
||||
"filenameTemplatesUpdated": "Dateinamen-Vorlagen aktualisiert",
|
||||
"filenameTemplatesFailed": "Dateinamen-Vorlagen konnten nicht gespeichert werden: {message}",
|
||||
"recipesPathUpdated": "Rezepte-Speicherpfad aktualisiert",
|
||||
"recipesPathSaveFailed": "Fehler beim Aktualisieren des Rezepte-Speicherpfads: {message}",
|
||||
"settingsUpdated": "Einstellungen aktualisiert: {setting}",
|
||||
@@ -2698,6 +2782,11 @@
|
||||
"content": "Scannen und verwalten Sie VAE-, Upscaler-, Text-Encoder-, CLIP-Vision- und ControlNet-Dateien und laden Sie sie von CivitAI herunter, alles auf einer eigenen Seite.",
|
||||
"enable": "Weitere Modelle aktivieren",
|
||||
"openSettings": "Einstellungen öffnen"
|
||||
},
|
||||
"pager": {
|
||||
"previous": "Vorherige Mitteilung",
|
||||
"next": "Nächste Mitteilung",
|
||||
"position": "Mitteilung {current} von {total}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+93
-4
@@ -325,6 +325,12 @@
|
||||
"civitaiApiKeyConfigured": "Configured",
|
||||
"civitaiApiKeyNotConfigured": "Not configured",
|
||||
"civitaiApiKeySet": "Set up",
|
||||
"huggingfaceApiKey": "Hugging Face Access Token",
|
||||
"huggingfaceApiKeyPlaceholder": "Enter your Hugging Face access token",
|
||||
"huggingfaceApiKeyHelp": "Required to download from gated or private Hugging Face repositories. Create a read-only token at huggingface.co/settings/tokens, and accept the repository's terms on its page first.",
|
||||
"huggingfaceApiKeyConfigured": "Configured",
|
||||
"huggingfaceApiKeyNotConfigured": "Not configured",
|
||||
"huggingfaceApiKeySet": "Set up",
|
||||
"civitaiHost": {
|
||||
"label": "CivitAI host",
|
||||
"help": "Choose which CivitAI site opens when using View on CivitAI links.",
|
||||
@@ -382,7 +388,9 @@
|
||||
"nav": {
|
||||
"general": "General",
|
||||
"interface": "Interface",
|
||||
"library": "Library"
|
||||
"library": "Library",
|
||||
"organization": "Organization",
|
||||
"modelPaths": "Model Paths"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Search settings...",
|
||||
@@ -583,6 +591,46 @@
|
||||
"checkpointUnetOverlapInline": "This path is also used for a different model type. Use separate folders for checkpoints and diffusion models."
|
||||
}
|
||||
},
|
||||
"modelPaths": {
|
||||
"title": "Model Library Paths",
|
||||
"description": "Root folders LoRA Manager scans for your models. These are the primary model locations read from settings.json in standalone mode.",
|
||||
"restartRequired": "Requires restart to take effect",
|
||||
"coreTypes": "Core Model Types",
|
||||
"otherTypes": "Other Model Types",
|
||||
"otherTypesDisabledHint": "No other model types are enabled. Turn on the types you need above to configure their folders.",
|
||||
"saveSuccessRestart": "Model library paths updated. Restart required to apply changes.",
|
||||
"pendingRestartNotice": "Path changes saved. Restart LoRA Manager for them to take effect.",
|
||||
"pendingRestartBannerTitle": "Restart required to apply path changes",
|
||||
"pendingRestartBannerMessage": "Model library paths were updated. Restart the LoRA Manager server to scan the new folders.",
|
||||
"folderKeys": {
|
||||
"loras": "LoRA Paths",
|
||||
"checkpoints": "Checkpoint Paths",
|
||||
"unet": "Diffusion Model Paths",
|
||||
"embeddings": "Embedding Paths",
|
||||
"vae": "VAE Paths",
|
||||
"upscale_models": "Upscaler Paths",
|
||||
"text_encoders": "Text Encoder Paths",
|
||||
"clip": "CLIP Paths (legacy)",
|
||||
"clip_vision": "CLIP Vision Paths",
|
||||
"controlnet": "ControlNet Paths"
|
||||
}
|
||||
},
|
||||
"directoryPicker": {
|
||||
"title": "Browse Folders",
|
||||
"selectFolder": "Select This Folder",
|
||||
"goUp": "Up",
|
||||
"pathPlaceholder": "Enter path...",
|
||||
"go": "Go",
|
||||
"emptyFolder": "No subfolders",
|
||||
"loadError": "Failed to load directory"
|
||||
},
|
||||
"pathValidation": {
|
||||
"valid": "Path is valid",
|
||||
"pathNotFound": "Path does not exist",
|
||||
"notADirectory": "Not a directory",
|
||||
"notReadable": "Path is not readable",
|
||||
"notWritable": "Path is not writable"
|
||||
},
|
||||
"priorityTags": {
|
||||
"title": "Priority Tags",
|
||||
"description": "Customize the tag priority order for each model type (e.g., character, concept, style(toon|toon_style))",
|
||||
@@ -639,6 +687,22 @@
|
||||
"validTemplate": "Valid template"
|
||||
}
|
||||
},
|
||||
"filenameTemplates": {
|
||||
"title": "Filename Templates",
|
||||
"help": "Configure filenames for downloaded models per model type. Leave empty to keep original filenames on download; applying an empty template restores the recorded original filenames of previously renamed models. The original filename is always preserved in the model's metadata.",
|
||||
"availablePlaceholders": "Available placeholders:",
|
||||
"templatePlaceholder": "Enter filename template (e.g., {base_model}-{model_name}-{version_name})",
|
||||
"applyButton": "Apply to Library Now",
|
||||
"applyHelp": "Renames all existing files of this model type according to the template; with an empty template, restores the recorded original filenames instead. Warning: renaming changes the relative path seen by ComfyUI loaders, so existing workflows referencing the old filename may need to be updated. The original filename is preserved in each model's metadata.",
|
||||
"confirmApply": "Rename all existing files of this model type according to the filename template? This changes the relative path seen by ComfyUI loaders. The original filename is preserved in each model's metadata.",
|
||||
"confirmRevert": "Restore the recorded original filenames of all previously renamed files of this model type? This changes the relative path seen by ComfyUI loaders. Files without a recorded original filename are skipped.",
|
||||
"validation": {
|
||||
"restoreOriginal": "Valid (empty template restores original filenames)",
|
||||
"invalidChars": "Invalid characters detected (a filename cannot contain / \\ < > : \" | ? *)",
|
||||
"invalidPlaceholder": "Invalid placeholder: {placeholder}",
|
||||
"validTemplate": "Valid template"
|
||||
}
|
||||
},
|
||||
"exampleImages": {
|
||||
"downloadLocation": "Download Location",
|
||||
"downloadLocationPlaceholder": "Enter folder path for example images",
|
||||
@@ -871,6 +935,14 @@
|
||||
"complete": "Auto-organize complete",
|
||||
"error": "Error: {error}"
|
||||
},
|
||||
"filenameTemplateProgress": {
|
||||
"initializing": "Initializing filename template apply...",
|
||||
"starting": "Applying filename template to {type}...",
|
||||
"processing": "Processing ({processed}/{total}) - {success} renamed, {skipped} skipped, {failures} failed",
|
||||
"completed": "Completed: {success} renamed, {skipped} skipped, {failures} failed",
|
||||
"complete": "Filename template apply complete",
|
||||
"error": "Error: {error}"
|
||||
},
|
||||
"enrichHfAgent": "Enrich Metadata with AI"
|
||||
},
|
||||
"contextMenu": {
|
||||
@@ -1241,11 +1313,13 @@
|
||||
},
|
||||
"noPaths": {
|
||||
"title": "No other-model folders found",
|
||||
"descriptionStandalone": "Other Models management is on, but none of the configured model folders exist on disk. Add the folder paths below to settings.json and restart LoRA Manager.",
|
||||
"hintStandalone": "Only the folder keys listed above are scanned; keys you do not need can be omitted.",
|
||||
"descriptionStandalone": "Other Models management is on, but no other-model folders were found. Add your model folders under Settings → Model Paths, then restart LoRA Manager.",
|
||||
"hintStandalone": "Only enabled model types are scanned; enable the types you need under Library → Folder Settings.",
|
||||
"descriptionComfyUI": "Other Models management is on, but none of the configured model folders exist on disk. Add the matching model folders to your ComfyUI model paths, then reload this page.",
|
||||
"hintComfyUI": "Other models are read from ComfyUI's vae, upscale_models, text_encoders, clip_vision and controlnet folders.",
|
||||
"openSettings": "Open Settings"
|
||||
"openSettings": "Open Settings",
|
||||
"openModelPaths": "Configure Model Folders",
|
||||
"openSettingsFolder": "Open Settings Folder"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
@@ -1558,6 +1632,11 @@
|
||||
"tip": "To work in smaller batches, switch to bulk mode, choose the ones you need, then use \"Check Updates for Selected\".",
|
||||
"action": "Check All"
|
||||
},
|
||||
"filenameTemplateConfirm": {
|
||||
"titleApply": "Apply filename template to library?",
|
||||
"titleRevert": "Restore original filenames?",
|
||||
"revertButton": "Restore Original Filenames"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Add Tags to Multiple Models",
|
||||
"description": "Add tags to",
|
||||
@@ -2267,6 +2346,9 @@
|
||||
"autoOrganizeSuccess": "Auto-organize completed successfully for {count} {type}",
|
||||
"autoOrganizePartialSuccess": "Auto-organize completed with {success} moved, {failures} failed out of {total} models",
|
||||
"autoOrganizeFailed": "Auto-organize failed: {error}",
|
||||
"filenameTemplateSuccess": "Filename template applied successfully for {count} {type}",
|
||||
"filenameTemplatePartialSuccess": "Filename template applied with {success} renamed, {failures} failed out of {total} models",
|
||||
"filenameTemplateFailed": "Applying filename template failed: {error}",
|
||||
"noModelsSelected": "No models selected"
|
||||
},
|
||||
"recipes": {
|
||||
@@ -2433,6 +2515,8 @@
|
||||
"mappingSaveFailed": "Failed to save base model mappings: {message}",
|
||||
"downloadTemplatesUpdated": "Download path templates updated",
|
||||
"downloadTemplatesFailed": "Failed to save download path templates: {message}",
|
||||
"filenameTemplatesUpdated": "Filename templates updated",
|
||||
"filenameTemplatesFailed": "Failed to save filename templates: {message}",
|
||||
"recipesPathUpdated": "Recipes storage path updated",
|
||||
"recipesPathSaveFailed": "Failed to update recipes storage path: {message}",
|
||||
"settingsUpdated": "Settings updated: {setting}",
|
||||
@@ -2698,6 +2782,11 @@
|
||||
"content": "Scan and manage VAE, upscaler, text encoder, CLIP vision and ControlNet files — and download them from CivitAI — from one dedicated page.",
|
||||
"enable": "Enable Other Models",
|
||||
"openSettings": "Open Settings"
|
||||
},
|
||||
"pager": {
|
||||
"previous": "Previous message",
|
||||
"next": "Next message",
|
||||
"position": "Message {current} of {total}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+93
-4
@@ -325,6 +325,12 @@
|
||||
"civitaiApiKeyConfigured": "Configurado",
|
||||
"civitaiApiKeyNotConfigured": "No configurado",
|
||||
"civitaiApiKeySet": "Configurar",
|
||||
"huggingfaceApiKey": "Token de acceso de Hugging Face",
|
||||
"huggingfaceApiKeyPlaceholder": "Introduce tu token de acceso de Hugging Face",
|
||||
"huggingfaceApiKeyHelp": "Necesario para descargar de repositorios de Hugging Face restringidos (gated) o privados. Crea un token de solo lectura en huggingface.co/settings/tokens y acepta primero los términos del repositorio en su página.",
|
||||
"huggingfaceApiKeyConfigured": "Configurado",
|
||||
"huggingfaceApiKeyNotConfigured": "No configurado",
|
||||
"huggingfaceApiKeySet": "Configurar",
|
||||
"civitaiHost": {
|
||||
"label": "Host de CivitAI",
|
||||
"help": "Elige qué sitio de CivitAI se abre al usar los enlaces de \"View on CivitAI\".",
|
||||
@@ -382,7 +388,9 @@
|
||||
"nav": {
|
||||
"general": "General",
|
||||
"interface": "Interfaz",
|
||||
"library": "Biblioteca"
|
||||
"library": "Biblioteca",
|
||||
"organization": "Organización",
|
||||
"modelPaths": "Rutas de modelos"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Buscar ajustes...",
|
||||
@@ -583,6 +591,46 @@
|
||||
"checkpointUnetOverlapInline": "Esta ruta ya se usa para otro tipo de modelo. Use carpetas separadas para checkpoints y modelos de difusión."
|
||||
}
|
||||
},
|
||||
"modelPaths": {
|
||||
"title": "Rutas de la biblioteca de modelos",
|
||||
"description": "Carpetas raíz que LoRA Manager escanea en busca de tus modelos. Son las ubicaciones de modelos principales leídas de settings.json en modo independiente.",
|
||||
"restartRequired": "Requiere reiniciar para que surta efecto",
|
||||
"coreTypes": "Tipos de modelos principales",
|
||||
"otherTypes": "Otros tipos de modelos",
|
||||
"otherTypesDisabledHint": "No hay habilitado ningún otro tipo de modelo. Activa los tipos que necesites arriba para configurar sus carpetas.",
|
||||
"saveSuccessRestart": "Rutas de la biblioteca de modelos actualizadas. Se requiere reinicio para aplicar los cambios.",
|
||||
"pendingRestartNotice": "Cambios de rutas guardados. Reinicia LoRA Manager para que surtan efecto.",
|
||||
"pendingRestartBannerTitle": "Se requiere reinicio para aplicar los cambios de rutas",
|
||||
"pendingRestartBannerMessage": "Se actualizaron las rutas de la biblioteca de modelos. Reinicia el servidor de LoRA Manager para escanear las nuevas carpetas.",
|
||||
"folderKeys": {
|
||||
"loras": "Rutas de LoRA",
|
||||
"checkpoints": "Rutas de Checkpoint",
|
||||
"unet": "Rutas de modelo de difusión",
|
||||
"embeddings": "Rutas de Embedding",
|
||||
"vae": "Rutas de VAE",
|
||||
"upscale_models": "Rutas de Upscaler",
|
||||
"text_encoders": "Rutas de Text Encoder",
|
||||
"clip": "Rutas de CLIP (heredadas)",
|
||||
"clip_vision": "Rutas de CLIP Vision",
|
||||
"controlnet": "Rutas de ControlNet"
|
||||
}
|
||||
},
|
||||
"directoryPicker": {
|
||||
"title": "Explorar carpetas",
|
||||
"selectFolder": "Seleccionar esta carpeta",
|
||||
"goUp": "Subir",
|
||||
"pathPlaceholder": "Introducir ruta...",
|
||||
"go": "Ir",
|
||||
"emptyFolder": "No hay subcarpetas",
|
||||
"loadError": "Error al cargar el directorio"
|
||||
},
|
||||
"pathValidation": {
|
||||
"valid": "La ruta es válida",
|
||||
"pathNotFound": "La ruta no existe",
|
||||
"notADirectory": "No es un directorio",
|
||||
"notReadable": "La ruta no es legible",
|
||||
"notWritable": "La ruta no es escribible"
|
||||
},
|
||||
"priorityTags": {
|
||||
"title": "Etiquetas prioritarias",
|
||||
"description": "Personaliza el orden de prioridad de etiquetas para cada tipo de modelo (p. ej., character, concept, style(toon|toon_style))",
|
||||
@@ -639,6 +687,22 @@
|
||||
"validTemplate": "Plantilla válida"
|
||||
}
|
||||
},
|
||||
"filenameTemplates": {
|
||||
"title": "Plantillas de nombres de archivo",
|
||||
"help": "Configurar nombres de archivo de los modelos descargados por tipo de modelo. Dejar vacío para conservar los nombres de archivo originales al descargar; aplicar una plantilla vacía restaura los nombres de archivo originales registrados de los modelos renombrados previamente. El nombre de archivo original siempre se conserva en los metadatos del modelo.",
|
||||
"availablePlaceholders": "Marcadores de posición disponibles:",
|
||||
"templatePlaceholder": "Introduce plantilla de nombre de archivo (ej., {base_model}-{model_name}-{version_name})",
|
||||
"applyButton": "Aplicar a la biblioteca ahora",
|
||||
"applyHelp": "Renombra todos los archivos existentes de este tipo de modelo según la plantilla; con una plantilla vacía, restaura los nombres de archivo originales registrados. Advertencia: renombrar cambia la ruta relativa que ven los cargadores de ComfyUI, por lo que los workflows existentes que hagan referencia al nombre de archivo anterior pueden necesitar actualizarse. El nombre de archivo original se conserva en los metadatos de cada modelo.",
|
||||
"confirmApply": "¿Renombrar todos los archivos existentes de este tipo de modelo según la plantilla de nombres de archivo? Esto cambia la ruta relativa que ven los cargadores de ComfyUI. El nombre de archivo original se conserva en los metadatos de cada modelo.",
|
||||
"confirmRevert": "¿Restaurar los nombres de archivo originales registrados de todos los archivos renombrados previamente de este tipo de modelo? Esto cambia la ruta relativa que ven los cargadores de ComfyUI. Los archivos sin un nombre de archivo original registrado se omiten.",
|
||||
"validation": {
|
||||
"restoreOriginal": "Válido (la plantilla vacía restaura los nombres de archivo originales)",
|
||||
"invalidChars": "Caracteres inválidos detectados (un nombre de archivo no puede contener / \\ < > : \" | ? *)",
|
||||
"invalidPlaceholder": "Marcador de posición inválido: {placeholder}",
|
||||
"validTemplate": "Plantilla válida"
|
||||
}
|
||||
},
|
||||
"exampleImages": {
|
||||
"downloadLocation": "Ubicación de descarga",
|
||||
"downloadLocationPlaceholder": "Introduce la ruta de la carpeta para imágenes de ejemplo",
|
||||
@@ -871,6 +935,14 @@
|
||||
"complete": "Auto-organización completada",
|
||||
"error": "Error: {error}"
|
||||
},
|
||||
"filenameTemplateProgress": {
|
||||
"initializing": "Inicializando aplicación de plantilla de nombres de archivo...",
|
||||
"starting": "Aplicando plantilla de nombres de archivo a {type}...",
|
||||
"processing": "Procesando ({processed}/{total}) - {success} renombrados, {skipped} omitidos, {failures} fallidos",
|
||||
"completed": "Completado: {success} renombrados, {skipped} omitidos, {failures} fallidos",
|
||||
"complete": "Aplicación de plantilla de nombres de archivo completada",
|
||||
"error": "Error: {error}"
|
||||
},
|
||||
"enrichHfAgent": "Enriquecer metadatos con IA"
|
||||
},
|
||||
"contextMenu": {
|
||||
@@ -1241,11 +1313,13 @@
|
||||
},
|
||||
"noPaths": {
|
||||
"title": "No se encontraron carpetas de otros modelos",
|
||||
"descriptionStandalone": "La gestión de otros modelos está activada, pero ninguna de las carpetas de modelos configuradas existe en el disco. Añade las rutas de carpetas de abajo a settings.json y reinicia LoRA Manager.",
|
||||
"hintStandalone": "Solo se escanean las claves de carpeta listadas arriba; las claves que no necesites puedes omitirlas.",
|
||||
"descriptionStandalone": "La gestión de otros modelos está activada, pero no se encontraron carpetas de otros modelos. Añade tus carpetas de modelos en Configuración → Rutas de modelos y reinicia LoRA Manager.",
|
||||
"hintStandalone": "Solo se escanean los tipos de modelos habilitados; activa los tipos que necesites en Biblioteca → Raíces predeterminadas.",
|
||||
"descriptionComfyUI": "La gestión de otros modelos está activada, pero ninguna de las carpetas de modelos configuradas existe en el disco. Añade las carpetas de modelos correspondientes a tus rutas de modelos de ComfyUI y recarga esta página.",
|
||||
"hintComfyUI": "Los otros modelos se leen de las carpetas vae, upscale_models, text_encoders, clip_vision y controlnet de ComfyUI.",
|
||||
"openSettings": "Abrir configuración"
|
||||
"openSettings": "Abrir configuración",
|
||||
"openModelPaths": "Configurar carpetas de modelos",
|
||||
"openSettingsFolder": "Abrir carpeta de ajustes"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
@@ -1558,6 +1632,11 @@
|
||||
"tip": "¿Quieres hacerlo por partes? Activa el modo por lotes, selecciona los modelos que necesites y usa \"Comprobar actualizaciones para la selección\".",
|
||||
"action": "Comprobar todo"
|
||||
},
|
||||
"filenameTemplateConfirm": {
|
||||
"titleApply": "¿Aplicar la plantilla de nombres de archivo a la biblioteca?",
|
||||
"titleRevert": "¿Restaurar los nombres de archivo originales?",
|
||||
"revertButton": "Restaurar nombres de archivo originales"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Añadir etiquetas a múltiples modelos",
|
||||
"description": "Añadir etiquetas a",
|
||||
@@ -2267,6 +2346,9 @@
|
||||
"autoOrganizeSuccess": "Auto-organización completada exitosamente para {count} {type}",
|
||||
"autoOrganizePartialSuccess": "Auto-organización completada con {success} movidos, {failures} fallidos de un total de {total} modelos",
|
||||
"autoOrganizeFailed": "Auto-organización fallida: {error}",
|
||||
"filenameTemplateSuccess": "Plantilla de nombres de archivo aplicada exitosamente para {count} {type}",
|
||||
"filenameTemplatePartialSuccess": "Plantilla de nombres de archivo aplicada con {success} renombrados, {failures} fallidos de un total de {total} modelos",
|
||||
"filenameTemplateFailed": "Aplicación de la plantilla de nombres de archivo fallida: {error}",
|
||||
"noModelsSelected": "No hay modelos seleccionados"
|
||||
},
|
||||
"recipes": {
|
||||
@@ -2433,6 +2515,8 @@
|
||||
"mappingSaveFailed": "Error al guardar mapeos de modelo base: {message}",
|
||||
"downloadTemplatesUpdated": "Plantillas de rutas de descarga actualizadas",
|
||||
"downloadTemplatesFailed": "Error al guardar plantillas de rutas de descarga: {message}",
|
||||
"filenameTemplatesUpdated": "Plantillas de nombres de archivo actualizadas",
|
||||
"filenameTemplatesFailed": "Error al guardar plantillas de nombres de archivo: {message}",
|
||||
"recipesPathUpdated": "Ruta de almacenamiento de recetas actualizada",
|
||||
"recipesPathSaveFailed": "Error al actualizar la ruta de almacenamiento de recetas: {message}",
|
||||
"settingsUpdated": "Configuración actualizada: {setting}",
|
||||
@@ -2698,6 +2782,11 @@
|
||||
"content": "Escanea y gestiona archivos VAE, Upscaler, Text Encoder, CLIP Vision y ControlNet, y descárgalos desde CivitAI, todo desde una página dedicada.",
|
||||
"enable": "Activar otros modelos",
|
||||
"openSettings": "Abrir configuración"
|
||||
},
|
||||
"pager": {
|
||||
"previous": "Notificación anterior",
|
||||
"next": "Notificación siguiente",
|
||||
"position": "Notificación {current} de {total}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+93
-4
@@ -325,6 +325,12 @@
|
||||
"civitaiApiKeyConfigured": "Configuré",
|
||||
"civitaiApiKeyNotConfigured": "Non configuré",
|
||||
"civitaiApiKeySet": "Configurer",
|
||||
"huggingfaceApiKey": "Jeton d'accès Hugging Face",
|
||||
"huggingfaceApiKeyPlaceholder": "Entrez votre jeton d'accès Hugging Face",
|
||||
"huggingfaceApiKeyHelp": "Nécessaire pour télécharger depuis des dépôts Hugging Face restreints (gated) ou privés. Créez un jeton en lecture seule sur huggingface.co/settings/tokens, puis acceptez d'abord les conditions du dépôt sur sa page.",
|
||||
"huggingfaceApiKeyConfigured": "Configuré",
|
||||
"huggingfaceApiKeyNotConfigured": "Non configuré",
|
||||
"huggingfaceApiKeySet": "Configurer",
|
||||
"civitaiHost": {
|
||||
"label": "Hôte CivitAI",
|
||||
"help": "Choisissez quel site CivitAI s'ouvre lorsque vous utilisez les liens « View on CivitAI ».",
|
||||
@@ -382,7 +388,9 @@
|
||||
"nav": {
|
||||
"general": "Général",
|
||||
"interface": "Interface",
|
||||
"library": "Bibliothèque"
|
||||
"library": "Bibliothèque",
|
||||
"organization": "Organisation",
|
||||
"modelPaths": "Chemins de modèles"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Rechercher dans les paramètres...",
|
||||
@@ -583,6 +591,46 @@
|
||||
"checkpointUnetOverlapInline": "Ce chemin est déjà utilisé pour un autre type de modèle. Utilisez des dossiers séparés pour les checkpoints et les modèles de diffusion."
|
||||
}
|
||||
},
|
||||
"modelPaths": {
|
||||
"title": "Chemins de la bibliothèque de modèles",
|
||||
"description": "Dossiers racine que LoRA Manager analyse pour trouver vos modèles. Ce sont les emplacements de modèles principaux lus depuis settings.json en mode autonome.",
|
||||
"restartRequired": "Un redémarrage est requis pour appliquer les changements",
|
||||
"coreTypes": "Types de modèles principaux",
|
||||
"otherTypes": "Autres types de modèles",
|
||||
"otherTypesDisabledHint": "Aucun autre type de modèle n’est activé. Activez les types dont vous avez besoin ci-dessus pour configurer leurs dossiers.",
|
||||
"saveSuccessRestart": "Chemins de la bibliothèque de modèles mis à jour. Redémarrage requis pour appliquer les changements.",
|
||||
"pendingRestartNotice": "Changements de chemins enregistrés. Redémarrez LoRA Manager pour qu’ils prennent effet.",
|
||||
"pendingRestartBannerTitle": "Redémarrage requis pour appliquer les changements de chemins",
|
||||
"pendingRestartBannerMessage": "Les chemins de la bibliothèque de modèles ont été mis à jour. Redémarrez le serveur LoRA Manager pour analyser les nouveaux dossiers.",
|
||||
"folderKeys": {
|
||||
"loras": "Chemins LoRA",
|
||||
"checkpoints": "Chemins Checkpoint",
|
||||
"unet": "Chemins de modèle de diffusion",
|
||||
"embeddings": "Chemins Embedding",
|
||||
"vae": "Chemins VAE",
|
||||
"upscale_models": "Chemins Upscaler",
|
||||
"text_encoders": "Chemins Text Encoder",
|
||||
"clip": "Chemins CLIP (hérité)",
|
||||
"clip_vision": "Chemins CLIP Vision",
|
||||
"controlnet": "Chemins ControlNet"
|
||||
}
|
||||
},
|
||||
"directoryPicker": {
|
||||
"title": "Parcourir les dossiers",
|
||||
"selectFolder": "Sélectionner ce dossier",
|
||||
"goUp": "Remonter",
|
||||
"pathPlaceholder": "Saisir un chemin...",
|
||||
"go": "Aller",
|
||||
"emptyFolder": "Aucun sous-dossier",
|
||||
"loadError": "Échec du chargement du dossier"
|
||||
},
|
||||
"pathValidation": {
|
||||
"valid": "Le chemin est valide",
|
||||
"pathNotFound": "Le chemin n’existe pas",
|
||||
"notADirectory": "N’est pas un dossier",
|
||||
"notReadable": "Le chemin n’est pas lisible",
|
||||
"notWritable": "Le chemin n’est pas accessible en écriture"
|
||||
},
|
||||
"priorityTags": {
|
||||
"title": "Tags prioritaires",
|
||||
"description": "Personnalisez l'ordre de priorité des tags pour chaque type de modèle (par ex. : character, concept, style(toon|toon_style))",
|
||||
@@ -639,6 +687,22 @@
|
||||
"validTemplate": "Modèle valide"
|
||||
}
|
||||
},
|
||||
"filenameTemplates": {
|
||||
"title": "Modèles de nom de fichier",
|
||||
"help": "Configurer les noms de fichier des modèles téléchargés par type de modèle. Laisser vide pour conserver le nom de fichier d'origine. Le nom de fichier d'origine est toujours conservé dans les métadonnées du modèle.",
|
||||
"availablePlaceholders": "Espaces réservés disponibles :",
|
||||
"templatePlaceholder": "Entrez un modèle de nom de fichier (ex: {base_model}-{model_name}-{version_name})",
|
||||
"applyButton": "Appliquer à la bibliothèque maintenant",
|
||||
"applyHelp": "Renomme tous les fichiers existants de ce type de modèle selon le modèle. Attention : le renommage change le chemin relatif vu par les loaders ComfyUI, les workflows existants référençant l'ancien nom de fichier peuvent donc nécessiter une mise à jour. Le nom de fichier d'origine est conservé dans les métadonnées de chaque modèle.",
|
||||
"confirmApply": "Renommer tous les fichiers existants de ce type de modèle selon le modèle de nom de fichier ? Cela change le chemin relatif vu par les loaders ComfyUI. Le nom de fichier d'origine est conservé dans les métadonnées de chaque modèle.",
|
||||
"confirmRevert": "Restaurer les noms de fichier d'origine enregistrés de tous les fichiers précédemment renommés de ce type de modèle ? Cela change le chemin relatif vu par les loaders ComfyUI. Les fichiers sans nom de fichier d'origine enregistré sont ignorés.",
|
||||
"validation": {
|
||||
"restoreOriginal": "Valide (un modèle vide restaure les noms de fichier d'origine)",
|
||||
"invalidChars": "Caractères invalides détectés (un nom de fichier ne peut pas contenir / \\ < > : \" | ? *)",
|
||||
"invalidPlaceholder": "Espace réservé invalide : {placeholder}",
|
||||
"validTemplate": "Modèle valide"
|
||||
}
|
||||
},
|
||||
"exampleImages": {
|
||||
"downloadLocation": "Emplacement de téléchargement",
|
||||
"downloadLocationPlaceholder": "Entrez le chemin du dossier pour les images d'exemple",
|
||||
@@ -871,6 +935,14 @@
|
||||
"complete": "Auto-organisation terminée",
|
||||
"error": "Erreur : {error}"
|
||||
},
|
||||
"filenameTemplateProgress": {
|
||||
"initializing": "Initialisation de l'application du modèle de nom de fichier...",
|
||||
"starting": "Application du modèle de nom de fichier pour {type}...",
|
||||
"processing": "Traitement ({processed}/{total}) - {success} renommés, {skipped} ignorés, {failures} échecs",
|
||||
"completed": "Terminé : {success} renommés, {skipped} ignorés, {failures} échecs",
|
||||
"complete": "Application du modèle de nom de fichier terminée",
|
||||
"error": "Erreur : {error}"
|
||||
},
|
||||
"enrichHfAgent": "Enrichir les métadonnées avec l'IA"
|
||||
},
|
||||
"contextMenu": {
|
||||
@@ -1241,11 +1313,13 @@
|
||||
},
|
||||
"noPaths": {
|
||||
"title": "Aucun dossier d’autres modèles trouvé",
|
||||
"descriptionStandalone": "La gestion des autres modèles est activée, mais aucun des dossiers de modèles configurés n’existe sur le disque. Ajoutez les chemins de dossiers ci-dessous à settings.json, puis redémarrez LoRA Manager.",
|
||||
"hintStandalone": "Seules les clés de dossiers listées ci-dessus sont analysées ; les clés inutiles peuvent être omises.",
|
||||
"descriptionStandalone": "La gestion des autres modèles est activée, mais aucun dossier d’autres modèles n’a été trouvé. Ajoutez vos dossiers de modèles dans Paramètres → Chemins de modèles, puis redémarrez LoRA Manager.",
|
||||
"hintStandalone": "Seuls les types de modèles activés sont analysés ; activez les types dont vous avez besoin dans Bibliothèque → Racines par défaut.",
|
||||
"descriptionComfyUI": "La gestion des autres modèles est activée, mais aucun des dossiers de modèles configurés n’existe sur le disque. Ajoutez les dossiers de modèles correspondants à vos chemins de modèles ComfyUI, puis rechargez cette page.",
|
||||
"hintComfyUI": "Les autres modèles sont lus depuis les dossiers vae, upscale_models, text_encoders, clip_vision et controlnet de ComfyUI.",
|
||||
"openSettings": "Ouvrir les paramètres"
|
||||
"openSettings": "Ouvrir les paramètres",
|
||||
"openModelPaths": "Configurer les dossiers de modèles",
|
||||
"openSettingsFolder": "Ouvrir le dossier des paramètres"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
@@ -1558,6 +1632,11 @@
|
||||
"tip": "Besoin de procéder par étapes ? Passez en mode groupé, sélectionnez les modèles souhaités puis utilisez \"Vérifier les mises à jour pour la sélection\".",
|
||||
"action": "Tout vérifier"
|
||||
},
|
||||
"filenameTemplateConfirm": {
|
||||
"titleApply": "Appliquer le modèle de nom de fichier à la bibliothèque ?",
|
||||
"titleRevert": "Restaurer les noms de fichier d'origine ?",
|
||||
"revertButton": "Restaurer les noms de fichier d'origine"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Ajouter des tags à plusieurs modèles",
|
||||
"description": "Ajouter des tags à",
|
||||
@@ -2267,6 +2346,9 @@
|
||||
"autoOrganizeSuccess": "Auto-organisation terminée avec succès pour {count} {type}",
|
||||
"autoOrganizePartialSuccess": "Auto-organisation terminée avec {success} déplacés, {failures} échecs sur {total} modèles",
|
||||
"autoOrganizeFailed": "Échec de l'auto-organisation : {error}",
|
||||
"filenameTemplateSuccess": "Modèle de nom de fichier appliqué avec succès pour {count} {type}",
|
||||
"filenameTemplatePartialSuccess": "Modèle de nom de fichier appliqué avec {success} renommés, {failures} échecs sur {total} modèles",
|
||||
"filenameTemplateFailed": "Échec de l'application du modèle de nom de fichier : {error}",
|
||||
"noModelsSelected": "Aucun modèle sélectionné"
|
||||
},
|
||||
"recipes": {
|
||||
@@ -2433,6 +2515,8 @@
|
||||
"mappingSaveFailed": "Échec de la sauvegarde des mappages de modèle de base : {message}",
|
||||
"downloadTemplatesUpdated": "Modèles de chemin de téléchargement mis à jour",
|
||||
"downloadTemplatesFailed": "Échec de la sauvegarde des modèles de chemin de téléchargement : {message}",
|
||||
"filenameTemplatesUpdated": "Modèles de nom de fichier mis à jour",
|
||||
"filenameTemplatesFailed": "Échec de la sauvegarde des modèles de nom de fichier : {message}",
|
||||
"recipesPathUpdated": "Chemin de stockage des Recipes mis à jour",
|
||||
"recipesPathSaveFailed": "Échec de la mise à jour du chemin de stockage des Recipes : {message}",
|
||||
"settingsUpdated": "Paramètres mis à jour : {setting}",
|
||||
@@ -2698,6 +2782,11 @@
|
||||
"content": "Analysez et gérez les fichiers VAE, Upscaler, Text Encoder, CLIP Vision et ControlNet, et téléchargez-les depuis CivitAI, le tout depuis une page dédiée.",
|
||||
"enable": "Activer les autres modèles",
|
||||
"openSettings": "Ouvrir les paramètres"
|
||||
},
|
||||
"pager": {
|
||||
"previous": "Message précédent",
|
||||
"next": "Message suivant",
|
||||
"position": "Message {current} sur {total}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+93
-4
@@ -325,6 +325,12 @@
|
||||
"civitaiApiKeyConfigured": "מוגדר",
|
||||
"civitaiApiKeyNotConfigured": "לא מוגדר",
|
||||
"civitaiApiKeySet": "הגדר",
|
||||
"huggingfaceApiKey": "אסימון גישה של Hugging Face",
|
||||
"huggingfaceApiKeyPlaceholder": "הזן את אסימון הגישה שלך מ-Hugging Face",
|
||||
"huggingfaceApiKeyHelp": "נדרש להורדה ממאגרי Hugging Face מוגבלים (gated) או פרטיים. צור אסימון לקריאה-בלבד בכתובת huggingface.co/settings/tokens ואשר תחילה את תנאי המאגר בעמוד שלו.",
|
||||
"huggingfaceApiKeyConfigured": "מוגדר",
|
||||
"huggingfaceApiKeyNotConfigured": "לא מוגדר",
|
||||
"huggingfaceApiKeySet": "הגדר",
|
||||
"civitaiHost": {
|
||||
"label": "מארח CivitAI",
|
||||
"help": "בחר איזה אתר של CivitAI ייפתח בעת שימוש בקישורי \"View on CivitAI\".",
|
||||
@@ -382,7 +388,9 @@
|
||||
"nav": {
|
||||
"general": "כללי",
|
||||
"interface": "ממשק",
|
||||
"library": "ספרייה"
|
||||
"library": "ספרייה",
|
||||
"organization": "ארגון",
|
||||
"modelPaths": "נתיבי מודלים"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "חיפוש בהגדרות...",
|
||||
@@ -583,6 +591,46 @@
|
||||
"checkpointUnetOverlapInline": "הנתיב הזה כבר נמצא בשימוש עבור סוג מודל אחר. יש להשתמש בתיקיות נפרדות עבור checkpoints ומודלי דיפוזיה."
|
||||
}
|
||||
},
|
||||
"modelPaths": {
|
||||
"title": "נתיבי ספריית המודלים",
|
||||
"description": "תיקיות שורש ש-LoRA Manager סורק לאיתור המודלים שלך. אלו מיקומי המודלים הראשיים הנקראים מ-settings.json במצב עצמאי.",
|
||||
"restartRequired": "נדרש אתחול כדי שהשינוי ייכנס לתוקף",
|
||||
"coreTypes": "סוגי מודלים מרכזיים",
|
||||
"otherTypes": "סוגי מודלים אחרים",
|
||||
"otherTypesDisabledHint": "לא מופעלים סוגי מודלים אחרים. הפעל למעלה את הסוגים הדרושים לך כדי להגדיר את התיקיות שלהם.",
|
||||
"saveSuccessRestart": "נתיבי ספריית המודלים עודכנו. נדרשת הפעלה מחדש כדי להחיל את השינויים.",
|
||||
"pendingRestartNotice": "שינויי הנתיבים נשמרו. הפעל מחדש את LoRA Manager כדי שייכנסו לתוקף.",
|
||||
"pendingRestartBannerTitle": "נדרשת הפעלה מחדש כדי להחיל את שינויי הנתיבים",
|
||||
"pendingRestartBannerMessage": "נתיבי ספריית המודלים עודכנו. הפעל מחדש את שרת LoRA Manager כדי לסרוק את התיקיות החדשות.",
|
||||
"folderKeys": {
|
||||
"loras": "נתיבי LoRA",
|
||||
"checkpoints": "נתיבי Checkpoint",
|
||||
"unet": "נתיבי מודל דיפוזיה",
|
||||
"embeddings": "נתיבי Embedding",
|
||||
"vae": "נתיבי VAE",
|
||||
"upscale_models": "נתיבי Upscaler",
|
||||
"text_encoders": "נתיבי Text Encoder",
|
||||
"clip": "נתיבי CLIP (ישן)",
|
||||
"clip_vision": "נתיבי CLIP Vision",
|
||||
"controlnet": "נתיבי ControlNet"
|
||||
}
|
||||
},
|
||||
"directoryPicker": {
|
||||
"title": "עיון בתיקיות",
|
||||
"selectFolder": "בחר תיקייה זו",
|
||||
"goUp": "למעלה",
|
||||
"pathPlaceholder": "הזן נתיב...",
|
||||
"go": "עבור",
|
||||
"emptyFolder": "אין תתי-תיקיות",
|
||||
"loadError": "טעינת התיקייה נכשלה"
|
||||
},
|
||||
"pathValidation": {
|
||||
"valid": "הנתיב תקין",
|
||||
"pathNotFound": "הנתיב לא קיים",
|
||||
"notADirectory": "לא תיקייה",
|
||||
"notReadable": "הנתיב לא ניתן לקריאה",
|
||||
"notWritable": "הנתיב לא ניתן לכתיבה"
|
||||
},
|
||||
"priorityTags": {
|
||||
"title": "תגיות עדיפות",
|
||||
"description": "התאם את סדר העדיפות של התגיות עבור כל סוג מודל (לדוגמה: character, concept, style(toon|toon_style))",
|
||||
@@ -639,6 +687,22 @@
|
||||
"validTemplate": "תבנית תקינה"
|
||||
}
|
||||
},
|
||||
"filenameTemplates": {
|
||||
"title": "תבניות שמות קבצים",
|
||||
"help": "הגדר שמות קבצים למודלים שהורדו לפי סוג מודל. השאר ריק כדי לשמור על שמות הקבצים המקוריים בעת ההורדה; החלת תבנית ריקה משחזרת את שמות הקבצים המקוריים המתועדים של מודלים ששונה שמם בעבר. שם הקובץ המקורי תמיד נשמר במטא-נתונים של המודל.",
|
||||
"availablePlaceholders": "מצייני מקום זמינים:",
|
||||
"templatePlaceholder": "הזן תבנית שם קובץ (למשל, {base_model}-{model_name}-{version_name})",
|
||||
"applyButton": "החל על הספרייה כעת",
|
||||
"applyHelp": "משנה את שמות כל הקבצים הקיימים מסוג מודל זה בהתאם לתבנית; עם תבנית ריקה, משחזר במקום זאת את שמות הקבצים המקוריים המתועדים. אזהרה: שינוי שם משנה את הנתיב היחסי שרואים הטוענים של ComfyUI, ולכן workflows קיימים המפנים לשם הקובץ הישן עשויים לדרוש עדכון. שם הקובץ המקורי נשמר במטא-נתונים של כל מודל.",
|
||||
"confirmApply": "לשנות את שמות כל הקבצים הקיימים מסוג מודל זה בהתאם לתבנית שם הקובץ? פעולה זו משנה את הנתיב היחסי שרואים הטוענים של ComfyUI. שם הקובץ המקורי נשמר במטא-נתונים של כל מודל.",
|
||||
"confirmRevert": "לשחזר את שמות הקבצים המקוריים המתועדים של כל הקבצים ששונה שמם בעבר מסוג מודל זה? פעולה זו משנה את הנתיב היחסי שרואים הטוענים של ComfyUI. קבצים ללא שם קובץ מקורי מתועד ידולגו.",
|
||||
"validation": {
|
||||
"restoreOriginal": "תקין (תבנית ריקה משחזרת שמות קבצים מקוריים)",
|
||||
"invalidChars": "זוהו תווים לא חוקיים (שם קובץ אינו יכול להכיל / \\ < > : \" | ? *)",
|
||||
"invalidPlaceholder": "מציין מקום לא חוקי: {placeholder}",
|
||||
"validTemplate": "תבנית תקינה"
|
||||
}
|
||||
},
|
||||
"exampleImages": {
|
||||
"downloadLocation": "מיקום הורדה",
|
||||
"downloadLocationPlaceholder": "הזן נתיב תיקייה לתמונות דוגמה",
|
||||
@@ -871,6 +935,14 @@
|
||||
"complete": "ארגון אוטומטי הושלם",
|
||||
"error": "שגיאה: {error}"
|
||||
},
|
||||
"filenameTemplateProgress": {
|
||||
"initializing": "מאתחל החלת תבנית שם קובץ...",
|
||||
"starting": "מחיל תבנית שם קובץ על {type}...",
|
||||
"processing": "מעבד ({processed}/{total}) - {success} שונו שמותם, {skipped} דולגו, {failures} נכשלו",
|
||||
"completed": "הושלם: {success} שונו שמותם, {skipped} דולגו, {failures} נכשלו",
|
||||
"complete": "החלת תבנית שם הקובץ הושלמה",
|
||||
"error": "שגיאה: {error}"
|
||||
},
|
||||
"enrichHfAgent": "העשרת מטא-נתונים ב-AI"
|
||||
},
|
||||
"contextMenu": {
|
||||
@@ -1241,11 +1313,13 @@
|
||||
},
|
||||
"noPaths": {
|
||||
"title": "לא נמצאו תיקיות של מודלים אחרים",
|
||||
"descriptionStandalone": "ניהול המודלים האחרים פועל, אך אף אחת מתיקיות המודלים המוגדרות אינה קיימת בדיסק. הוסף את נתיבי התיקיות שלמטה ל-settings.json והפעל מחדש את LoRA Manager.",
|
||||
"hintStandalone": "רק מפתחות התיקיות המפורטים למעלה נסרקים; ניתן להשמיט מפתחות שאינך צריך.",
|
||||
"descriptionStandalone": "ניהול המודלים האחרים פועל, אך לא נמצאו תיקיות של מודלים אחרים. הוסף את תיקיות המודלים שלך תחת הגדרות > נתיבי מודלים, ולאחר מכן הפעל מחדש את LoRA Manager.",
|
||||
"hintStandalone": "נסרקים רק סוגי מודלים מופעלים; הפעל את הסוגים הדרושים לך תחת ספרייה > תיקיות ברירת מחדל.",
|
||||
"descriptionComfyUI": "ניהול המודלים האחרים פועל, אך אף אחת מתיקיות המודלים המוגדרות אינה קיימת בדיסק. הוסף את תיקיות המודלים המתאימות לנתיבי המודלים של ComfyUI וטען מחדש עמוד זה.",
|
||||
"hintComfyUI": "מודלים אחרים נקראים מתיקיות vae, upscale_models, text_encoders, clip_vision ו-controlnet של ComfyUI.",
|
||||
"openSettings": "פתח הגדרות"
|
||||
"openSettings": "פתח הגדרות",
|
||||
"openModelPaths": "הגדר תיקיות מודלים",
|
||||
"openSettingsFolder": "פתח תיקיית הגדרות"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
@@ -1558,6 +1632,11 @@
|
||||
"tip": "רוצים לחלק למנות קטנות? עברו למצב בכמות גדולה, בחרו את המודלים הדרושים ואז השתמשו ב\"בדוק עדכונים לנבחרים\".",
|
||||
"action": "בדוק הכל"
|
||||
},
|
||||
"filenameTemplateConfirm": {
|
||||
"titleApply": "להחיל תבנית שם קובץ על הספרייה?",
|
||||
"titleRevert": "לשחזר שמות קבצים מקוריים?",
|
||||
"revertButton": "שחזר שמות קבצים מקוריים"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "הוסף תגיות למספר מודלים",
|
||||
"description": "הוסף תגיות ל-",
|
||||
@@ -2267,6 +2346,9 @@
|
||||
"autoOrganizeSuccess": "הארגון האוטומטי הושלם בהצלחה עבור {count} {type}",
|
||||
"autoOrganizePartialSuccess": "הארגון האוטומטי הושלם עם {success} שהועברו, {failures} שנכשלו מתוך {total} מודלים",
|
||||
"autoOrganizeFailed": "הארגון האוטומטי נכשל: {error}",
|
||||
"filenameTemplateSuccess": "תבנית שם הקובץ הוחלה בהצלחה עבור {count} {type}",
|
||||
"filenameTemplatePartialSuccess": "החלת תבנית שם הקובץ הושלמה עם {success} ששונה שמם, {failures} שנכשלו מתוך {total} מודלים",
|
||||
"filenameTemplateFailed": "החלת תבנית שם הקובץ נכשלה: {error}",
|
||||
"noModelsSelected": "לא נבחרו מודלים"
|
||||
},
|
||||
"recipes": {
|
||||
@@ -2433,6 +2515,8 @@
|
||||
"mappingSaveFailed": "שמירת מיפויי מודל בסיס נכשלה: {message}",
|
||||
"downloadTemplatesUpdated": "תבניות נתיב הורדה עודכנו",
|
||||
"downloadTemplatesFailed": "שמירת תבניות נתיב הורדה נכשלה: {message}",
|
||||
"filenameTemplatesUpdated": "תבניות שמות הקבצים עודכנו",
|
||||
"filenameTemplatesFailed": "שמירת תבניות שמות הקבצים נכשלה: {message}",
|
||||
"recipesPathUpdated": "נתיב אחסון המתכונים עודכן",
|
||||
"recipesPathSaveFailed": "עדכון נתיב אחסון המתכונים נכשל: {message}",
|
||||
"settingsUpdated": "הגדרות עודכנו: {setting}",
|
||||
@@ -2698,6 +2782,11 @@
|
||||
"content": "סרוק ונהל קבצי VAE, Upscaler, Text Encoder, CLIP Vision ו-ControlNet, והורד אותם מ-CivitAI — מהעמוד הייעודי.",
|
||||
"enable": "הפעל מודלים אחרים",
|
||||
"openSettings": "פתח הגדרות"
|
||||
},
|
||||
"pager": {
|
||||
"previous": "הודעה קודמת",
|
||||
"next": "הודעה הבאה",
|
||||
"position": "הודעה {current} מתוך {total}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+93
-4
@@ -325,6 +325,12 @@
|
||||
"civitaiApiKeyConfigured": "設定済み",
|
||||
"civitaiApiKeyNotConfigured": "未設定",
|
||||
"civitaiApiKeySet": "設定",
|
||||
"huggingfaceApiKey": "Hugging Face アクセストークン",
|
||||
"huggingfaceApiKeyPlaceholder": "Hugging Face アクセストークンを入力してください",
|
||||
"huggingfaceApiKeyHelp": "ゲート付きまたはプライベートな Hugging Face リポジトリからダウンロードする際に必要です。huggingface.co/settings/tokens で読み取り専用トークンを作成し、先にリポジトリのページで利用条件に同意してください。",
|
||||
"huggingfaceApiKeyConfigured": "設定済み",
|
||||
"huggingfaceApiKeyNotConfigured": "未設定",
|
||||
"huggingfaceApiKeySet": "設定",
|
||||
"civitaiHost": {
|
||||
"label": "CivitAI ホスト",
|
||||
"help": "「View on CivitAI」リンクを使うときに開く CivitAI サイトを選択します。",
|
||||
@@ -382,7 +388,9 @@
|
||||
"nav": {
|
||||
"general": "一般",
|
||||
"interface": "インターフェース",
|
||||
"library": "ライブラリ"
|
||||
"library": "ライブラリ",
|
||||
"organization": "整理",
|
||||
"modelPaths": "モデルパス"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "設定を検索...",
|
||||
@@ -583,6 +591,46 @@
|
||||
"checkpointUnetOverlapInline": "このパスは別のモデルタイプですでに使用されています。Checkpoints と diffusion models には別々のフォルダを使用してください。"
|
||||
}
|
||||
},
|
||||
"modelPaths": {
|
||||
"title": "モデルライブラリパス",
|
||||
"description": "LoRA Managerがモデルをスキャンするルートフォルダーです。スタンドアロンモードでは settings.json から読み込まれる主要なモデルの場所になります。",
|
||||
"restartRequired": "変更を有効にするには再起動が必要です",
|
||||
"coreTypes": "コアモデルタイプ",
|
||||
"otherTypes": "その他のモデルタイプ",
|
||||
"otherTypesDisabledHint": "その他のモデルタイプが有効になっていません。フォルダーを設定するには、上で必要なタイプをオンにしてください。",
|
||||
"saveSuccessRestart": "モデルライブラリパスを更新しました。変更を適用するには再起動が必要です。",
|
||||
"pendingRestartNotice": "パスの変更を保存しました。変更を有効にするにはLoRA Managerを再起動してください。",
|
||||
"pendingRestartBannerTitle": "パスの変更を適用するには再起動が必要です",
|
||||
"pendingRestartBannerMessage": "モデルライブラリパスが更新されました。新しいフォルダーをスキャンするにはLoRA Managerサーバーを再起動してください。",
|
||||
"folderKeys": {
|
||||
"loras": "LoRAパス",
|
||||
"checkpoints": "Checkpointパス",
|
||||
"unet": "Diffusionモデルパス",
|
||||
"embeddings": "Embeddingパス",
|
||||
"vae": "VAEパス",
|
||||
"upscale_models": "Upscalerパス",
|
||||
"text_encoders": "Text Encoderパス",
|
||||
"clip": "CLIPパス(レガシー)",
|
||||
"clip_vision": "CLIP Visionパス",
|
||||
"controlnet": "ControlNetパス"
|
||||
}
|
||||
},
|
||||
"directoryPicker": {
|
||||
"title": "フォルダを参照",
|
||||
"selectFolder": "このフォルダを選択",
|
||||
"goUp": "上へ",
|
||||
"pathPlaceholder": "パスを入力...",
|
||||
"go": "移動",
|
||||
"emptyFolder": "サブフォルダがありません",
|
||||
"loadError": "ディレクトリの読み込みに失敗しました"
|
||||
},
|
||||
"pathValidation": {
|
||||
"valid": "パスは有効です",
|
||||
"pathNotFound": "パスが存在しません",
|
||||
"notADirectory": "ディレクトリではありません",
|
||||
"notReadable": "パスは読み取れません",
|
||||
"notWritable": "パスは書き込めません"
|
||||
},
|
||||
"priorityTags": {
|
||||
"title": "優先タグ",
|
||||
"description": "各モデルタイプのタグ優先順位をカスタマイズします (例: character, concept, style(toon|toon_style))",
|
||||
@@ -639,6 +687,22 @@
|
||||
"validTemplate": "有効なテンプレート"
|
||||
}
|
||||
},
|
||||
"filenameTemplates": {
|
||||
"title": "ファイル名テンプレート",
|
||||
"help": "ダウンロードしたモデルのファイル名をモデルタイプごとに設定します。空欄にするとダウンロード時は元のファイル名が保持され、空のテンプレートを適用すると以前にリネームされたモデルの記録済みの元のファイル名が復元されます。元のファイル名は常にモデルのメタデータに保持されます。",
|
||||
"availablePlaceholders": "利用可能なプレースホルダー:",
|
||||
"templatePlaceholder": "ファイル名テンプレートを入力(例:{base_model}-{model_name}-{version_name})",
|
||||
"applyButton": "ライブラリに今すぐ適用",
|
||||
"applyHelp": "このモデルタイプの既存のすべてのファイルをテンプレートに従ってリネームします。空のテンプレートの場合は、代わりに記録済みの元のファイル名を復元します。警告:リネームするとComfyUIローダーから見える相対パスが変わるため、古いファイル名を参照する既存のワークフローは更新が必要になる場合があります。元のファイル名は各モデルのメタデータに保持されます。",
|
||||
"confirmApply": "このモデルタイプの既存のすべてのファイルをファイル名テンプレートに従ってリネームしますか?ComfyUIローダーから見える相対パスが変わります。元のファイル名は各モデルのメタデータに保持されます。",
|
||||
"confirmRevert": "このモデルタイプの以前にリネームされたすべてのファイルについて、記録済みの元のファイル名を復元しますか?ComfyUIローダーから見える相対パスが変わります。記録済みの元のファイル名がないファイルはスキップされます。",
|
||||
"validation": {
|
||||
"restoreOriginal": "有効(空のテンプレートは元のファイル名を復元)",
|
||||
"invalidChars": "無効な文字が検出されました(ファイル名に / \\ < > : \" | ? * は使用できません)",
|
||||
"invalidPlaceholder": "無効なプレースホルダー:{placeholder}",
|
||||
"validTemplate": "有効なテンプレート"
|
||||
}
|
||||
},
|
||||
"exampleImages": {
|
||||
"downloadLocation": "ダウンロード場所",
|
||||
"downloadLocationPlaceholder": "例画像のフォルダパスを入力",
|
||||
@@ -871,6 +935,14 @@
|
||||
"complete": "自動整理が完了しました",
|
||||
"error": "エラー:{error}"
|
||||
},
|
||||
"filenameTemplateProgress": {
|
||||
"initializing": "ファイル名テンプレートの適用を初期化中...",
|
||||
"starting": "{type}にファイル名テンプレートを適用中...",
|
||||
"processing": "処理中({processed}/{total})- {success} リネーム、{skipped} スキップ、{failures} 失敗",
|
||||
"completed": "完了:{success} リネーム、{skipped} スキップ、{failures} 失敗",
|
||||
"complete": "ファイル名テンプレートの適用が完了しました",
|
||||
"error": "エラー:{error}"
|
||||
},
|
||||
"enrichHfAgent": "メタデータをAIで補完"
|
||||
},
|
||||
"contextMenu": {
|
||||
@@ -1241,11 +1313,13 @@
|
||||
},
|
||||
"noPaths": {
|
||||
"title": "その他のモデルのフォルダーが見つかりません",
|
||||
"descriptionStandalone": "その他のモデル管理はオンですが、設定されたモデルフォルダーがディスク上に存在しません。以下のフォルダーパスをsettings.jsonに追加し、LoRA Managerを再起動してください。",
|
||||
"hintStandalone": "スキャンされるのは上記のフォルダーキーのみです。不要なキーは省略できます。",
|
||||
"descriptionStandalone": "その他のモデル管理はオンですが、その他のモデルフォルダーが見つかりませんでした。「設定 > モデルパス」でモデルフォルダーを追加し、LoRA Managerを再起動してください。",
|
||||
"hintStandalone": "有効になっているモデルタイプのみがスキャンされます。必要なタイプは「ライブラリ > デフォルトルート」で有効にしてください。",
|
||||
"descriptionComfyUI": "その他のモデル管理はオンですが、設定されたモデルフォルダーがディスク上に存在しません。該当するモデルフォルダーをComfyUIのモデルパスに追加し、このページを再読み込みしてください。",
|
||||
"hintComfyUI": "その他のモデルは、ComfyUIのvae、upscale_models、text_encoders、clip_vision、controlnetフォルダーから読み込まれます。",
|
||||
"openSettings": "設定を開く"
|
||||
"openSettings": "設定を開く",
|
||||
"openModelPaths": "モデルフォルダーを設定",
|
||||
"openSettingsFolder": "設定フォルダーを開く"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
@@ -1558,6 +1632,11 @@
|
||||
"tip": "少しずつ確認したい場合は一括モードに切り替え、必要なモデルを選んで「選択項目の更新を確認」を使ってください。",
|
||||
"action": "すべて確認"
|
||||
},
|
||||
"filenameTemplateConfirm": {
|
||||
"titleApply": "ファイル名テンプレートをライブラリに適用しますか?",
|
||||
"titleRevert": "元のファイル名を復元しますか?",
|
||||
"revertButton": "元のファイル名を復元"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "複数モデルにタグを追加",
|
||||
"description": "タグを追加するモデル:",
|
||||
@@ -2267,6 +2346,9 @@
|
||||
"autoOrganizeSuccess": "{count} {type} の自動整理が正常に完了しました",
|
||||
"autoOrganizePartialSuccess": "自動整理が完了しました:{total} モデル中 {success} 移動、{failures} 失敗",
|
||||
"autoOrganizeFailed": "自動整理に失敗しました:{error}",
|
||||
"filenameTemplateSuccess": "{count} 件の{type}にファイル名テンプレートを正常に適用しました",
|
||||
"filenameTemplatePartialSuccess": "ファイル名テンプレートを適用しました:{total} 件中 {success} 件をリネーム、{failures} 件失敗",
|
||||
"filenameTemplateFailed": "ファイル名テンプレートの適用に失敗しました:{error}",
|
||||
"noModelsSelected": "モデルが選択されていません"
|
||||
},
|
||||
"recipes": {
|
||||
@@ -2433,6 +2515,8 @@
|
||||
"mappingSaveFailed": "ベースモデルマッピングの保存に失敗しました:{message}",
|
||||
"downloadTemplatesUpdated": "ダウンロードパステンプレートが更新されました",
|
||||
"downloadTemplatesFailed": "ダウンロードパステンプレートの保存に失敗しました:{message}",
|
||||
"filenameTemplatesUpdated": "ファイル名テンプレートを更新しました",
|
||||
"filenameTemplatesFailed": "ファイル名テンプレートの保存に失敗しました:{message}",
|
||||
"recipesPathUpdated": "レシピ保存先を更新しました",
|
||||
"recipesPathSaveFailed": "レシピ保存先の更新に失敗しました: {message}",
|
||||
"settingsUpdated": "設定が更新されました:{setting}",
|
||||
@@ -2698,6 +2782,11 @@
|
||||
"content": "専用ページで VAE、Upscaler、Text Encoder、CLIP Vision、ControlNet の各ファイルをスキャン・管理し、CivitAI からダウンロードできます。",
|
||||
"enable": "その他のモデルを有効にする",
|
||||
"openSettings": "設定を開く"
|
||||
},
|
||||
"pager": {
|
||||
"previous": "前の通知",
|
||||
"next": "次の通知",
|
||||
"position": "{total} 件中 {current} 件目の通知"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+93
-4
@@ -325,6 +325,12 @@
|
||||
"civitaiApiKeyConfigured": "설정됨",
|
||||
"civitaiApiKeyNotConfigured": "설정되지 않음",
|
||||
"civitaiApiKeySet": "설정",
|
||||
"huggingfaceApiKey": "Hugging Face 액세스 토큰",
|
||||
"huggingfaceApiKeyPlaceholder": "Hugging Face 액세스 토큰을 입력하세요",
|
||||
"huggingfaceApiKeyHelp": "게이트가 설정된 또는 비공개 Hugging Face 저장소에서 다운로드할 때 필요합니다. huggingface.co/settings/tokens에서 읽기 전용 토큰을 만들고, 먼저 저장소 페이지에서 이용 약관에 동의하세요.",
|
||||
"huggingfaceApiKeyConfigured": "설정됨",
|
||||
"huggingfaceApiKeyNotConfigured": "설정되지 않음",
|
||||
"huggingfaceApiKeySet": "설정",
|
||||
"civitaiHost": {
|
||||
"label": "CivitAI 호스트",
|
||||
"help": "\"View on CivitAI\" 링크를 사용할 때 어떤 CivitAI 사이트를 열지 선택합니다.",
|
||||
@@ -382,7 +388,9 @@
|
||||
"nav": {
|
||||
"general": "일반",
|
||||
"interface": "인터페이스",
|
||||
"library": "라이브러리"
|
||||
"library": "라이브러리",
|
||||
"organization": "정리",
|
||||
"modelPaths": "모델 경로"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "설정 검색...",
|
||||
@@ -583,6 +591,46 @@
|
||||
"checkpointUnetOverlapInline": "이 경로는 다른 모델 유형에 이미 사용 중입니다. checkpoints와 diffusion models에 별도의 폴더를 사용하세요."
|
||||
}
|
||||
},
|
||||
"modelPaths": {
|
||||
"title": "모델 라이브러리 경로",
|
||||
"description": "LoRA Manager가 모델을 스캔하는 루트 폴더입니다. 독립 실행 모드에서는 settings.json에서 읽어오는 기본 모델 위치입니다.",
|
||||
"restartRequired": "변경 사항을 적용하려면 재시작이 필요합니다",
|
||||
"coreTypes": "핵심 모델 유형",
|
||||
"otherTypes": "기타 모델 유형",
|
||||
"otherTypesDisabledHint": "활성화된 기타 모델 유형이 없습니다. 위에서 필요한 유형을 켜면 해당 폴더를 구성할 수 있습니다.",
|
||||
"saveSuccessRestart": "모델 라이브러리 경로가 업데이트되었습니다. 변경 사항을 적용하려면 재시작이 필요합니다.",
|
||||
"pendingRestartNotice": "경로 변경 사항이 저장되었습니다. 적용하려면 LoRA Manager를 재시작하세요.",
|
||||
"pendingRestartBannerTitle": "경로 변경 사항을 적용하려면 재시작이 필요합니다",
|
||||
"pendingRestartBannerMessage": "모델 라이브러리 경로가 업데이트되었습니다. 새 폴더를 스캔하려면 LoRA Manager 서버를 재시작하세요.",
|
||||
"folderKeys": {
|
||||
"loras": "LoRA 경로",
|
||||
"checkpoints": "Checkpoint 경로",
|
||||
"unet": "Diffusion Model 경로",
|
||||
"embeddings": "Embedding 경로",
|
||||
"vae": "VAE 경로",
|
||||
"upscale_models": "Upscaler 경로",
|
||||
"text_encoders": "Text Encoder 경로",
|
||||
"clip": "CLIP 경로 (레거시)",
|
||||
"clip_vision": "CLIP Vision 경로",
|
||||
"controlnet": "ControlNet 경로"
|
||||
}
|
||||
},
|
||||
"directoryPicker": {
|
||||
"title": "폴더 찾아보기",
|
||||
"selectFolder": "이 폴더 선택",
|
||||
"goUp": "위로",
|
||||
"pathPlaceholder": "경로 입력...",
|
||||
"go": "이동",
|
||||
"emptyFolder": "하위 폴더 없음",
|
||||
"loadError": "디렉터리를 불러오지 못했습니다"
|
||||
},
|
||||
"pathValidation": {
|
||||
"valid": "유효한 경로입니다",
|
||||
"pathNotFound": "경로가 존재하지 않습니다",
|
||||
"notADirectory": "디렉터리가 아닙니다",
|
||||
"notReadable": "경로를 읽을 수 없습니다",
|
||||
"notWritable": "경로에 쓸 수 없습니다"
|
||||
},
|
||||
"priorityTags": {
|
||||
"title": "우선순위 태그",
|
||||
"description": "모델 유형별 태그 우선순위를 사용자 지정합니다(예: character, concept, style(toon|toon_style)).",
|
||||
@@ -639,6 +687,22 @@
|
||||
"validTemplate": "유효한 템플릿"
|
||||
}
|
||||
},
|
||||
"filenameTemplates": {
|
||||
"title": "파일명 템플릿",
|
||||
"help": "모델 유형별로 다운로드되는 모델의 파일명을 구성합니다. 비워 두면 다운로드 시 원본 파일명을 유지하고, 빈 템플릿을 적용하면 이전에 이름이 변경된 모델의 기록된 원본 파일명이 복원됩니다. 원본 파일명은 항상 모델의 메타데이터에 보존됩니다.",
|
||||
"availablePlaceholders": "사용 가능한 플레이스홀더:",
|
||||
"templatePlaceholder": "파일명 템플릿 입력 (예: {base_model}-{model_name}-{version_name})",
|
||||
"applyButton": "지금 라이브러리에 적용",
|
||||
"applyHelp": "이 모델 유형의 기존 파일을 모두 템플릿에 따라 이름 변경합니다. 빈 템플릿이면 기록된 원본 파일명을 대신 복원합니다. 경고: 이름을 변경하면 ComfyUI 로더에서 보이는 상대 경로가 바뀌므로 이전 파일명을 참조하는 기존 워크플로를 업데이트해야 할 수 있습니다. 원본 파일명은 각 모델의 메타데이터에 보존됩니다.",
|
||||
"confirmApply": "이 모델 유형의 기존 파일을 모두 파일명 템플릿에 따라 이름 변경하시겠습니까? ComfyUI 로더에서 보이는 상대 경로가 변경됩니다. 원본 파일명은 각 모델의 메타데이터에 보존됩니다.",
|
||||
"confirmRevert": "이 모델 유형에서 이전에 이름이 변경된 모든 파일의 기록된 원본 파일명을 복원하시겠습니까? ComfyUI 로더에서 보이는 상대 경로가 변경됩니다. 기록된 원본 파일명이 없는 파일은 건너뜁니다.",
|
||||
"validation": {
|
||||
"restoreOriginal": "유효함 (빈 템플릿은 원본 파일명을 복원합니다)",
|
||||
"invalidChars": "잘못된 문자가 감지됨 (파일명에는 / \\ < > : \" | ? * 문자를 사용할 수 없습니다)",
|
||||
"invalidPlaceholder": "잘못된 플레이스홀더: {placeholder}",
|
||||
"validTemplate": "유효한 템플릿"
|
||||
}
|
||||
},
|
||||
"exampleImages": {
|
||||
"downloadLocation": "다운로드 위치",
|
||||
"downloadLocationPlaceholder": "예시 이미지 폴더 경로를 입력하세요",
|
||||
@@ -871,6 +935,14 @@
|
||||
"complete": "자동 정리 완료",
|
||||
"error": "오류: {error}"
|
||||
},
|
||||
"filenameTemplateProgress": {
|
||||
"initializing": "파일명 템플릿 적용 초기화 중...",
|
||||
"starting": "{type}에 파일명 템플릿 적용 중...",
|
||||
"processing": "처리 중 ({processed}/{total}) - {success}개 이름 변경, {skipped}개 건너뜀, {failures}개 실패",
|
||||
"completed": "완료: {success}개 이름 변경, {skipped}개 건너뜀, {failures}개 실패",
|
||||
"complete": "파일명 템플릿 적용 완료",
|
||||
"error": "오류: {error}"
|
||||
},
|
||||
"enrichHfAgent": "AI로 메타데이터 보강"
|
||||
},
|
||||
"contextMenu": {
|
||||
@@ -1241,11 +1313,13 @@
|
||||
},
|
||||
"noPaths": {
|
||||
"title": "기타 모델 폴더를 찾을 수 없습니다",
|
||||
"descriptionStandalone": "기타 모델 관리가 켜져 있지만, 설정된 모델 폴더가 디스크에 존재하지 않습니다. 아래 폴더 경로를 settings.json에 추가한 뒤 LoRA Manager를 재시작하세요.",
|
||||
"hintStandalone": "위에 나열된 폴더 키만 스캔됩니다. 필요 없는 키는 생략할 수 있습니다.",
|
||||
"descriptionStandalone": "기타 모델 관리가 켜져 있지만, 기타 모델 폴더를 찾을 수 없습니다. 설정 → 모델 경로에서 모델 폴더를 추가한 뒤 LoRA Manager를 재시작하세요.",
|
||||
"hintStandalone": "활성화된 모델 유형만 스캔됩니다. 라이브러리 → 기본 루트에서 필요한 유형을 활성화하세요.",
|
||||
"descriptionComfyUI": "기타 모델 관리가 켜져 있지만, 설정된 모델 폴더가 디스크에 존재하지 않습니다. 해당 모델 폴더를 ComfyUI 모델 경로에 추가한 뒤 이 페이지를 새로 고침하세요.",
|
||||
"hintComfyUI": "기타 모델은 ComfyUI의 vae, upscale_models, text_encoders, clip_vision, controlnet 폴더에서 읽어옵니다.",
|
||||
"openSettings": "설정 열기"
|
||||
"openSettings": "설정 열기",
|
||||
"openModelPaths": "모델 폴더 구성",
|
||||
"openSettingsFolder": "설정 폴더 열기"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
@@ -1558,6 +1632,11 @@
|
||||
"tip": "나눠서 진행하고 싶다면 일괄 모드로 전환해 필요한 모델만 선택한 뒤 \"선택 항목 업데이트 확인\"을 사용하세요.",
|
||||
"action": "전체 확인"
|
||||
},
|
||||
"filenameTemplateConfirm": {
|
||||
"titleApply": "라이브러리에 파일명 템플릿을 적용하시겠습니까?",
|
||||
"titleRevert": "원본 파일명을 복원하시겠습니까?",
|
||||
"revertButton": "원본 파일명 복원"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "여러 모델에 태그 추가",
|
||||
"description": "다음에 태그를 추가합니다:",
|
||||
@@ -2267,6 +2346,9 @@
|
||||
"autoOrganizeSuccess": "{count}개의 {type}에 대해 자동 정리가 성공적으로 완료되었습니다",
|
||||
"autoOrganizePartialSuccess": "자동 정리 완료: 전체 {total}개 중 {success}개 이동, {failures}개 실패",
|
||||
"autoOrganizeFailed": "자동 정리 실패: {error}",
|
||||
"filenameTemplateSuccess": "{count}개의 {type}에 파일명 템플릿이 성공적으로 적용되었습니다",
|
||||
"filenameTemplatePartialSuccess": "파일명 템플릿 적용 완료: 전체 {total}개 중 {success}개 이름 변경, {failures}개 실패",
|
||||
"filenameTemplateFailed": "파일명 템플릿 적용 실패: {error}",
|
||||
"noModelsSelected": "선택된 모델이 없습니다"
|
||||
},
|
||||
"recipes": {
|
||||
@@ -2433,6 +2515,8 @@
|
||||
"mappingSaveFailed": "베이스 모델 매핑 저장 실패: {message}",
|
||||
"downloadTemplatesUpdated": "다운로드 경로 템플릿이 업데이트되었습니다",
|
||||
"downloadTemplatesFailed": "다운로드 경로 템플릿 저장 실패: {message}",
|
||||
"filenameTemplatesUpdated": "파일명 템플릿이 업데이트되었습니다",
|
||||
"filenameTemplatesFailed": "파일명 템플릿 저장 실패: {message}",
|
||||
"recipesPathUpdated": "레시피 저장 경로가 업데이트되었습니다",
|
||||
"recipesPathSaveFailed": "레시피 저장 경로 업데이트 실패: {message}",
|
||||
"settingsUpdated": "설정 업데이트됨: {setting}",
|
||||
@@ -2698,6 +2782,11 @@
|
||||
"content": "전용 페이지에서 VAE, Upscaler, Text Encoder, CLIP Vision, ControlNet 파일을 스캔 및 관리하고 CivitAI에서 다운로드할 수 있습니다.",
|
||||
"enable": "기타 모델 활성화",
|
||||
"openSettings": "설정 열기"
|
||||
},
|
||||
"pager": {
|
||||
"previous": "이전 알림",
|
||||
"next": "다음 알림",
|
||||
"position": "전체 {total}개 중 {current}번째 알림"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+93
-4
@@ -325,6 +325,12 @@
|
||||
"civitaiApiKeyConfigured": "Настроен",
|
||||
"civitaiApiKeyNotConfigured": "Не настроен",
|
||||
"civitaiApiKeySet": "Настроить",
|
||||
"huggingfaceApiKey": "Токен доступа Hugging Face",
|
||||
"huggingfaceApiKeyPlaceholder": "Введите ваш токен доступа Hugging Face",
|
||||
"huggingfaceApiKeyHelp": "Требуется для загрузки из закрытых (gated) или приватных репозиториев Hugging Face. Создайте токен только для чтения на huggingface.co/settings/tokens и сначала примите условия репозитория на его странице.",
|
||||
"huggingfaceApiKeyConfigured": "Настроен",
|
||||
"huggingfaceApiKeyNotConfigured": "Не настроен",
|
||||
"huggingfaceApiKeySet": "Настроить",
|
||||
"civitaiHost": {
|
||||
"label": "Хост CivitAI",
|
||||
"help": "Выберите, какой сайт CivitAI будет открываться при использовании ссылок «View on CivitAI».",
|
||||
@@ -382,7 +388,9 @@
|
||||
"nav": {
|
||||
"general": "Общее",
|
||||
"interface": "Интерфейс",
|
||||
"library": "Библиотека"
|
||||
"library": "Библиотека",
|
||||
"organization": "Организация",
|
||||
"modelPaths": "Пути к моделям"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Поиск в настройках...",
|
||||
@@ -583,6 +591,46 @@
|
||||
"checkpointUnetOverlapInline": "Этот путь уже используется для другого типа модели. Используйте отдельные папки для checkpoints и diffusion models."
|
||||
}
|
||||
},
|
||||
"modelPaths": {
|
||||
"title": "Пути библиотеки моделей",
|
||||
"description": "Корневые папки, которые LoRA Manager сканирует в поисках ваших моделей. В автономном режиме это основные расположения моделей, считываемые из settings.json.",
|
||||
"restartRequired": "Требуется перезапуск, чтобы изменения вступили в силу",
|
||||
"coreTypes": "Основные типы моделей",
|
||||
"otherTypes": "Другие типы моделей",
|
||||
"otherTypesDisabledHint": "Другие типы моделей не включены. Включите нужные типы выше, чтобы настроить их папки.",
|
||||
"saveSuccessRestart": "Пути библиотеки моделей обновлены. Требуется перезапуск для применения изменений.",
|
||||
"pendingRestartNotice": "Изменения путей сохранены. Перезапустите LoRA Manager, чтобы они вступили в силу.",
|
||||
"pendingRestartBannerTitle": "Требуется перезапуск для применения изменений путей",
|
||||
"pendingRestartBannerMessage": "Пути библиотеки моделей обновлены. Перезапустите сервер LoRA Manager, чтобы просканировать новые папки.",
|
||||
"folderKeys": {
|
||||
"loras": "Пути LoRA",
|
||||
"checkpoints": "Пути Checkpoint",
|
||||
"unet": "Пути моделей диффузии",
|
||||
"embeddings": "Пути Embedding",
|
||||
"vae": "Пути VAE",
|
||||
"upscale_models": "Пути Upscaler",
|
||||
"text_encoders": "Пути Text Encoder",
|
||||
"clip": "Пути CLIP (устаревшие)",
|
||||
"clip_vision": "Пути CLIP Vision",
|
||||
"controlnet": "Пути ControlNet"
|
||||
}
|
||||
},
|
||||
"directoryPicker": {
|
||||
"title": "Обзор папок",
|
||||
"selectFolder": "Выбрать эту папку",
|
||||
"goUp": "Вверх",
|
||||
"pathPlaceholder": "Введите путь...",
|
||||
"go": "Перейти",
|
||||
"emptyFolder": "Нет подпапок",
|
||||
"loadError": "Не удалось загрузить каталог"
|
||||
},
|
||||
"pathValidation": {
|
||||
"valid": "Путь действителен",
|
||||
"pathNotFound": "Путь не существует",
|
||||
"notADirectory": "Не является каталогом",
|
||||
"notReadable": "Путь недоступен для чтения",
|
||||
"notWritable": "Путь недоступен для записи"
|
||||
},
|
||||
"priorityTags": {
|
||||
"title": "Приоритетные теги",
|
||||
"description": "Настройте порядок приоритетов тегов для каждого типа моделей (например, character, concept, style(toon|toon_style)).",
|
||||
@@ -639,6 +687,22 @@
|
||||
"validTemplate": "Действительный шаблон"
|
||||
}
|
||||
},
|
||||
"filenameTemplates": {
|
||||
"title": "Шаблоны имён файлов",
|
||||
"help": "Настройте имена файлов загружаемых моделей для каждого типа моделей. Оставьте пустым, чтобы сохранять исходные имена файлов при загрузке; применение пустого шаблона восстанавливает записанные исходные имена файлов ранее переименованных моделей. Исходное имя файла всегда сохраняется в метаданных модели.",
|
||||
"availablePlaceholders": "Доступные заполнители:",
|
||||
"templatePlaceholder": "Введите шаблон имени файла (например, {base_model}-{model_name}-{version_name})",
|
||||
"applyButton": "Применить к библиотеке сейчас",
|
||||
"applyHelp": "Переименовывает все существующие файлы этого типа моделей согласно шаблону; при пустом шаблоне вместо этого восстанавливает записанные исходные имена файлов. Предупреждение: переименование меняет относительный путь, который видят загрузчики ComfyUI, поэтому существующие workflow, ссылающиеся на старое имя файла, может потребоваться обновить. Исходное имя файла сохраняется в метаданных каждой модели.",
|
||||
"confirmApply": "Переименовать все существующие файлы этого типа моделей согласно шаблону имён файлов? Это меняет относительный путь, который видят загрузчики ComfyUI. Исходное имя файла сохраняется в метаданных каждой модели.",
|
||||
"confirmRevert": "Восстановить записанные исходные имена файлов всех ранее переименованных файлов этого типа моделей? Это меняет относительный путь, который видят загрузчики ComfyUI. Файлы без записанного исходного имени файла пропускаются.",
|
||||
"validation": {
|
||||
"restoreOriginal": "Действительный (пустой шаблон восстанавливает исходные имена файлов)",
|
||||
"invalidChars": "Обнаружены недопустимые символы (имя файла не может содержать / \\ < > : \" | ? *)",
|
||||
"invalidPlaceholder": "Недопустимый заполнитель: {placeholder}",
|
||||
"validTemplate": "Действительный шаблон"
|
||||
}
|
||||
},
|
||||
"exampleImages": {
|
||||
"downloadLocation": "Место загрузки",
|
||||
"downloadLocationPlaceholder": "Введите путь к папке для примеров изображений",
|
||||
@@ -871,6 +935,14 @@
|
||||
"complete": "Автоматическая организация завершена",
|
||||
"error": "Ошибка: {error}"
|
||||
},
|
||||
"filenameTemplateProgress": {
|
||||
"initializing": "Инициализация применения шаблона имён файлов...",
|
||||
"starting": "Применение шаблона имён файлов к {type}...",
|
||||
"processing": "Обработка ({processed}/{total}) — {success} переименовано, {skipped} пропущено, {failures} не удалось",
|
||||
"completed": "Завершено: {success} переименовано, {skipped} пропущено, {failures} не удалось",
|
||||
"complete": "Применение шаблона имён файлов завершено",
|
||||
"error": "Ошибка: {error}"
|
||||
},
|
||||
"enrichHfAgent": "Обогатить метаданные с помощью ИИ"
|
||||
},
|
||||
"contextMenu": {
|
||||
@@ -1241,11 +1313,13 @@
|
||||
},
|
||||
"noPaths": {
|
||||
"title": "Папки других моделей не найдены",
|
||||
"descriptionStandalone": "Управление другими моделями включено, но ни одна из настроенных папок моделей не существует на диске. Добавьте указанные ниже пути к папкам в settings.json и перезапустите LoRA Manager.",
|
||||
"hintStandalone": "Сканируются только перечисленные выше ключи папок; ненужные ключи можно опустить.",
|
||||
"descriptionStandalone": "Управление другими моделями включено, но папки других моделей не найдены. Добавьте свои папки моделей в разделе «Настройки → Пути к моделям», затем перезапустите LoRA Manager.",
|
||||
"hintStandalone": "Сканируются только включённые типы моделей; включите нужные типы в разделе «Библиотека → Корневые папки».",
|
||||
"descriptionComfyUI": "Управление другими моделями включено, но ни одна из настроенных папок моделей не существует на диске. Добавьте соответствующие папки моделей в пути к моделям ComfyUI и перезагрузите эту страницу.",
|
||||
"hintComfyUI": "Другие модели читаются из папок vae, upscale_models, text_encoders, clip_vision и controlnet в ComfyUI.",
|
||||
"openSettings": "Открыть настройки"
|
||||
"openSettings": "Открыть настройки",
|
||||
"openModelPaths": "Настроить папки моделей",
|
||||
"openSettingsFolder": "Открыть папку настроек"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
@@ -1558,6 +1632,11 @@
|
||||
"tip": "Хотите проверять по частям? Переключитесь в массовый режим, выберите нужные модели и используйте \"Проверить обновления для выбранных\".",
|
||||
"action": "Проверить всё"
|
||||
},
|
||||
"filenameTemplateConfirm": {
|
||||
"titleApply": "Применить шаблон имён файлов к библиотеке?",
|
||||
"titleRevert": "Восстановить исходные имена файлов?",
|
||||
"revertButton": "Восстановить исходные имена файлов"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Добавить теги к нескольким моделям",
|
||||
"description": "Добавить теги к",
|
||||
@@ -2267,6 +2346,9 @@
|
||||
"autoOrganizeSuccess": "Автоматическая организация успешно завершена для {count} {type}",
|
||||
"autoOrganizePartialSuccess": "Автоматическая организация завершена: перемещено {success}, не удалось {failures} из {total} моделей",
|
||||
"autoOrganizeFailed": "Ошибка автоматической организации: {error}",
|
||||
"filenameTemplateSuccess": "Шаблон имён файлов успешно применён для {count} {type}",
|
||||
"filenameTemplatePartialSuccess": "Шаблон имён файлов применён: переименовано {success}, не удалось {failures} из {total} моделей",
|
||||
"filenameTemplateFailed": "Не удалось применить шаблон имён файлов: {error}",
|
||||
"noModelsSelected": "Модели не выбраны"
|
||||
},
|
||||
"recipes": {
|
||||
@@ -2433,6 +2515,8 @@
|
||||
"mappingSaveFailed": "Не удалось сохранить сопоставления базовых моделей: {message}",
|
||||
"downloadTemplatesUpdated": "Шаблоны путей загрузки обновлены",
|
||||
"downloadTemplatesFailed": "Не удалось сохранить шаблоны путей загрузки: {message}",
|
||||
"filenameTemplatesUpdated": "Шаблоны имён файлов обновлены",
|
||||
"filenameTemplatesFailed": "Не удалось сохранить шаблоны имён файлов: {message}",
|
||||
"recipesPathUpdated": "Путь хранения рецептов обновлён",
|
||||
"recipesPathSaveFailed": "Не удалось обновить путь хранения рецептов: {message}",
|
||||
"settingsUpdated": "Настройки обновлены: {setting}",
|
||||
@@ -2698,6 +2782,11 @@
|
||||
"content": "Сканирование и управление файлами VAE, Upscaler, Text Encoder, CLIP Vision и ControlNet, а также загрузка их с CivitAI — всё на одной отдельной странице.",
|
||||
"enable": "Включить другие модели",
|
||||
"openSettings": "Открыть настройки"
|
||||
},
|
||||
"pager": {
|
||||
"previous": "Предыдущее уведомление",
|
||||
"next": "Следующее уведомление",
|
||||
"position": "Уведомление {current} из {total}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+93
-4
@@ -325,6 +325,12 @@
|
||||
"civitaiApiKeyConfigured": "已配置",
|
||||
"civitaiApiKeyNotConfigured": "未配置",
|
||||
"civitaiApiKeySet": "设置",
|
||||
"huggingfaceApiKey": "Hugging Face 访问令牌",
|
||||
"huggingfaceApiKeyPlaceholder": "请输入你的 Hugging Face 访问令牌",
|
||||
"huggingfaceApiKeyHelp": "从受限(gated)或私有 Hugging Face 仓库下载时需要。请在 huggingface.co/settings/tokens 创建只读令牌,并先在该仓库页面同意其条款。",
|
||||
"huggingfaceApiKeyConfigured": "已配置",
|
||||
"huggingfaceApiKeyNotConfigured": "未配置",
|
||||
"huggingfaceApiKeySet": "设置",
|
||||
"civitaiHost": {
|
||||
"label": "CivitAI 站点",
|
||||
"help": "选择使用“在 CivitAI 中查看”时默认打开的 CivitAI 站点。",
|
||||
@@ -382,7 +388,9 @@
|
||||
"nav": {
|
||||
"general": "通用",
|
||||
"interface": "界面",
|
||||
"library": "库"
|
||||
"library": "库",
|
||||
"organization": "整理",
|
||||
"modelPaths": "模型路径"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "搜索设置...",
|
||||
@@ -583,6 +591,46 @@
|
||||
"checkpointUnetOverlapInline": "此路径已被用于另一种模型类型。请为 checkpoints 和 diffusion models 使用不同的文件夹。"
|
||||
}
|
||||
},
|
||||
"modelPaths": {
|
||||
"title": "模型库路径",
|
||||
"description": "LoRA Manager 扫描模型所用的根文件夹。独立模式下,这些是从 settings.json 读取的主要模型位置。",
|
||||
"restartRequired": "需要重启才能生效",
|
||||
"coreTypes": "核心模型类型",
|
||||
"otherTypes": "其他模型类型",
|
||||
"otherTypesDisabledHint": "未启用任何其他模型类型。请在上方启用你需要的类型,然后为其配置文件夹。",
|
||||
"saveSuccessRestart": "模型库路径已更新,需要重启才能生效。",
|
||||
"pendingRestartNotice": "路径更改已保存。重启 LoRA Manager 后生效。",
|
||||
"pendingRestartBannerTitle": "需要重启以应用路径更改",
|
||||
"pendingRestartBannerMessage": "模型库路径已更新。请重启 LoRA Manager 服务器以扫描新文件夹。",
|
||||
"folderKeys": {
|
||||
"loras": "LoRA 路径",
|
||||
"checkpoints": "Checkpoint 路径",
|
||||
"unet": "Diffusion 模型路径",
|
||||
"embeddings": "Embedding 路径",
|
||||
"vae": "VAE 路径",
|
||||
"upscale_models": "Upscaler 路径",
|
||||
"text_encoders": "Text Encoder 路径",
|
||||
"clip": "CLIP 路径(旧版)",
|
||||
"clip_vision": "CLIP Vision 路径",
|
||||
"controlnet": "ControlNet 路径"
|
||||
}
|
||||
},
|
||||
"directoryPicker": {
|
||||
"title": "浏览文件夹",
|
||||
"selectFolder": "选择此文件夹",
|
||||
"goUp": "上级目录",
|
||||
"pathPlaceholder": "输入路径...",
|
||||
"go": "跳转",
|
||||
"emptyFolder": "没有子文件夹",
|
||||
"loadError": "目录加载失败"
|
||||
},
|
||||
"pathValidation": {
|
||||
"valid": "路径有效",
|
||||
"pathNotFound": "路径不存在",
|
||||
"notADirectory": "不是一个目录",
|
||||
"notReadable": "路径不可读",
|
||||
"notWritable": "路径不可写"
|
||||
},
|
||||
"priorityTags": {
|
||||
"title": "优先标签",
|
||||
"description": "为每种模型类型自定义标签优先级顺序 (例如: character, concept, style(toon|toon_style))",
|
||||
@@ -639,6 +687,22 @@
|
||||
"validTemplate": "有效模板"
|
||||
}
|
||||
},
|
||||
"filenameTemplates": {
|
||||
"title": "文件名模板",
|
||||
"help": "按模型类型配置下载模型的文件名。留空则下载时保留原始文件名;应用空模板会恢复此前被重命名模型所记录的原始文件名。原始文件名始终保留在模型的元数据中。",
|
||||
"availablePlaceholders": "可用占位符:",
|
||||
"templatePlaceholder": "输入文件名模板(如:{base_model}-{model_name}-{version_name})",
|
||||
"applyButton": "立即应用到库",
|
||||
"applyHelp": "根据模板重命名此模型类型的所有现有文件;模板为空时则恢复已记录的原始文件名。警告:重命名会改变 ComfyUI 加载器所见的相对路径,因此引用旧文件名的现有工作流可能需要更新。原始文件名保留在每个模型的元数据中。",
|
||||
"confirmApply": "要根据文件名模板重命名此模型类型的所有现有文件吗?这会改变 ComfyUI 加载器所见的相对路径。原始文件名保留在每个模型的元数据中。",
|
||||
"confirmRevert": "要恢复此模型类型中所有此前被重命名文件所记录的原始文件名吗?这会改变 ComfyUI 加载器所见的相对路径。未记录原始文件名的文件将被跳过。",
|
||||
"validation": {
|
||||
"restoreOriginal": "有效(空模板将恢复原始文件名)",
|
||||
"invalidChars": "检测到无效字符(文件名不能包含 / \\ < > : \" | ? *)",
|
||||
"invalidPlaceholder": "无效占位符:{placeholder}",
|
||||
"validTemplate": "有效模板"
|
||||
}
|
||||
},
|
||||
"exampleImages": {
|
||||
"downloadLocation": "下载位置",
|
||||
"downloadLocationPlaceholder": "输入示例图片文件夹路径",
|
||||
@@ -871,6 +935,14 @@
|
||||
"complete": "自动整理已完成",
|
||||
"error": "错误:{error}"
|
||||
},
|
||||
"filenameTemplateProgress": {
|
||||
"initializing": "正在初始化应用文件名模板...",
|
||||
"starting": "正在为 {type} 应用文件名模板...",
|
||||
"processing": "处理中({processed}/{total})- 已重命名 {success} 个,跳过 {skipped} 个,失败 {failures} 个",
|
||||
"completed": "完成:已重命名 {success} 个,跳过 {skipped} 个,失败 {failures} 个",
|
||||
"complete": "文件名模板应用完成",
|
||||
"error": "错误:{error}"
|
||||
},
|
||||
"enrichHfAgent": "AI 元数据增强"
|
||||
},
|
||||
"contextMenu": {
|
||||
@@ -1241,11 +1313,13 @@
|
||||
},
|
||||
"noPaths": {
|
||||
"title": "未找到其他模型文件夹",
|
||||
"descriptionStandalone": "其他模型管理已开启,但配置的模型文件夹在磁盘上都不存在。请将下面的文件夹路径添加到 settings.json,然后重启 LoRA Manager。",
|
||||
"hintStandalone": "只会扫描上面列出的文件夹键;不需要的键可以省略。",
|
||||
"descriptionStandalone": "其他模型管理已开启,但未找到其他模型文件夹。请在“设置 → 模型路径”中添加你的模型文件夹,然后重启 LoRA Manager。",
|
||||
"hintStandalone": "仅扫描已启用的模型类型;请在“库 → 默认根目录”中启用你需要的类型。",
|
||||
"descriptionComfyUI": "其他模型管理已开启,但配置的模型文件夹在磁盘上都不存在。请将对应的模型文件夹添加到 ComfyUI 的模型路径,然后重新加载此页面。",
|
||||
"hintComfyUI": "其他模型从 ComfyUI 的 vae、upscale_models、text_encoders、clip_vision 和 controlnet 文件夹中读取。",
|
||||
"openSettings": "打开设置"
|
||||
"openSettings": "打开设置",
|
||||
"openModelPaths": "配置模型文件夹",
|
||||
"openSettingsFolder": "打开设置文件夹"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
@@ -1558,6 +1632,11 @@
|
||||
"tip": "想分批进行?切换到批量模式,选中需要的模型,然后使用“检查所选更新”。",
|
||||
"action": "检查全部"
|
||||
},
|
||||
"filenameTemplateConfirm": {
|
||||
"titleApply": "将文件名模板应用到库?",
|
||||
"titleRevert": "恢复原始文件名?",
|
||||
"revertButton": "恢复原始文件名"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "批量添加标签",
|
||||
"description": "为多个模型添加标签",
|
||||
@@ -2267,6 +2346,9 @@
|
||||
"autoOrganizeSuccess": "自动整理已成功完成,共 {count} 个 {type}",
|
||||
"autoOrganizePartialSuccess": "自动整理完成:已移动 {success} 个,{failures} 个失败,共 {total} 个模型",
|
||||
"autoOrganizeFailed": "自动整理失败:{error}",
|
||||
"filenameTemplateSuccess": "文件名模板已成功应用,共 {count} 个 {type}",
|
||||
"filenameTemplatePartialSuccess": "文件名模板应用完成:已重命名 {success} 个,{failures} 个失败,共 {total} 个模型",
|
||||
"filenameTemplateFailed": "应用文件名模板失败:{error}",
|
||||
"noModelsSelected": "未选中模型"
|
||||
},
|
||||
"recipes": {
|
||||
@@ -2433,6 +2515,8 @@
|
||||
"mappingSaveFailed": "保存基础模型映射失败:{message}",
|
||||
"downloadTemplatesUpdated": "下载路径模板已更新",
|
||||
"downloadTemplatesFailed": "保存下载路径模板失败:{message}",
|
||||
"filenameTemplatesUpdated": "文件名模板已更新",
|
||||
"filenameTemplatesFailed": "保存文件名模板失败:{message}",
|
||||
"recipesPathUpdated": "配方存储路径已更新",
|
||||
"recipesPathSaveFailed": "更新配方存储路径失败:{message}",
|
||||
"settingsUpdated": "设置已更新:{setting}",
|
||||
@@ -2698,6 +2782,11 @@
|
||||
"content": "在一个专属页面中扫描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 文件,并从 CivitAI 下载。",
|
||||
"enable": "启用其他模型",
|
||||
"openSettings": "打开设置"
|
||||
},
|
||||
"pager": {
|
||||
"previous": "上一条通知",
|
||||
"next": "下一条通知",
|
||||
"position": "第 {current} 条通知,共 {total} 条"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+93
-4
@@ -325,6 +325,12 @@
|
||||
"civitaiApiKeyConfigured": "已設定",
|
||||
"civitaiApiKeyNotConfigured": "未設定",
|
||||
"civitaiApiKeySet": "設定",
|
||||
"huggingfaceApiKey": "Hugging Face 存取權杖",
|
||||
"huggingfaceApiKeyPlaceholder": "請輸入您的 Hugging Face 存取權杖",
|
||||
"huggingfaceApiKeyHelp": "從受限(gated)或私有 Hugging Face 倉庫下載時需要。請在 huggingface.co/settings/tokens 建立唯讀權杖,並先在該倉庫頁面同意其條款。",
|
||||
"huggingfaceApiKeyConfigured": "已設定",
|
||||
"huggingfaceApiKeyNotConfigured": "未設定",
|
||||
"huggingfaceApiKeySet": "設定",
|
||||
"civitaiHost": {
|
||||
"label": "CivitAI 站點",
|
||||
"help": "選擇使用「在 CivitAI 中查看」時預設開啟的 CivitAI 站點。",
|
||||
@@ -382,7 +388,9 @@
|
||||
"nav": {
|
||||
"general": "通用",
|
||||
"interface": "介面",
|
||||
"library": "模型庫"
|
||||
"library": "模型庫",
|
||||
"organization": "整理",
|
||||
"modelPaths": "模型路徑"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "搜尋設定...",
|
||||
@@ -583,6 +591,46 @@
|
||||
"checkpointUnetOverlapInline": "此路徑已被用於另一種模型類型。請為 checkpoints 和 diffusion models 使用不同的資料夾。"
|
||||
}
|
||||
},
|
||||
"modelPaths": {
|
||||
"title": "模型庫路徑",
|
||||
"description": "LoRA Manager 掃描您模型的根目錄資料夾。這些是獨立模式下從 settings.json 讀取的主要模型位置。",
|
||||
"restartRequired": "需要重新啟動才能生效",
|
||||
"coreTypes": "核心模型類型",
|
||||
"otherTypes": "其他模型類型",
|
||||
"otherTypesDisabledHint": "尚未啟用任何其他模型類型。請在上方開啟您需要的類型,以設定其資料夾。",
|
||||
"saveSuccessRestart": "模型庫路徑已更新,需要重新啟動才能生效。",
|
||||
"pendingRestartNotice": "路徑變更已儲存。請重新啟動 LoRA Manager 以使其生效。",
|
||||
"pendingRestartBannerTitle": "需要重新啟動才能套用路徑變更",
|
||||
"pendingRestartBannerMessage": "模型庫路徑已更新。請重新啟動 LoRA Manager 伺服器以掃描新的資料夾。",
|
||||
"folderKeys": {
|
||||
"loras": "LoRA 路徑",
|
||||
"checkpoints": "Checkpoint 路徑",
|
||||
"unet": "Diffusion 模型路徑",
|
||||
"embeddings": "Embedding 路徑",
|
||||
"vae": "VAE 路徑",
|
||||
"upscale_models": "Upscaler 路徑",
|
||||
"text_encoders": "Text Encoder 路徑",
|
||||
"clip": "CLIP 路徑(舊版)",
|
||||
"clip_vision": "CLIP Vision 路徑",
|
||||
"controlnet": "ControlNet 路徑"
|
||||
}
|
||||
},
|
||||
"directoryPicker": {
|
||||
"title": "瀏覽資料夾",
|
||||
"selectFolder": "選擇此資料夾",
|
||||
"goUp": "上一層",
|
||||
"pathPlaceholder": "輸入路徑...",
|
||||
"go": "前往",
|
||||
"emptyFolder": "沒有子資料夾",
|
||||
"loadError": "目錄載入失敗"
|
||||
},
|
||||
"pathValidation": {
|
||||
"valid": "路徑有效",
|
||||
"pathNotFound": "路徑不存在",
|
||||
"notADirectory": "不是目錄",
|
||||
"notReadable": "路徑無法讀取",
|
||||
"notWritable": "路徑無法寫入"
|
||||
},
|
||||
"priorityTags": {
|
||||
"title": "優先標籤",
|
||||
"description": "為每種模型類型自訂標籤的優先順序 (例如: character, concept, style(toon|toon_style))",
|
||||
@@ -639,6 +687,22 @@
|
||||
"validTemplate": "範本有效"
|
||||
}
|
||||
},
|
||||
"filenameTemplates": {
|
||||
"title": "檔案名稱範本",
|
||||
"help": "依模型類型設定已下載模型的檔案名稱。留空則下載時保留原始檔案名稱;套用空範本會還原先前已重新命名模型所記錄的原始檔案名稱。原始檔案名稱一律會保存在模型的中繼資料中。",
|
||||
"availablePlaceholders": "可用佔位符:",
|
||||
"templatePlaceholder": "輸入檔案名稱範本(例如:{base_model}-{model_name}-{version_name})",
|
||||
"applyButton": "立即套用至模型庫",
|
||||
"applyHelp": "依範本重新命名此模型類型的所有現有檔案;若範本為空,則改為還原已記錄的原始檔案名稱。警告:重新命名會變更 ComfyUI 載入器所見的相對路徑,因此參照舊檔案名稱的現有工作流可能需要更新。原始檔案名稱會保存在每個模型的中繼資料中。",
|
||||
"confirmApply": "要依檔案名稱範本重新命名此模型類型的所有現有檔案嗎?這會變更 ComfyUI 載入器所見的相對路徑。原始檔案名稱會保存在每個模型的中繼資料中。",
|
||||
"confirmRevert": "要將此模型類型所有先前已重新命名的檔案還原為已記錄的原始檔案名稱嗎?這會變更 ComfyUI 載入器所見的相對路徑。沒有記錄原始檔案名稱的檔案將被略過。",
|
||||
"validation": {
|
||||
"restoreOriginal": "有效(空範本會還原原始檔案名稱)",
|
||||
"invalidChars": "偵測到無效字元(檔案名稱不能包含 / \\ < > : \" | ? *)",
|
||||
"invalidPlaceholder": "無效佔位符:{placeholder}",
|
||||
"validTemplate": "範本有效"
|
||||
}
|
||||
},
|
||||
"exampleImages": {
|
||||
"downloadLocation": "下載位置",
|
||||
"downloadLocationPlaceholder": "輸入範例圖片的資料夾路徑",
|
||||
@@ -871,6 +935,14 @@
|
||||
"complete": "自動整理完成",
|
||||
"error": "錯誤:{error}"
|
||||
},
|
||||
"filenameTemplateProgress": {
|
||||
"initializing": "正在初始化檔案名稱範本套用...",
|
||||
"starting": "正在將檔案名稱範本套用至 {type}...",
|
||||
"processing": "處理中({processed}/{total})- 已重新命名 {success},已略過 {skipped},失敗 {failures}",
|
||||
"completed": "完成:已重新命名 {success},已略過 {skipped},失敗 {failures}",
|
||||
"complete": "檔案名稱範本套用完成",
|
||||
"error": "錯誤:{error}"
|
||||
},
|
||||
"enrichHfAgent": "AI 中繼資料增強"
|
||||
},
|
||||
"contextMenu": {
|
||||
@@ -1241,11 +1313,13 @@
|
||||
},
|
||||
"noPaths": {
|
||||
"title": "找不到其他模型資料夾",
|
||||
"descriptionStandalone": "其他模型管理已開啟,但設定的模型資料夾在磁碟上都不存在。請將下方的資料夾路徑加入 settings.json,然後重新啟動 LoRA Manager。",
|
||||
"hintStandalone": "只會掃描上方列出的資料夾鍵;不需要的鍵可以省略。",
|
||||
"descriptionStandalone": "其他模型管理已開啟,但找不到其他模型的資料夾。請在「設定 > 模型路徑」中加入您的模型資料夾,然後重新啟動 LoRA Manager。",
|
||||
"hintStandalone": "僅會掃描已啟用的模型類型;請在「模型庫 > 預設根目錄」中啟用您需要的類型。",
|
||||
"descriptionComfyUI": "其他模型管理已開啟,但設定的模型資料夾在磁碟上都不存在。請將對應的模型資料夾加入 ComfyUI 的模型路徑,然後重新載入此頁面。",
|
||||
"hintComfyUI": "其他模型會從 ComfyUI 的 vae、upscale_models、text_encoders、clip_vision 和 controlnet 資料夾讀取。",
|
||||
"openSettings": "開啟設定"
|
||||
"openSettings": "開啟設定",
|
||||
"openModelPaths": "設定模型資料夾",
|
||||
"openSettingsFolder": "開啟設定資料夾"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
@@ -1558,6 +1632,11 @@
|
||||
"tip": "想分批處理?切換到批次模式,選擇需要的模型,然後使用「檢查所選更新」。",
|
||||
"action": "全部檢查"
|
||||
},
|
||||
"filenameTemplateConfirm": {
|
||||
"titleApply": "要將檔案名稱範本套用至模型庫嗎?",
|
||||
"titleRevert": "要還原原始檔案名稱嗎?",
|
||||
"revertButton": "還原原始檔案名稱"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "新增標籤到多個模型",
|
||||
"description": "新增標籤到",
|
||||
@@ -2267,6 +2346,9 @@
|
||||
"autoOrganizeSuccess": "自動整理已成功完成,共 {count} 個 {type} 已整理",
|
||||
"autoOrganizePartialSuccess": "自動整理完成:已移動 {success} 個,{failures} 個失敗,共 {total} 個模型",
|
||||
"autoOrganizeFailed": "自動整理失敗:{error}",
|
||||
"filenameTemplateSuccess": "已成功為 {count} 個 {type} 套用檔案名稱範本",
|
||||
"filenameTemplatePartialSuccess": "檔案名稱範本套用完成:已重新命名 {success} 個,{failures} 個失敗,共 {total} 個模型",
|
||||
"filenameTemplateFailed": "套用檔案名稱範本失敗:{error}",
|
||||
"noModelsSelected": "未選擇任何模型"
|
||||
},
|
||||
"recipes": {
|
||||
@@ -2433,6 +2515,8 @@
|
||||
"mappingSaveFailed": "儲存基礎模型對應失敗:{message}",
|
||||
"downloadTemplatesUpdated": "下載路徑範本已更新",
|
||||
"downloadTemplatesFailed": "儲存下載路徑範本失敗:{message}",
|
||||
"filenameTemplatesUpdated": "檔案名稱範本已更新",
|
||||
"filenameTemplatesFailed": "儲存檔案名稱範本失敗:{message}",
|
||||
"recipesPathUpdated": "配方儲存路徑已更新",
|
||||
"recipesPathSaveFailed": "更新配方儲存路徑失敗:{message}",
|
||||
"settingsUpdated": "設定已更新:{setting}",
|
||||
@@ -2698,6 +2782,11 @@
|
||||
"content": "在專屬頁面中掃描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 檔案,並從 CivitAI 下載。",
|
||||
"enable": "啟用其他模型",
|
||||
"openSettings": "開啟設定"
|
||||
},
|
||||
"pager": {
|
||||
"previous": "上一則通知",
|
||||
"next": "下一則通知",
|
||||
"position": "第 {current} 則通知,共 {total} 則"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
"""Load an image and expose locally resolved generation settings."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import folder_paths # pyright: ignore[reportMissingImports]
|
||||
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
from ..utils.generation_metadata import (
|
||||
GenerationMetadata,
|
||||
MetadataError,
|
||||
extract_generation_metadata,
|
||||
finite_number,
|
||||
split_lora_tags,
|
||||
)
|
||||
from ..utils.utils import _format_model_name_for_comfyui
|
||||
from .checkpoint_loader import CheckpointLoaderLM
|
||||
|
||||
|
||||
DEFAULTS = {
|
||||
"positive": "", "negative": "", "seed": 0, "steps": 20, "cfg": 7.0,
|
||||
"sampler_name": "euler", "scheduler": "normal", "denoise": 1.0,
|
||||
}
|
||||
# An SDXL-sized starter preset inspired by ComfyUI's bottle example. These
|
||||
# values are explicitly synthetic, never presented as recovered metadata.
|
||||
EMPTY_IMAGE_DEFAULTS = {
|
||||
**DEFAULTS,
|
||||
"positive": "beautiful scenery inside a glass bottle, purple galaxy, intricate miniature landscape, highly detailed",
|
||||
"negative": "text, watermark",
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
}
|
||||
ALLOWED_OVERRIDES = set(DEFAULTS) | {"model_name", "checkpoint_name", "unet_name", "width", "height", "loras"}
|
||||
|
||||
|
||||
def parse_overrides(text: str) -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(text or "{}")
|
||||
except ValueError as exc:
|
||||
raise MetadataError(f"Invalid overrides_json: {exc}") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise MetadataError("overrides_json must be an object")
|
||||
unknown = set(value) - ALLOWED_OVERRIDES
|
||||
if unknown:
|
||||
raise MetadataError(f"Unknown override keys: {', '.join(sorted(unknown))}")
|
||||
model_keys = [key for key in ("model_name", "checkpoint_name", "unet_name") if key in value]
|
||||
if len(model_keys) > 1:
|
||||
raise MetadataError("Specify only one model_name override (checkpoint_name/unet_name are legacy aliases)")
|
||||
if model_keys:
|
||||
key = model_keys[0]
|
||||
name = value.pop(key)
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
raise MetadataError("model_name override must be nonempty text")
|
||||
value["model_name"] = name.strip()
|
||||
return value
|
||||
|
||||
|
||||
_MODEL_FILE_EXTENSIONS = (".safetensors", ".ckpt", ".pt", ".pth", ".bin", ".gguf")
|
||||
|
||||
|
||||
def _model_stem(name: str) -> str:
|
||||
"""Remove a known file extension, retaining dots in model/version names."""
|
||||
for extension in _MODEL_FILE_EXTENSIONS:
|
||||
if name.lower().endswith(extension):
|
||||
return name[:-len(extension)]
|
||||
return name
|
||||
|
||||
|
||||
def resolve_resource(name: str, resources: list[dict[str, Any]], roots: list[str]) -> dict[str, Any]:
|
||||
"""Match paths, filenames, then exact catalog aliases; never fuzzy-match."""
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
raise MetadataError("Missing model name")
|
||||
normalized = name.strip().replace("\\", "/")
|
||||
levels: list[list[dict[str, Any]]] = [[], [], [], []]
|
||||
for item in resources:
|
||||
file_path = item.get("file_path")
|
||||
if not file_path:
|
||||
continue
|
||||
path = file_path.replace("\\", "/")
|
||||
relative = _format_model_name_for_comfyui(file_path, roots).replace("\\", "/")
|
||||
exact = normalized in (path, relative, _model_stem(path), _model_stem(relative))
|
||||
basename = normalized.rsplit("/", 1)[-1] == path.rsplit("/", 1)[-1]
|
||||
stem = _model_stem(normalized.rsplit("/", 1)[-1]) == _model_stem(path.rsplit("/", 1)[-1])
|
||||
aliases = [item.get("file_name"), item.get("model_name")]
|
||||
alias = any(
|
||||
isinstance(value, str) and normalized in (value.strip(), _model_stem(value.strip()))
|
||||
for value in aliases
|
||||
)
|
||||
# Stat only plausible matches, not every file in a large library for
|
||||
# each LoRA. Missing cached files must never win a match.
|
||||
if not (exact or basename or stem or alias) or not os.path.isfile(file_path):
|
||||
continue
|
||||
if exact:
|
||||
levels[0].append(item)
|
||||
if basename:
|
||||
levels[1].append(item)
|
||||
if stem:
|
||||
levels[2].append(item)
|
||||
if alias:
|
||||
levels[3].append(item)
|
||||
for matches in levels:
|
||||
unique = {os.path.abspath(item["file_path"]): item for item in matches}
|
||||
if len(unique) == 1:
|
||||
return next(iter(unique.values()))
|
||||
if unique:
|
||||
raise MetadataError(f"Ambiguous local model '{name}': {', '.join(unique)}. Specify its relative path in overrides_json.")
|
||||
raise MetadataError(f"Model '{name}' could not be matched to an existing file in the local LoRA Manager catalog")
|
||||
|
||||
|
||||
class LoadImageMetadataLM:
|
||||
NAME = "Load Image Metadata (LoraManager)"
|
||||
CATEGORY = "Lora Manager/loaders"
|
||||
DESCRIPTION = (
|
||||
"Load an image and recover prompts, LoRAs and sampling settings from its metadata. "
|
||||
"Connect lora_stack to Lora Loader. Convert loader/sampler widgets to inputs for the other outputs. "
|
||||
"Extraction failures use starter defaults and are shown as ERROR messages in readable_report."
|
||||
)
|
||||
RETURN_TYPES = (
|
||||
"IMAGE", "MASK", "STRING", "STRING", "COMBO", "LORA_STACK", "STRING",
|
||||
"INT", "INT", "FLOAT", "COMBO", "COMBO", "INT", "INT", "FLOAT", "STRING", "STRING", "STRING",
|
||||
)
|
||||
RETURN_NAMES = (
|
||||
"image", "mask", "positive", "negative", "model_name", "lora_stack", "lora_stack_text",
|
||||
"seed", "steps", "cfg", "sampler_name", "scheduler", "width", "height", "denoise", "report", "readable_report", "missing_files",
|
||||
)
|
||||
FUNCTION = "load_metadata"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> dict[str, Any]:
|
||||
from nodes import LoadImage # pyright: ignore[reportMissingImports]
|
||||
|
||||
return {"required": {
|
||||
"image": LoadImage.INPUT_TYPES()["required"]["image"],
|
||||
"sampler_node_id": ("STRING", {"default": "", "tooltip": "Leave empty for a single sampler. Subgraphs: use the full API ID, e.g. 1481:1783 (or 1481/1783). A container or leaf ID works only when unique."}),
|
||||
"missing_settings": (["use_defaults", "strict"], {"tooltip": "Extraction errors always return defaults and an ERROR report, including for saved strict settings. Unresolved files are listed in missing_files."}),
|
||||
"overrides_json": ("STRING", {"default": "{}", "multiline": True, "dynamicPrompts": False, "tooltip": 'Explicit replacements, e.g. {"scheduler":"normal", "model_name":"folder/model.safetensors"}. Use "loras": [] to clear the recovered stack.'}),
|
||||
"prefer_saved_image_metadata": ("BOOLEAN", {"default": True, "tooltip": "Prefer saved A1111-style generation parameters. Disable to select an active workflow sampler; muted/bypassed samplers are excluded."}),
|
||||
}}
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, image: str, **kwargs: Any) -> bool | str:
|
||||
if not folder_paths.exists_annotated_filepath(image):
|
||||
return f"Invalid image file: {image}"
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, image: str, **kwargs: Any) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with open(folder_paths.get_annotated_filepath(image), "rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _source_diagnostics(path: str) -> str:
|
||||
"""Describe the actual selected file without including prompt contents."""
|
||||
from PIL import Image
|
||||
|
||||
try:
|
||||
with Image.open(path) as source:
|
||||
if source.format == "PNG":
|
||||
source.load()
|
||||
details = (
|
||||
f"File: {path}\nFormat: {source.format}; "
|
||||
f"size: {os.path.getsize(path)} bytes; "
|
||||
f"metadata keys: {', '.join(sorted(source.info)) or '(none)'}"
|
||||
)
|
||||
return details
|
||||
except (OSError, ValueError) as exc:
|
||||
return f"File: {path}\nCould not inspect image metadata: {exc}"
|
||||
|
||||
@staticmethod
|
||||
def _library() -> tuple[list[dict[str, Any]], list[str], list[dict[str, Any]], list[str]]:
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
|
||||
async def snapshot() -> tuple[list[dict[str, Any]], list[str], list[dict[str, Any]], list[str]]:
|
||||
models = await ServiceRegistry.get_checkpoint_scanner()
|
||||
loras = await ServiceRegistry.get_lora_scanner()
|
||||
model_cache = await models.get_cached_data()
|
||||
lora_cache = await loras.get_cached_data()
|
||||
return list(model_cache.raw_data), models.get_model_roots(), list(lora_cache.raw_data), loras.get_model_roots()
|
||||
|
||||
return CheckpointLoaderLM._run_async(snapshot)
|
||||
|
||||
def load_metadata(
|
||||
self, image: str, sampler_node_id: str = "", missing_settings: str = "use_defaults",
|
||||
overrides_json: str = "{}", prefer_saved_image_metadata: bool = True,
|
||||
) -> tuple[Any, ...]:
|
||||
import comfy.samplers # pyright: ignore[reportMissingImports]
|
||||
from nodes import LoadImage # pyright: ignore[reportMissingImports]
|
||||
|
||||
overrides = parse_overrides(overrides_json)
|
||||
if missing_settings not in ("strict", "use_defaults"):
|
||||
raise MetadataError("Invalid missing_settings policy")
|
||||
path = folder_paths.get_annotated_filepath(image)
|
||||
pixels, mask = LoadImage().load_image(image)
|
||||
fields = {}
|
||||
no_metadata = False
|
||||
try:
|
||||
fields = ExifUtils._load_structured_metadata(path)
|
||||
no_metadata = not any(fields.values())
|
||||
if no_metadata:
|
||||
extracted = GenerationMetadata(
|
||||
values=dict(EMPTY_IMAGE_DEFAULTS),
|
||||
notes=[
|
||||
"ERROR: No generation metadata found. Using the SDXL bottle starter preset; these settings were not extracted from the image.",
|
||||
self._source_diagnostics(path),
|
||||
],
|
||||
)
|
||||
else:
|
||||
extracted = extract_generation_metadata(fields, sampler_node_id, prefer_saved_image_metadata)
|
||||
except (ValueError, TypeError, KeyError, OSError, RecursionError) as exc:
|
||||
error = f"ERROR: Metadata extraction failed: {exc}"
|
||||
extracted = GenerationMetadata(issues={"source": str(exc)})
|
||||
# An unsupported API graph need not make valid saved generation
|
||||
# parameters unusable. Do not execute or infer custom graph nodes.
|
||||
if (fields.get("prompt") or fields.get("workflow")) and (fields.get("parameters") or fields.get("comment")):
|
||||
try:
|
||||
extracted = extract_generation_metadata({
|
||||
"parameters": fields.get("parameters"), "comment": fields.get("comment"),
|
||||
})
|
||||
extracted.notes.append(error + "; recovered saved generation parameters instead.")
|
||||
if sampler_node_id.strip():
|
||||
extracted.notes.append("ERROR: Global saved parameters cannot verify the requested sampler stage; they are an image-level fallback.")
|
||||
except (ValueError, TypeError, KeyError, RecursionError) as fallback_exc:
|
||||
extracted.notes.append(f"ERROR: Parameter fallback failed: {fallback_exc}")
|
||||
if "source" in extracted.issues:
|
||||
extracted.notes.extend([error, self._source_diagnostics(path)])
|
||||
source_resources = {"checkpoint_name": extracted.values.get("checkpoint_name"), "unet_name": extracted.values.get("unet_name"), "loras": list(extracted.loras), "resource_hints": extracted.resource_hints}
|
||||
values = extracted.values
|
||||
notes = extracted.notes
|
||||
for key, value in overrides.items():
|
||||
values[key] = value
|
||||
extracted.issues.pop(key, None)
|
||||
notes.append(f"Explicit override: {key}.")
|
||||
if "model_name" in overrides:
|
||||
extracted.issues.pop("model", None)
|
||||
values.pop("checkpoint_name", None)
|
||||
values.pop("unet_name", None)
|
||||
if "loras" in overrides:
|
||||
extracted.loras = self._override_loras(overrides["loras"])
|
||||
notes.extend(f"ERROR: {key}: {message}" for key, message in extracted.issues.items())
|
||||
# Discard incomplete graph results instead of outputting half a LoRA
|
||||
# chain or a prompt known to differ from its conditioning.
|
||||
for key in extracted.issues:
|
||||
if key not in overrides:
|
||||
values.pop(key, None)
|
||||
if "loras" in extracted.issues and "loras" not in overrides:
|
||||
extracted.loras = []
|
||||
if "model" in extracted.issues and "model_name" not in overrides:
|
||||
values.pop("checkpoint_name", None)
|
||||
values.pop("unet_name", None)
|
||||
# Extraction without a recognized latent source (e.g. img2img) leaves
|
||||
# width/height unset; the source image dimensions are the best
|
||||
# estimate then. The synthetic starter preset keeps its fixed size.
|
||||
image_fallback = not no_metadata and "source" not in extracted.issues
|
||||
try:
|
||||
image_height, image_width = int(pixels.shape[1]), int(pixels.shape[2])
|
||||
except (AttributeError, IndexError, TypeError, ValueError):
|
||||
image_fallback = False
|
||||
for key, default in EMPTY_IMAGE_DEFAULTS.items():
|
||||
if key in values:
|
||||
continue
|
||||
if image_fallback and key in ("width", "height"):
|
||||
values[key] = image_width if key == "width" else image_height
|
||||
notes.append(f"WARNING Missing {key}; using source image dimension {values[key]}.")
|
||||
else:
|
||||
values[key] = default
|
||||
notes.append(f"ERROR: Missing {key}; using default {default!r}.")
|
||||
# Validate independently so one invalid value cannot erase the other
|
||||
# successfully extracted settings. Invalid explicit overrides still
|
||||
# identify a user configuration error rather than an extraction error.
|
||||
for key in EMPTY_IMAGE_DEFAULTS:
|
||||
trial = {**EMPTY_IMAGE_DEFAULTS, key: values[key]}
|
||||
try:
|
||||
self._validate_values(trial, comfy.samplers.KSampler.SAMPLERS, comfy.samplers.KSampler.SCHEDULERS, True, [])
|
||||
values[key] = trial[key]
|
||||
except (ValueError, TypeError, OverflowError) as exc:
|
||||
if key in overrides:
|
||||
raise MetadataError(f"Invalid override {key}: {exc}") from exc
|
||||
values[key] = EMPTY_IMAGE_DEFAULTS[key]
|
||||
notes.append(f"ERROR: Invalid {key}: {exc}; using default {values[key]!r}.")
|
||||
# Only A1111 directives represent LoRA application. In ComfyUI graphs,
|
||||
# literal tags in encoder text are not executed by CLIPTextEncode.
|
||||
for key in ("positive", "negative"):
|
||||
try:
|
||||
clean, tags = split_lora_tags(values[key])
|
||||
except (ValueError, TypeError) as exc:
|
||||
if key in overrides:
|
||||
raise MetadataError(f"Invalid override {key}: {exc}") from exc
|
||||
values[key] = EMPTY_IMAGE_DEFAULTS[key]
|
||||
notes.append(f"ERROR: Invalid LoRA directive in {key}: {exc}; using starter prompt.")
|
||||
continue
|
||||
if tags:
|
||||
if notes and notes[0] == "A1111/Forge parameters.":
|
||||
if "loras" not in overrides:
|
||||
extracted.loras.extend(tags)
|
||||
values[key] = clean
|
||||
else:
|
||||
notes.append(f"Literal LoRA tags retained in {key}; the embedded ComfyUI graph determines the stack.")
|
||||
try:
|
||||
models, roots, loras, lora_roots = self._library()
|
||||
except Exception as exc:
|
||||
models, roots, loras, lora_roots = [], [], [], []
|
||||
notes.append(f"ERROR: Local library lookup failed: {exc}. Extracted names remain in source_resources.")
|
||||
if (no_metadata or "source" in extracted.issues) and "model_name" not in overrides:
|
||||
base_candidates = [
|
||||
item for item in models
|
||||
if item.get("sub_type") == "checkpoint"
|
||||
and os.path.basename(item.get("file_path", "")).lower() == "sd_xl_base_1.0.safetensors"
|
||||
and os.path.isfile(item["file_path"])
|
||||
]
|
||||
if len(base_candidates) == 1:
|
||||
values["model_name"] = _format_model_name_for_comfyui(base_candidates[0]["file_path"], roots)
|
||||
notes.append("Starter checkpoint: indexed sd_xl_base_1.0.safetensors.")
|
||||
else:
|
||||
notes.append("Select an SDXL checkpoint manually, or set model_name in overrides_json. No unambiguous SDXL base checkpoint was found.")
|
||||
missing_entries = []
|
||||
name = values.get("model_name") or values.get("checkpoint_name") or values.get("unet_name")
|
||||
values.pop("checkpoint_name", None)
|
||||
values.pop("unet_name", None)
|
||||
values["model_name"] = ""
|
||||
values["model_type"] = ""
|
||||
if name:
|
||||
try:
|
||||
# A1111's generic Model label can refer to either category.
|
||||
# Search both together so duplicate names remain ambiguous.
|
||||
available_models = [item for item in models if item.get("sub_type") in ("checkpoint", "diffusion_model")]
|
||||
item = resolve_resource(name, available_models, roots)
|
||||
values["model_name"] = _format_model_name_for_comfyui(item["file_path"], roots)
|
||||
values["model_type"] = item["sub_type"]
|
||||
notes.append(f"Resolved model_name: {values['model_name']} ({values['model_type']}).")
|
||||
except MetadataError as exc:
|
||||
missing_entries.append(f"Model: {name} — {exc}")
|
||||
notes.append(f"WARNING {exc}; model_name is empty.")
|
||||
if not values["model_name"]:
|
||||
notes.append("WARNING No model resolved. Select a model manually on your loader.")
|
||||
stack = []
|
||||
for name, model_strength, clip_strength in extracted.loras:
|
||||
try:
|
||||
item = resolve_resource(name, loras, lora_roots)
|
||||
stack.append((os.path.abspath(item["file_path"]), model_strength, clip_strength))
|
||||
except MetadataError as exc:
|
||||
missing_entries.append(f"LoRA: {name} | model weight: {model_strength:g} | CLIP weight: {clip_strength:g} — {exc}")
|
||||
notes.append(f"WARNING Skipped LoRA: {exc}.")
|
||||
notes.append(f"Resolved {len(stack)} LoRA entries; preserve stack order and avoid adding them again in the loader widget.")
|
||||
notes.append("Metadata settings do not restore VAE, text encoders, ControlNet, regional conditioning or the original latent pipeline.")
|
||||
lora_stack_text = "\n".join(
|
||||
f"{path} | model weight: {model_strength:g} | CLIP weight: {clip_strength:g}"
|
||||
for path, model_strength, clip_strength in stack
|
||||
)
|
||||
missing_files = "\n".join(missing_entries)
|
||||
report = "\n".join(notes) + "\n\n" + json.dumps({**values, "loras": stack, "lora_stack_text": lora_stack_text, "source_resources": source_resources, "missing_files": missing_files}, ensure_ascii=False, indent=2)
|
||||
readable_report = self._readable_report(image, values, extracted.loras, stack, source_resources, notes)
|
||||
return (pixels, mask, values["positive"], values["negative"], values["model_name"],
|
||||
stack, lora_stack_text, values["seed"], values["steps"],
|
||||
values["cfg"], values["sampler_name"], values["scheduler"], values["width"],
|
||||
values["height"], values["denoise"], report, readable_report, missing_files)
|
||||
|
||||
@staticmethod
|
||||
def _readable_report(
|
||||
image: str, values: dict[str, Any], requested_loras: list[tuple[str, float, float]],
|
||||
stack: list[tuple[str, float, float]], source: dict[str, Any], notes: list[str],
|
||||
) -> str:
|
||||
errors = [note for note in notes if note.startswith("ERROR")]
|
||||
lines = ["🖼️ IMAGE GENERATION SETTINGS", f"Image: {image}"]
|
||||
if errors:
|
||||
lines.extend(["", "❌ ERROR — RECOVERED SETTINGS / DEFAULTS", *errors])
|
||||
else:
|
||||
lines.append("✅ Metadata extracted")
|
||||
lines.extend(["", "📦 MODEL"])
|
||||
for key, label in (("checkpoint_name", "Checkpoint"), ("unet_name", "UNet")):
|
||||
if source.get(key):
|
||||
lines.append(f"{label} recorded in image: {source[key]}")
|
||||
if values["model_name"]:
|
||||
lines.append(f"Model resolved locally: {values['model_name']} ({values['model_type']})")
|
||||
else:
|
||||
lines.append("No local model resolved.")
|
||||
lines.extend([
|
||||
"", "⚙️ SAMPLING", f"Seed: {values['seed']}", f"Steps: {values['steps']}",
|
||||
f"CFG: {values['cfg']:g}", f"Sampler: {values['sampler_name']}",
|
||||
f"Scheduler: {values['scheduler']}", f"Size: {values['width']} × {values['height']}",
|
||||
f"Denoise: {values['denoise']:g}", "", "🧩 LORAS",
|
||||
])
|
||||
if requested_loras:
|
||||
for name, model_strength, clip_strength in requested_loras:
|
||||
lines.append(f"- {name} (model: {model_strength:g}, CLIP: {clip_strength:g})")
|
||||
else:
|
||||
lines.append("No LoRA entries extracted or selected.")
|
||||
for hint in source.get("resource_hints", []):
|
||||
if hint.get("name") not in {entry[0] for entry in requested_loras}:
|
||||
lines.append(f"- Recorded resource: {hint['name']} (strength unresolved)")
|
||||
lines.append(f"Resolved locally: {len(stack)} of {len(requested_loras)} requested entries.")
|
||||
lines.extend(["", "➕ POSITIVE PROMPT", values["positive"] or "(empty)",
|
||||
"", "➖ NEGATIVE PROMPT", values["negative"] or "(empty)",
|
||||
"", "📋 NOTES AND WARNINGS"])
|
||||
lines.extend(f"{'❌' if note.startswith('ERROR') else '⚠️' if note.startswith('WARNING') else 'ℹ️'} {note}" for note in notes)
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _override_loras(value: Any) -> list[tuple[str, float, float]]:
|
||||
if not isinstance(value, list):
|
||||
raise MetadataError("loras override must be a list of [name, model_strength, clip_strength]")
|
||||
entries = []
|
||||
for entry in value:
|
||||
if not isinstance(entry, list) or len(entry) != 3 or not isinstance(entry[0], str):
|
||||
raise MetadataError("Each LoRA override must be [name, model_strength, clip_strength]")
|
||||
entries.append((entry[0], finite_number(entry[1]), finite_number(entry[2])))
|
||||
return entries
|
||||
|
||||
@staticmethod
|
||||
def _validate_values(values: dict[str, Any], samplers: list[str], schedulers: list[str], strict: bool, notes: list[str]) -> None:
|
||||
for key in ("positive", "negative"):
|
||||
if not isinstance(values[key], str):
|
||||
raise MetadataError(f"{key} must be text")
|
||||
for key, low, high in (("seed", 0, 2**64 - 1), ("steps", 1, 10000), ("width", 1, 16384), ("height", 1, 16384)):
|
||||
raw = values[key]
|
||||
try:
|
||||
number = int(raw)
|
||||
if isinstance(raw, bool) or (isinstance(raw, float) and raw != number) or not low <= number <= high:
|
||||
raise ValueError()
|
||||
except (ValueError, TypeError, OverflowError) as exc:
|
||||
raise MetadataError(f"{key} must be an integer between {low} and {high}") from exc
|
||||
values[key] = number
|
||||
for key, low, high in (("cfg", 0, 100), ("denoise", 0, 1)):
|
||||
try:
|
||||
number = finite_number(values[key])
|
||||
if not low <= number <= high:
|
||||
raise ValueError()
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise MetadataError(f"{key} must be a finite number between {low} and {high}") from exc
|
||||
values[key] = number
|
||||
for key, choices in (("sampler_name", samplers), ("scheduler", schedulers)):
|
||||
if values[key] not in choices:
|
||||
if strict:
|
||||
raise MetadataError(f"Unsupported {key}: {values[key]!r}; set an explicit override")
|
||||
fallback = DEFAULTS[key]
|
||||
if fallback not in choices:
|
||||
raise MetadataError(f"Default {key} {fallback!r} is unavailable in this ComfyUI installation")
|
||||
notes.append(f"WARNING Replaced unsupported {key} {values[key]!r} with {fallback!r}.")
|
||||
values[key] = fallback
|
||||
@@ -1,5 +1,6 @@
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
|
||||
import comfy.sd # pyright: ignore[reportMissingImports]
|
||||
import comfy.utils # pyright: ignore[reportMissingImports]
|
||||
@@ -37,7 +38,9 @@ def _collect_stack_entries(lora_stack):
|
||||
|
||||
for lora_path, model_strength, clip_strength in lora_stack:
|
||||
lora_name = extract_lora_name(lora_path)
|
||||
absolute_lora_path, trigger_words = get_lora_info_absolute(lora_name)
|
||||
absolute_lora_path, trigger_words = get_lora_info_absolute(
|
||||
lora_path if os.path.isabs(lora_path) else lora_name
|
||||
)
|
||||
entries.append({
|
||||
"name": lora_name,
|
||||
"absolute_path": absolute_lora_path,
|
||||
|
||||
+15
-1
@@ -7,6 +7,7 @@ from ..services.wildcard_service import (
|
||||
contains_dynamic_syntax,
|
||||
get_wildcard_service,
|
||||
is_trigger_words_input,
|
||||
linked_text_requires_rerun,
|
||||
)
|
||||
|
||||
|
||||
@@ -85,6 +86,10 @@ class PromptLM:
|
||||
),
|
||||
},
|
||||
"optional": optional_inputs,
|
||||
"hidden": {
|
||||
"prompt": "PROMPT",
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("CONDITIONING", "STRING")
|
||||
@@ -100,10 +105,16 @@ class PromptLM:
|
||||
text: str,
|
||||
clip: Any | None = None,
|
||||
seed: int | None = None,
|
||||
prompt: dict | None = None,
|
||||
unique_id: str | None = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
del clip, kwargs
|
||||
if contains_dynamic_syntax(text) and seed is None:
|
||||
if seed is not None:
|
||||
return False
|
||||
if contains_dynamic_syntax(text):
|
||||
return float("NaN")
|
||||
if text is None and linked_text_requires_rerun(prompt, unique_id, "text"):
|
||||
return float("NaN")
|
||||
return False
|
||||
|
||||
@@ -112,8 +123,11 @@ class PromptLM:
|
||||
text: str,
|
||||
clip: Any,
|
||||
seed: int | None = None,
|
||||
prompt: dict | None = None,
|
||||
unique_id: str | None = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
del prompt, unique_id
|
||||
expanded_text = get_wildcard_service().expand_text(text, seed=seed)
|
||||
|
||||
trigger_words = []
|
||||
|
||||
+29
-4
@@ -1,6 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..services.wildcard_service import contains_dynamic_syntax, get_wildcard_service
|
||||
from ..services.wildcard_service import (
|
||||
contains_dynamic_syntax,
|
||||
get_wildcard_service,
|
||||
linked_text_requires_rerun,
|
||||
)
|
||||
|
||||
|
||||
class TextLM:
|
||||
@@ -34,6 +38,10 @@ class TextLM:
|
||||
},
|
||||
),
|
||||
},
|
||||
"hidden": {
|
||||
"prompt": "PROMPT",
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
@@ -42,10 +50,27 @@ class TextLM:
|
||||
FUNCTION = "process"
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, text: str, seed: int | None = None):
|
||||
if contains_dynamic_syntax(text) and seed is None:
|
||||
def IS_CHANGED(
|
||||
cls,
|
||||
text: str,
|
||||
seed: int | None = None,
|
||||
prompt: dict | None = None,
|
||||
unique_id: str | None = None,
|
||||
):
|
||||
if seed is not None:
|
||||
return False
|
||||
if contains_dynamic_syntax(text):
|
||||
return float("NaN")
|
||||
if text is None and linked_text_requires_rerun(prompt, unique_id, "text"):
|
||||
return float("NaN")
|
||||
return False
|
||||
|
||||
def process(self, text: str, seed: int | None = None):
|
||||
def process(
|
||||
self,
|
||||
text: str,
|
||||
seed: int | None = None,
|
||||
prompt: dict | None = None,
|
||||
unique_id: str | None = None,
|
||||
):
|
||||
del prompt, unique_id
|
||||
return (get_wildcard_service().expand_text(text, seed=seed),)
|
||||
|
||||
@@ -24,9 +24,11 @@ from ..services.use_cases import (
|
||||
AutoOrganizeUseCase,
|
||||
BulkMetadataRefreshUseCase,
|
||||
DownloadModelUseCase,
|
||||
FilenameTemplateUseCase,
|
||||
)
|
||||
from ..services.websocket_progress_callback import (
|
||||
WebSocketBroadcastCallback,
|
||||
WebSocketFilenameTemplateProgressCallback,
|
||||
WebSocketProgressCallback,
|
||||
)
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
@@ -37,6 +39,7 @@ from .handlers.model_handlers import (
|
||||
ModelAutoOrganizeHandler,
|
||||
ModelCivitaiHandler,
|
||||
ModelDownloadHandler,
|
||||
ModelFilenameTemplateHandler,
|
||||
ModelHandlerSet,
|
||||
ModelListingHandler,
|
||||
ModelManagementHandler,
|
||||
@@ -83,6 +86,9 @@ class BaseModelRoutes(ABC):
|
||||
self.model_lifecycle_service: ModelLifecycleService | None = None
|
||||
self.websocket_progress_callback = WebSocketProgressCallback()
|
||||
self.metadata_progress_callback = WebSocketBroadcastCallback()
|
||||
self.filename_template_progress_callback = (
|
||||
WebSocketFilenameTemplateProgressCallback()
|
||||
)
|
||||
|
||||
self._handler_set: ModelHandlerSet | None = None
|
||||
self._handler_mapping: Dict[str, Callable[[web.Request], Awaitable[web.Response]]] | None = None
|
||||
@@ -202,6 +208,17 @@ class BaseModelRoutes(ABC):
|
||||
ws_manager=self._ws_manager,
|
||||
logger=logger,
|
||||
)
|
||||
filename_template_use_case = FilenameTemplateUseCase(
|
||||
scanner=service.scanner,
|
||||
lifecycle_service=self._ensure_lifecycle_service(),
|
||||
lock_provider=self._ws_manager,
|
||||
model_type=service.model_type,
|
||||
)
|
||||
filename_template = ModelFilenameTemplateHandler(
|
||||
use_case=filename_template_use_case,
|
||||
progress_callback=self.filename_template_progress_callback,
|
||||
logger=logger,
|
||||
)
|
||||
updates = ModelUpdateHandler(
|
||||
service=service,
|
||||
update_service=update_service,
|
||||
@@ -218,6 +235,7 @@ class BaseModelRoutes(ABC):
|
||||
civitai=civitai,
|
||||
move=move,
|
||||
auto_organize=auto_organize,
|
||||
filename_template=filename_template,
|
||||
updates=updates,
|
||||
)
|
||||
|
||||
|
||||
@@ -54,12 +54,14 @@ from ...utils.constants import (
|
||||
SUPPORTED_MEDIA_EXTENSIONS,
|
||||
VALID_LORA_TYPES,
|
||||
VALID_OTHER_CIVITAI_TYPES,
|
||||
folder_path_schema,
|
||||
)
|
||||
from .model_source_handlers import ModelSourceHandler
|
||||
from .agent_handlers import AgentHandler
|
||||
from .download_routing_handlers import DownloadRoutingHandler
|
||||
from .model_handlers import ModelCivitaiHandler
|
||||
from ...utils.civitai_utils import rewrite_preview_url
|
||||
from ...utils.directory_browser import browse_directory
|
||||
from ...utils.example_images_paths import (
|
||||
find_non_compliant_items_in_example_images_root,
|
||||
is_valid_example_images_root,
|
||||
@@ -421,6 +423,11 @@ def _wsl_to_windows_path(wsl_path: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _has_gui_display() -> bool:
|
||||
"""Check whether a GUI session is reachable for xdg-open."""
|
||||
return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
|
||||
|
||||
|
||||
class PromptServerProtocol(Protocol):
|
||||
"""Subset of PromptServer used by the handlers."""
|
||||
|
||||
@@ -1499,6 +1506,7 @@ class SettingsHandler:
|
||||
# Sensitive — never expose the actual value to the frontend;
|
||||
# frontend receives a boolean instead (*_set).
|
||||
"civitai_api_key",
|
||||
"huggingface_api_key",
|
||||
"llm_api_key",
|
||||
}
|
||||
)
|
||||
@@ -1557,6 +1565,8 @@ class SettingsHandler:
|
||||
# Sensitive fields: only expose a boolean indicating whether set
|
||||
raw_key = self._settings.get("civitai_api_key")
|
||||
response_data["civitai_api_key_set"] = bool(raw_key)
|
||||
raw_hf_key = self._settings.get("huggingface_api_key")
|
||||
response_data["huggingface_api_key_set"] = bool(raw_hf_key)
|
||||
raw_llm_key = self._settings.get("llm_api_key")
|
||||
response_data["llm_api_key_set"] = bool(raw_llm_key)
|
||||
# Derived capability flag (not persisted): whether the host exposes
|
||||
@@ -1575,6 +1585,30 @@ class SettingsHandler:
|
||||
availability_error,
|
||||
)
|
||||
response_data["other_models_paths_available"] = None
|
||||
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"
|
||||
response_data["standalone_mode"] = standalone_mode
|
||||
if standalone_mode:
|
||||
# Standalone reads its model roots exclusively from
|
||||
# settings.json, so the Model Paths settings UI needs the
|
||||
# current values plus the editable-key schema. In plugin mode
|
||||
# the paths come from the ComfyUI host and stay hidden.
|
||||
folder_paths = self._settings.get("folder_paths") or {}
|
||||
# A fresh install is seeded from settings.json.example, whose
|
||||
# folder_paths are documentation placeholders — hide them so
|
||||
# the UI starts with empty editors instead of fake paths.
|
||||
get_placeholders = getattr(
|
||||
self._settings, "get_template_folder_path_placeholders", None
|
||||
)
|
||||
placeholders = get_placeholders() if get_placeholders else set()
|
||||
if placeholders:
|
||||
folder_paths = {
|
||||
key: [p for p in paths if p not in placeholders]
|
||||
if isinstance(paths, list)
|
||||
else paths
|
||||
for key, paths in folder_paths.items()
|
||||
}
|
||||
response_data["folder_paths"] = folder_paths
|
||||
response_data["folder_path_schema"] = folder_path_schema()
|
||||
settings_file = getattr(self._settings, "settings_file", None)
|
||||
if settings_file:
|
||||
response_data["settings_file"] = settings_file
|
||||
@@ -2759,12 +2793,40 @@ class ModelLibraryHandler:
|
||||
|
||||
normalized_type, scanner = await self._get_scanner_for_type(model_type)
|
||||
if not normalized_type:
|
||||
# The lookup cannot be served as a fully interactive list. Two
|
||||
# cases share this branch: a CivitAI type with no scanner at all
|
||||
# (Wildcards, Workflows, Hypernetwork, Poses, AestheticGradient)
|
||||
# and an Other-model type while the opt-in master switch is off.
|
||||
# Answer 200 with the CivitAI list marked read-only plus a
|
||||
# machine-readable reason, so clients can still show the
|
||||
# versions and explain why the actions are missing. Legacy
|
||||
# clients keep working: they only read `success`/`versions`.
|
||||
reason = (
|
||||
"other_models_disabled"
|
||||
if self._normalize_model_type(model_type) == "other"
|
||||
else "model_type_unsupported"
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": f'Model type "{model_type}" is not supported',
|
||||
},
|
||||
status=400,
|
||||
"success": True,
|
||||
"modelId": model_id,
|
||||
"modelName": model_name,
|
||||
"modelType": model_type,
|
||||
"supported": False,
|
||||
"reason": reason,
|
||||
"versions": [
|
||||
{
|
||||
"id": version.get("id"),
|
||||
"name": version.get("name", ""),
|
||||
"thumbnailUrl": version.get("images")[0]["url"]
|
||||
if version.get("images")
|
||||
else None,
|
||||
"inLibrary": False,
|
||||
"hasBeenDownloaded": False,
|
||||
}
|
||||
for version in versions
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
if not scanner:
|
||||
@@ -2806,6 +2868,7 @@ class ModelLibraryHandler:
|
||||
"modelId": model_id,
|
||||
"modelName": model_name,
|
||||
"modelType": model_type,
|
||||
"supported": True,
|
||||
"versions": enriched_versions,
|
||||
}
|
||||
)
|
||||
@@ -3393,6 +3456,18 @@ class FileSystemHandler:
|
||||
subprocess.Popen(["open", "-R", settings_file])
|
||||
else:
|
||||
folder = os.path.dirname(settings_file)
|
||||
if not _has_gui_display():
|
||||
# Headless/SSH session: xdg-open cannot open a file
|
||||
# manager, so hand the path to the browser for copying
|
||||
# instead of reporting a success that never happened.
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"message": "Headless session: path available for copying",
|
||||
"path": settings_file,
|
||||
"mode": "clipboard",
|
||||
}
|
||||
)
|
||||
subprocess.Popen(["xdg-open", folder])
|
||||
|
||||
return web.json_response(
|
||||
@@ -3426,6 +3501,76 @@ class FileSystemHandler:
|
||||
logger.error("Failed to open wildcards location: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def browse_directory(self, request: web.Request) -> web.Response:
|
||||
"""Browse a directory for the settings-UI directory picker."""
|
||||
try:
|
||||
data = await request.json()
|
||||
payload, status = browse_directory(data.get("path", ""))
|
||||
return web.json_response(payload, status=status)
|
||||
except json.JSONDecodeError:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Invalid JSON"}, status=400
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error("Failed to browse directory: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def validate_path(self, request: web.Request) -> web.Response:
|
||||
"""Validate a filesystem path for the settings UI.
|
||||
|
||||
A well-formed request always returns HTTP 200; invalid paths are
|
||||
reported via ``error_code`` in the payload. HTTP 400 is reserved for
|
||||
malformed requests (missing path, invalid JSON).
|
||||
"""
|
||||
try:
|
||||
data = await request.json()
|
||||
raw_path = data.get("path")
|
||||
expect = data.get("expect", "directory")
|
||||
|
||||
if not raw_path or not isinstance(raw_path, str):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Missing path parameter"}, status=400
|
||||
)
|
||||
|
||||
# Business path convention: abspath only, never realpath.
|
||||
path = os.path.abspath(os.path.expanduser(raw_path))
|
||||
|
||||
exists = os.path.exists(path)
|
||||
is_directory = os.path.isdir(path) if exists else False
|
||||
readable = bool(exists and os.access(path, os.R_OK))
|
||||
writable = bool(exists and os.access(path, os.W_OK))
|
||||
|
||||
error_code = None
|
||||
if not exists:
|
||||
error_code = "path_not_found"
|
||||
elif expect == "directory" and not is_directory:
|
||||
error_code = "not_a_directory"
|
||||
elif expect == "file" and not os.path.isfile(path):
|
||||
error_code = "not_a_file"
|
||||
elif not readable:
|
||||
error_code = "not_readable"
|
||||
elif not writable:
|
||||
error_code = "not_writable"
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"path": path,
|
||||
"exists": exists,
|
||||
"is_directory": is_directory,
|
||||
"readable": readable,
|
||||
"writable": writable,
|
||||
"error_code": error_code,
|
||||
}
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Invalid JSON"}, status=400
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error("Failed to validate path: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
|
||||
class CustomWordsHandler:
|
||||
"""Handler for autocomplete via TagFTSIndex."""
|
||||
@@ -4070,6 +4215,8 @@ class MiscHandlerSet:
|
||||
"open_settings_location": self.filesystem.open_settings_location,
|
||||
"open_backup_location": self.filesystem.open_backup_location,
|
||||
"open_wildcards_location": self.filesystem.open_wildcards_location,
|
||||
"browse_directory": self.filesystem.browse_directory,
|
||||
"validate_path": self.filesystem.validate_path,
|
||||
"search_custom_words": self.custom_words.search_custom_words,
|
||||
"search_wildcards": self.wildcards.search_wildcards,
|
||||
"get_supporters": self.supporters.get_supporters,
|
||||
|
||||
@@ -37,15 +37,20 @@ from ...services.use_cases import (
|
||||
DownloadModelEarlyAccessError,
|
||||
DownloadModelUseCase,
|
||||
DownloadModelValidationError,
|
||||
FilenameTemplateUseCase,
|
||||
MetadataRefreshProgressReporter,
|
||||
)
|
||||
from ...services.websocket_manager import WebSocketManager
|
||||
from ...services.websocket_progress_callback import WebSocketProgressCallback
|
||||
from ...services.websocket_progress_callback import (
|
||||
WebSocketFilenameTemplateProgressCallback,
|
||||
WebSocketProgressCallback,
|
||||
)
|
||||
from ...services.download_queue_service import DownloadQueueService
|
||||
from ...services.errors import RateLimitError, ResourceNotFoundError
|
||||
from ...utils.civitai_utils import resolve_license_payload
|
||||
from ...utils.file_utils import calculate_sha256
|
||||
from ...utils.metadata_manager import MetadataManager
|
||||
from ...utils.url_utils import relative_root_prefix
|
||||
|
||||
LICENSE_FIELDS = (
|
||||
"allowNoCredit",
|
||||
@@ -200,6 +205,7 @@ class ModelPageView:
|
||||
"version": self._get_app_version(),
|
||||
"provider_presets_json": json.dumps(PROVIDER_PRESETS),
|
||||
"provider_models_json": "{}",
|
||||
"rel_prefix": relative_root_prefix(request.path),
|
||||
}
|
||||
|
||||
if not is_initializing:
|
||||
@@ -2692,6 +2698,71 @@ class ModelAutoOrganizeHandler:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
|
||||
class ModelFilenameTemplateHandler:
|
||||
"""Apply the configured filename template to existing library models."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
use_case: FilenameTemplateUseCase,
|
||||
progress_callback: WebSocketFilenameTemplateProgressCallback,
|
||||
logger: logging.Logger,
|
||||
) -> None:
|
||||
self._use_case = use_case
|
||||
self._progress_callback = progress_callback
|
||||
self._logger = logger
|
||||
|
||||
async def apply_filename_template(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
file_paths = None
|
||||
if request.method == "POST":
|
||||
try:
|
||||
data = await request.json()
|
||||
file_paths = data.get("file_paths")
|
||||
except Exception: # pragma: no cover - permissive path
|
||||
pass
|
||||
else:
|
||||
# GET variant (browser extension is GET-only): comma-separated
|
||||
# file_paths query parameter.
|
||||
raw_file_paths = request.query.get("file_paths")
|
||||
if raw_file_paths:
|
||||
file_paths = [
|
||||
path.strip()
|
||||
for path in raw_file_paths.split(",")
|
||||
if path.strip()
|
||||
]
|
||||
|
||||
result = await self._use_case.execute(
|
||||
file_paths=file_paths,
|
||||
progress_callback=self._progress_callback,
|
||||
)
|
||||
_broadcast_models_changed()
|
||||
return web.json_response(result.to_dict())
|
||||
except AutoOrganizeInProgressError:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Another library operation is already running. Please wait for it to complete.",
|
||||
},
|
||||
status=409,
|
||||
)
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error in apply_filename_template: %s", exc, exc_info=True
|
||||
)
|
||||
try:
|
||||
await self._progress_callback.on_progress(
|
||||
{
|
||||
"type": "filename_template_progress",
|
||||
"status": "error",
|
||||
"error": str(exc),
|
||||
}
|
||||
)
|
||||
except Exception: # pragma: no cover - defensive reporting
|
||||
pass
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
|
||||
class ModelUpdateHandler:
|
||||
"""Handle update tracking requests."""
|
||||
|
||||
@@ -3459,6 +3530,7 @@ class ModelHandlerSet:
|
||||
civitai: ModelCivitaiHandler
|
||||
move: ModelMoveHandler
|
||||
auto_organize: ModelAutoOrganizeHandler
|
||||
filename_template: ModelFilenameTemplateHandler
|
||||
updates: ModelUpdateHandler
|
||||
|
||||
def to_route_mapping(
|
||||
@@ -3523,6 +3595,7 @@ class ModelHandlerSet:
|
||||
"rename_folder": self.move.rename_folder,
|
||||
"auto_organize_models": self.auto_organize.auto_organize_models,
|
||||
"get_auto_organize_progress": self.auto_organize.get_auto_organize_progress,
|
||||
"apply_filename_template": self.filename_template.apply_filename_template,
|
||||
"get_model_notes": self.query.get_model_notes,
|
||||
"get_model_preview_url": self.query.get_model_preview_url,
|
||||
"get_model_civitai_url": self.query.get_model_civitai_url,
|
||||
|
||||
@@ -580,6 +580,10 @@ class ModelSourceHandler:
|
||||
get_settings_manager().get("download_backend", "default")
|
||||
)
|
||||
|
||||
# Site-specific credentials (e.g. a Hugging Face access token for
|
||||
# gated/private repositories); empty for anonymous downloads.
|
||||
auth_headers = source.auth_headers()
|
||||
|
||||
if download_backend == "aria2":
|
||||
aria2 = await Aria2Downloader.get_instance()
|
||||
aid = download_id or f"{source.platform}_{repo}_{filename}"
|
||||
@@ -589,6 +593,7 @@ class ModelSourceHandler:
|
||||
save_path=dest_path,
|
||||
download_id=aid,
|
||||
progress_callback=progress_callback,
|
||||
headers=auth_headers or None,
|
||||
)
|
||||
if ok:
|
||||
await _save_source_metadata(
|
||||
@@ -618,6 +623,7 @@ class ModelSourceHandler:
|
||||
use_auth=False,
|
||||
allow_resume=True,
|
||||
progress_callback=progress_callback,
|
||||
custom_headers=auth_headers or None,
|
||||
)
|
||||
if success:
|
||||
await _save_source_metadata(
|
||||
|
||||
@@ -9,7 +9,6 @@ import re
|
||||
import asyncio
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Protocol, Tuple
|
||||
|
||||
from aiohttp import web
|
||||
@@ -34,8 +33,10 @@ from ...utils.civitai_utils import (
|
||||
rewrite_preview_url,
|
||||
)
|
||||
from ...utils.constants import NSFW_LEVELS
|
||||
from ...utils.directory_browser import WINDOWS_DRIVES_TOKEN, browse_directory
|
||||
from ...utils.exif_utils import ExifUtils
|
||||
from ...utils.recipe_open_stats import RecipeOpenStats
|
||||
from ...utils.url_utils import relative_root_prefix
|
||||
from ...recipes.merger import GenParamsMerger
|
||||
from ...recipes.enrichment import RecipeEnricher
|
||||
from ...services.websocket_manager import ws_manager as default_ws_manager
|
||||
@@ -215,6 +216,7 @@ class RecipePageView:
|
||||
settings=self._settings,
|
||||
request=request,
|
||||
t=self._server_i18n.get_translation,
|
||||
rel_prefix=relative_root_prefix(request.path),
|
||||
)
|
||||
except Exception as cache_error: # pragma: no cover - logging path
|
||||
self._logger.error("Error loading recipe cache data: %s", cache_error)
|
||||
@@ -223,6 +225,7 @@ class RecipePageView:
|
||||
settings=self._settings,
|
||||
request=request,
|
||||
t=self._server_i18n.get_translation,
|
||||
rel_prefix=relative_root_prefix(request.path),
|
||||
)
|
||||
return web.Response(text=rendered, content_type="text/html")
|
||||
except Exception as exc: # pragma: no cover - logging path
|
||||
@@ -3124,11 +3127,10 @@ class RecipeWorkflowHandler:
|
||||
class BatchImportHandler:
|
||||
"""Handle batch import operations for recipes."""
|
||||
|
||||
# Virtual path token for the Windows drive list. Browsing up from a drive
|
||||
# root (e.g. C:\) lands here so users can switch drives without typing a
|
||||
# path. Only meaningful on Windows; elsewhere it falls through to normal
|
||||
# path handling and fails the existence check.
|
||||
WINDOWS_DRIVES_TOKEN = "__drives__"
|
||||
# Virtual path token for the Windows drive list. Kept as a class
|
||||
# attribute for backwards compatibility; the canonical definition lives
|
||||
# in py/utils/directory_browser.py.
|
||||
WINDOWS_DRIVES_TOKEN = WINDOWS_DRIVES_TOKEN
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -3301,131 +3303,8 @@ class BatchImportHandler:
|
||||
"""Browse a directory and return its contents (subdirectories and files)."""
|
||||
try:
|
||||
data = await request.json()
|
||||
directory_path = data.get("path", "")
|
||||
|
||||
if os.name == "nt" and directory_path == self.WINDOWS_DRIVES_TOKEN:
|
||||
return self._windows_drives_response()
|
||||
|
||||
# Default to the user's home directory. The frontend previously
|
||||
# sent "/" as the initial path, which is POSIX-only: on Windows it
|
||||
# resolves to the current drive root and then fails the access
|
||||
# check below.
|
||||
if not directory_path:
|
||||
path = Path.home()
|
||||
else:
|
||||
path = Path(directory_path).expanduser().resolve()
|
||||
|
||||
# Access check: browsing intentionally covers the whole server
|
||||
# filesystem (the server operator browses their own machine). On
|
||||
# POSIX every absolute path is under "/", but Path("/") has no
|
||||
# drive letter on Windows and can never anchor a drive-qualified
|
||||
# path in relative_to(), so test for a drive there instead.
|
||||
if os.name == "nt":
|
||||
is_allowed = bool(path.drive)
|
||||
else:
|
||||
is_allowed = path.is_absolute()
|
||||
|
||||
if not is_allowed:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Access denied to this directory"},
|
||||
status=403,
|
||||
)
|
||||
|
||||
if not path.exists():
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Directory does not exist"},
|
||||
status=404,
|
||||
)
|
||||
|
||||
if not path.is_dir():
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Path is not a directory"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# List directory contents
|
||||
directories = []
|
||||
image_files = []
|
||||
|
||||
image_extensions = {
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".gif",
|
||||
".webp",
|
||||
".bmp",
|
||||
".tiff",
|
||||
".tif",
|
||||
}
|
||||
|
||||
try:
|
||||
for item in path.iterdir():
|
||||
try:
|
||||
if item.is_dir():
|
||||
# Skip hidden directories and common system folders
|
||||
if not item.name.startswith(".") and item.name not in [
|
||||
"__pycache__",
|
||||
"node_modules",
|
||||
]:
|
||||
directories.append(
|
||||
{
|
||||
"name": item.name,
|
||||
"path": str(item),
|
||||
"is_parent": False,
|
||||
}
|
||||
)
|
||||
elif item.is_file() and item.suffix.lower() in image_extensions:
|
||||
image_files.append(
|
||||
{
|
||||
"name": item.name,
|
||||
"path": str(item),
|
||||
"size": item.stat().st_size,
|
||||
}
|
||||
)
|
||||
except (PermissionError, OSError):
|
||||
# Skip files/directories we can't access
|
||||
continue
|
||||
|
||||
# Sort directories and files alphabetically
|
||||
directories.sort(key=lambda x: x["name"].lower())
|
||||
image_files.sort(key=lambda x: x["name"].lower())
|
||||
|
||||
# Parent directory. A filesystem root is its own parent
|
||||
# (parent == path): POSIX "/" gets no parent, while a Windows
|
||||
# drive root (C:\) links up to the virtual drive list so users
|
||||
# can switch drives. The previous str(path) != str(path.root)
|
||||
# check misfired on Windows, where a drive root's parent is
|
||||
# itself, producing an infinite self-loop.
|
||||
if path.parent == path:
|
||||
parent_path = (
|
||||
self.WINDOWS_DRIVES_TOKEN if os.name == "nt" else None
|
||||
)
|
||||
else:
|
||||
parent_path = str(path.parent)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"current_path": str(path),
|
||||
"parent_path": parent_path,
|
||||
"directories": directories,
|
||||
"image_files": image_files,
|
||||
"image_count": len(image_files),
|
||||
"directory_count": len(directories),
|
||||
}
|
||||
)
|
||||
|
||||
except PermissionError:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Permission denied"},
|
||||
status=403,
|
||||
)
|
||||
except OSError as exc:
|
||||
return web.json_response(
|
||||
{"success": False, "error": f"Error reading directory: {str(exc)}"},
|
||||
status=500,
|
||||
)
|
||||
|
||||
payload, status = browse_directory(data.get("path", ""))
|
||||
return web.json_response(payload, status=status)
|
||||
except json.JSONDecodeError:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Invalid JSON"},
|
||||
@@ -3434,30 +3313,3 @@ class BatchImportHandler:
|
||||
except Exception as exc:
|
||||
self._logger.error("Error browsing directory: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
def _windows_drives_response(self) -> web.Response:
|
||||
"""List available drive letters as a virtual directory (Windows only)."""
|
||||
try:
|
||||
drives = os.listdrives()
|
||||
except AttributeError: # Python < 3.12
|
||||
drives = [
|
||||
f"{letter}:\\"
|
||||
for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
if os.path.exists(f"{letter}:\\")
|
||||
]
|
||||
directories = [
|
||||
{"name": drive, "path": drive, "is_parent": False} for drive in drives
|
||||
]
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
# Empty current_path marks the virtual level; the frontend
|
||||
# disables folder selection there.
|
||||
"current_path": "",
|
||||
"parent_path": None,
|
||||
"directories": directories,
|
||||
"image_files": [],
|
||||
"image_count": 0,
|
||||
"directory_count": len(directories),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -37,6 +37,8 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("GET", "/api/lm/wildcards/search", "search_wildcards"),
|
||||
RouteDefinition("POST", "/api/lm/wildcards/open-location", "open_wildcards_location"),
|
||||
RouteDefinition("POST", "/api/lm/open-file-location", "open_file_location"),
|
||||
RouteDefinition("POST", "/api/lm/browse-directory", "browse_directory"),
|
||||
RouteDefinition("POST", "/api/lm/validate-path", "validate_path"),
|
||||
RouteDefinition("POST", "/api/lm/update-usage-stats", "update_usage_stats"),
|
||||
RouteDefinition("GET", "/api/lm/get-usage-stats", "get_usage_stats"),
|
||||
RouteDefinition("POST", "/api/lm/update-lora-code", "update_lora_code"),
|
||||
|
||||
@@ -48,6 +48,12 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/{prefix}/auto-organize-progress", "get_auto_organize_progress"
|
||||
),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/{prefix}/apply-filename-template", "apply_filename_template"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/{prefix}/apply-filename-template", "apply_filename_template"
|
||||
),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/top-tags", "get_top_tags"),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/search-tags", "search_tags"),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/base-models", "get_base_models"),
|
||||
|
||||
@@ -83,11 +83,16 @@ class OtherRoutes(BaseModelRoutes):
|
||||
# resolved to no existing folder. Render an actionable empty state
|
||||
# instead of an apparently broken empty grid.
|
||||
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"
|
||||
return {
|
||||
context = {
|
||||
"other_disabled": False,
|
||||
"other_no_paths": not bool(config.other_roots),
|
||||
"standalone_mode": standalone_mode,
|
||||
}
|
||||
if standalone_mode:
|
||||
# The empty state points at the Model Paths settings section and
|
||||
# shows the settings.json path as a fallback reference.
|
||||
context["settings_file"] = getattr(self._settings, "settings_file", "") or ""
|
||||
return context
|
||||
|
||||
def _get_expected_model_types(self) -> str:
|
||||
"""Get expected model types string for error messages"""
|
||||
|
||||
@@ -13,6 +13,7 @@ from ..services.server_i18n import server_i18n
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..services.model_query import normalize_sub_type, resolve_sub_type
|
||||
from ..utils.constants import VALID_LORA_SUB_TYPES, VALID_CHECKPOINT_SUB_TYPES
|
||||
from ..utils.url_utils import relative_root_prefix
|
||||
from ..utils.usage_stats import UsageStats
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -106,6 +107,7 @@ class StatsRoutes:
|
||||
settings=settings_manager,
|
||||
request=request,
|
||||
t=server_i18n.get_translation,
|
||||
rel_prefix=relative_root_prefix(request.path),
|
||||
)
|
||||
|
||||
return web.Response(
|
||||
|
||||
@@ -21,7 +21,20 @@ NETWORK_EXCEPTIONS = (ClientError, OSError, asyncio.TimeoutError)
|
||||
# otherwise delete them because they are untracked and, in released tags,
|
||||
# not listed in ``.gitignore``. ``-e`` excludes a path from cleaning
|
||||
# regardless of whether it is ignored.
|
||||
_PRESERVE_DIRS = ('settings.json', 'civitai', 'wildcards', 'backups', 'stats', 'logs', 'cache', 'model_cache')
|
||||
# ``cache`` covers the resolved cache tree (cache/model, cache/recipe,
|
||||
# cache/fts, ...); the legacy ``recipe_cache`` / ``model_cache`` directories
|
||||
# are listed too because a portable install can predate the cache/ move.
|
||||
_PRESERVE_DIRS = (
|
||||
'settings.json',
|
||||
'civitai',
|
||||
'wildcards',
|
||||
'backups',
|
||||
'stats',
|
||||
'logs',
|
||||
'cache',
|
||||
'model_cache',
|
||||
'recipe_cache',
|
||||
)
|
||||
|
||||
|
||||
def _clean_excludes() -> List[str]:
|
||||
|
||||
@@ -199,6 +199,15 @@ class PostProcessor:
|
||||
if is_source_model and site_version:
|
||||
self._merge_civitai(updates, metadata, name=site_version)
|
||||
|
||||
# Site-native identity ids (ModelScope's published model/version ids).
|
||||
# They are what version grouping keys off, so they must reach the
|
||||
# sidecar even when nothing else about the card changed.
|
||||
if is_source_model and source_context is not None:
|
||||
if source_context.source_model_id:
|
||||
updates["source_model_id"] = source_context.source_model_id
|
||||
if source_context.source_version_id:
|
||||
updates["source_version_id"] = source_context.source_version_id
|
||||
|
||||
# gallery images → civitai.images (site example images, YAML frontmatter
|
||||
# widget entries, and Sample Gallery markdown tables in the README body)
|
||||
rec_width = llm_output.get("recommended_width") or 0
|
||||
|
||||
@@ -81,6 +81,14 @@ CIVITAI_DOWNLOAD_URL_PREFIXES = (
|
||||
"https://civitai.red/api/download/",
|
||||
)
|
||||
|
||||
#: Hosts whose authenticated downloads redirect to a signed CDN URL. aria2
|
||||
#: forwards custom headers to redirect targets, so for these hosts the
|
||||
#: redirect is resolved first and the signed URL is handed to aria2 without
|
||||
#: the credentials.
|
||||
AUTH_REDIRECT_DOWNLOAD_URL_PREFIXES = CIVITAI_DOWNLOAD_URL_PREFIXES + (
|
||||
"https://huggingface.co/",
|
||||
)
|
||||
|
||||
|
||||
def _is_no_uri_available_error(message: str) -> bool:
|
||||
"""Return True for aria2's "No URI available" transfer failure.
|
||||
@@ -308,12 +316,12 @@ class Aria2Downloader:
|
||||
|
||||
resolved_url = url
|
||||
request_headers = headers
|
||||
if headers and url.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES):
|
||||
if headers and url.startswith(AUTH_REDIRECT_DOWNLOAD_URL_PREFIXES):
|
||||
resolved_url = await self._resolve_authenticated_redirect_url(url, headers)
|
||||
if resolved_url != url:
|
||||
request_headers = None
|
||||
logger.debug(
|
||||
"Resolved Civitai download %s to signed URL for aria2",
|
||||
"Resolved authenticated download %s to signed URL for aria2",
|
||||
download_id,
|
||||
)
|
||||
|
||||
@@ -341,7 +349,7 @@ class Aria2Downloader:
|
||||
]
|
||||
|
||||
logger.debug(
|
||||
"Submitting aria2 download %s -> %s (auth=%s, civitai_signed=%s)",
|
||||
"Submitting aria2 download %s -> %s (auth=%s, signed_url=%s)",
|
||||
download_id,
|
||||
save_path,
|
||||
bool(request_headers),
|
||||
@@ -732,7 +740,7 @@ class Aria2Downloader:
|
||||
if location:
|
||||
return location
|
||||
raise Aria2Error(
|
||||
"Authenticated Civitai redirect did not include a Location header"
|
||||
"Authenticated redirect did not include a Location header"
|
||||
)
|
||||
|
||||
if response.status == 200:
|
||||
@@ -740,12 +748,12 @@ class Aria2Downloader:
|
||||
|
||||
body = await response.text()
|
||||
raise Aria2Error(
|
||||
f"Failed to resolve authenticated Civitai redirect: status={response.status} body={body[:300]}"
|
||||
f"Failed to resolve authenticated redirect: status={response.status} body={body[:300]}"
|
||||
)
|
||||
except aiohttp.ClientError as exc:
|
||||
if is_ssl_cert_verify_error(exc):
|
||||
logger.error(
|
||||
"SSL certificate verification failed during Civitai redirect "
|
||||
"SSL certificate verification failed during authenticated redirect "
|
||||
"resolution for %s. This is usually caused by an outdated CA "
|
||||
"certificate bundle. Recommended fixes:\n"
|
||||
" 1. pip install --upgrade certifi\n"
|
||||
@@ -753,7 +761,7 @@ class Aria2Downloader:
|
||||
url,
|
||||
)
|
||||
raise Aria2Error(
|
||||
f"Failed to resolve authenticated Civitai redirect: {exc}"
|
||||
f"Failed to resolve authenticated redirect: {exc}"
|
||||
) from exc
|
||||
|
||||
async def _ensure_process(self) -> None:
|
||||
|
||||
@@ -740,18 +740,14 @@ class BaseModelService(ABC):
|
||||
|
||||
return annotated
|
||||
|
||||
@staticmethod
|
||||
def _extract_hf_group_key(item: Dict[str, Any]) -> Optional[str]:
|
||||
"""Extract `hf:{owner}/{repo}` from item's ``hf_url``, or None."""
|
||||
key = BaseModelService._extract_source_group_key(item)
|
||||
return key if key and key.startswith("hf:") else None
|
||||
|
||||
@staticmethod
|
||||
def _extract_source_group_key(item: Dict[str, Any]) -> Optional[str]:
|
||||
"""Return the external-source group key for *item*, or None.
|
||||
|
||||
Hugging Face keeps the historical ``hf:{owner}/{repo}`` shape; other
|
||||
platforms use their own short prefix (``ms:`` / ``ta:``).
|
||||
Only sources with a site-native model identity yield a key:
|
||||
ModelScope groups by its published-model id (``ms:{id}``), TensorArt
|
||||
by its numeric model id (``ta:{id}``); Hugging Face models never
|
||||
group (see :meth:`ModelSource.group_key`).
|
||||
"""
|
||||
return source_group_key(item)
|
||||
|
||||
@@ -761,8 +757,8 @@ class BaseModelService(ABC):
|
||||
|
||||
Preference order:
|
||||
1. CivitAI ``modelId`` (int)
|
||||
2. External model source identity, e.g. ``hf:{owner}/{repo}``,
|
||||
``ms:{owner}/{repo}``, ``ta:{model_id}`` (str)
|
||||
2. External model source identity, e.g. ``ms:{model_id}``,
|
||||
``ta:{model_id}`` (str)
|
||||
3. ``None`` (no known grouping source)
|
||||
"""
|
||||
mid = BaseModelService._extract_model_id(item)
|
||||
|
||||
@@ -69,6 +69,8 @@ class CheckpointService(BaseModelService):
|
||||
"version_count": model_data.get("version_count"),
|
||||
"source_platform": model_data.get("source_platform", ""),
|
||||
"source_url": model_data.get("source_url", ""),
|
||||
"source_model_id": model_data.get("source_model_id", ""),
|
||||
"source_version_id": model_data.get("source_version_id", ""),
|
||||
"hf_url": model_data.get("hf_url", ""),
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ from ..utils.models import (
|
||||
)
|
||||
from ..utils.constants import (
|
||||
CARD_PREVIEW_WIDTH,
|
||||
MAX_FOLDER_NAME_LENGTH,
|
||||
MAX_PATH_TAG_LENGTH,
|
||||
MODEL_WEIGHT_FILE_TYPES,
|
||||
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
|
||||
VALID_LORA_TYPES,
|
||||
@@ -33,7 +35,7 @@ from ..utils.constants import (
|
||||
from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url
|
||||
from ..utils.file_utils import calculate_sha256, calculate_autov3
|
||||
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
|
||||
from ..utils.utils import sanitize_folder_name
|
||||
from ..utils.utils import calculate_filename_for_model, sanitize_folder_name
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from .service_registry import ServiceRegistry
|
||||
@@ -45,6 +47,7 @@ from .errors import RateLimitError
|
||||
from .aria2_downloader import Aria2Error, get_aria2_downloader
|
||||
from .aria2_transfer_state import Aria2TransferStateStore
|
||||
from .download_queue_service import DownloadQueueService
|
||||
from .model_lifecycle_service import ModelLifecycleService, load_local_metadata
|
||||
|
||||
# Download to temporary file first
|
||||
import tempfile
|
||||
@@ -2326,16 +2329,26 @@ class DownloadManager:
|
||||
if not first_tag:
|
||||
first_tag = "no tags" # Default if no tags available
|
||||
|
||||
# Tags come straight from CivitAI, so sanitize the value before it
|
||||
# becomes a path segment and cap its length (#1119).
|
||||
first_tag = sanitize_folder_name(first_tag, max_length=MAX_PATH_TAG_LENGTH)
|
||||
|
||||
# Format the template with available data
|
||||
formatted_path = path_template
|
||||
formatted_path = formatted_path.replace("{base_model}", mapped_base_model)
|
||||
formatted_path = formatted_path.replace("{first_tag}", first_tag)
|
||||
formatted_path = formatted_path.replace("{author}", author)
|
||||
formatted_path = formatted_path.replace(
|
||||
"{model_name}", sanitize_folder_name(model_info.get("name", ""))
|
||||
"{model_name}",
|
||||
sanitize_folder_name(
|
||||
model_info.get("name", ""), max_length=MAX_FOLDER_NAME_LENGTH
|
||||
),
|
||||
)
|
||||
formatted_path = formatted_path.replace(
|
||||
"{version_name}", sanitize_folder_name(version_info.get("name", ""))
|
||||
"{version_name}",
|
||||
sanitize_folder_name(
|
||||
version_info.get("name", ""), max_length=MAX_FOLDER_NAME_LENGTH
|
||||
),
|
||||
)
|
||||
|
||||
if model_type == "embedding":
|
||||
@@ -2746,6 +2759,7 @@ class DownloadManager:
|
||||
else None
|
||||
)
|
||||
|
||||
downloaded_metadata: List[Dict[str, Any]] = []
|
||||
for index, entry in enumerate(metadata_entries):
|
||||
file_path_for_adjust = getattr(
|
||||
entry, "file_path", actual_file_paths[index]
|
||||
@@ -2788,6 +2802,15 @@ class DownloadManager:
|
||||
if scanner is not None:
|
||||
await scanner.add_model_to_cache(metadata_dict, relative_path)
|
||||
|
||||
downloaded_metadata.append(metadata_dict)
|
||||
|
||||
await self._apply_download_filename_template(
|
||||
scanner=scanner,
|
||||
model_type=model_type,
|
||||
downloaded_metadata=downloaded_metadata,
|
||||
download_id=download_id,
|
||||
)
|
||||
|
||||
if transfer_backend == "aria2" and download_id:
|
||||
await self._aria2_state_store.remove(download_id)
|
||||
|
||||
@@ -2827,6 +2850,83 @@ class DownloadManager:
|
||||
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def _apply_download_filename_template(
|
||||
self,
|
||||
*,
|
||||
scanner,
|
||||
model_type: str,
|
||||
downloaded_metadata: List[Dict[str, Any]],
|
||||
download_id: Optional[str],
|
||||
) -> None:
|
||||
"""Rename freshly downloaded models according to the filename template.
|
||||
|
||||
Best-effort post-download step: any failure (including name conflicts)
|
||||
is logged and skipped so a successful download is never turned into a
|
||||
failure by a rename problem.
|
||||
"""
|
||||
try:
|
||||
if scanner is None or not downloaded_metadata:
|
||||
return
|
||||
|
||||
template = get_settings_manager().get_download_filename_template(
|
||||
model_type
|
||||
)
|
||||
if not template:
|
||||
return
|
||||
|
||||
lifecycle_service = ModelLifecycleService(
|
||||
scanner=scanner,
|
||||
metadata_manager=MetadataManager,
|
||||
metadata_loader=load_local_metadata,
|
||||
recipe_scanner_factory=ServiceRegistry.get_recipe_scanner,
|
||||
)
|
||||
|
||||
for metadata_dict in downloaded_metadata:
|
||||
file_path = metadata_dict.get("file_path")
|
||||
if not isinstance(file_path, str) or not file_path:
|
||||
continue
|
||||
|
||||
new_stem = calculate_filename_for_model(metadata_dict, model_type)
|
||||
if not new_stem:
|
||||
continue
|
||||
|
||||
current_stem = os.path.splitext(os.path.basename(file_path))[0]
|
||||
if new_stem == current_stem or os.path.normcase(
|
||||
new_stem
|
||||
) == os.path.normcase(current_stem):
|
||||
continue
|
||||
|
||||
try:
|
||||
result = await lifecycle_service.rename_model(
|
||||
file_path=file_path, new_file_name=new_stem
|
||||
)
|
||||
except ValueError as exc:
|
||||
logger.warning(
|
||||
"Keeping original filename for %s: %s", file_path, exc
|
||||
)
|
||||
continue
|
||||
|
||||
new_file_path = result.get("new_file_path")
|
||||
if download_id and isinstance(new_file_path, str):
|
||||
info = self._active_downloads.get(download_id)
|
||||
if info is None:
|
||||
continue
|
||||
if info.get("file_path") == file_path:
|
||||
info["file_path"] = new_file_path
|
||||
extracted = info.get("extracted_paths")
|
||||
if isinstance(extracted, list):
|
||||
info["extracted_paths"] = [
|
||||
new_file_path if path == file_path else path
|
||||
for path in extracted
|
||||
]
|
||||
except Exception as exc: # Rename phase must never fail the download
|
||||
logger.warning(
|
||||
"Filename template rename failed for %s download: %s",
|
||||
model_type,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def _get_supported_extensions_for_type(self, model_type: str) -> Set[str]:
|
||||
if model_type in ("checkpoint", "other"):
|
||||
return {
|
||||
|
||||
@@ -69,6 +69,8 @@ class EmbeddingService(BaseModelService):
|
||||
"version_count": model_data.get("version_count"),
|
||||
"source_platform": model_data.get("source_platform", ""),
|
||||
"source_url": model_data.get("source_url", ""),
|
||||
"source_model_id": model_data.get("source_model_id", ""),
|
||||
"source_version_id": model_data.get("source_version_id", ""),
|
||||
"hf_url": model_data.get("hf_url", ""),
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,8 @@ class LoraService(BaseModelService):
|
||||
"version_count": model_data.get("version_count"),
|
||||
"source_platform": model_data.get("source_platform", ""),
|
||||
"source_url": model_data.get("source_url", ""),
|
||||
"source_model_id": model_data.get("source_model_id", ""),
|
||||
"source_version_id": model_data.get("source_version_id", ""),
|
||||
"hf_url": model_data.get("hf_url", ""),
|
||||
}
|
||||
|
||||
@@ -714,12 +716,18 @@ class LoraService(BaseModelService):
|
||||
),
|
||||
)
|
||||
|
||||
# Return minimal data needed for cycling
|
||||
return [
|
||||
{
|
||||
# Return minimal data needed for cycling. usage_tips is only included
|
||||
# when non-empty so widget consumers (recommended strength range cues)
|
||||
# can build their lookup without inflating the payload.
|
||||
result = []
|
||||
for lora in available_loras:
|
||||
entry = {
|
||||
"file_name": f"{lora['folder']}/{lora['file_name']}" if lora.get("folder") else lora["file_name"],
|
||||
"model_name": lora.get("model_name", lora["file_name"]),
|
||||
"folder": lora.get("folder", ""),
|
||||
}
|
||||
for lora in available_loras
|
||||
]
|
||||
usage_tips = lora.get("usage_tips")
|
||||
if usage_tips:
|
||||
entry["usage_tips"] = usage_tips
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
@@ -43,10 +43,22 @@ class AutoOrganizeResult:
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert result to dictionary"""
|
||||
if self.operation_type == 'filename_template':
|
||||
message = (
|
||||
f'Filename template applied: {self.success_count} renamed, '
|
||||
f'{self.skipped_count} skipped, {self.failure_count} failed '
|
||||
f'out of {self.total} total'
|
||||
)
|
||||
else:
|
||||
message = (
|
||||
f'Auto-organize {self.operation_type} completed: '
|
||||
f'{self.success_count} moved, {self.skipped_count} skipped, '
|
||||
f'{self.failure_count} failed out of {self.total} total'
|
||||
)
|
||||
result: Dict[str, Any] = {
|
||||
'success': self.status != 'error',
|
||||
'status': self.status,
|
||||
'message': f'Auto-organize {self.operation_type} completed: {self.success_count} moved, {self.skipped_count} skipped, {self.failure_count} failed out of {self.total} total',
|
||||
'message': message,
|
||||
'summary': {
|
||||
'total': self.total,
|
||||
'success': self.success_count,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Optional, TYPE_CHECKING, cast
|
||||
@@ -17,6 +18,26 @@ if TYPE_CHECKING:
|
||||
from ..services.model_update_service import ModelUpdateService
|
||||
|
||||
|
||||
async def load_local_metadata(metadata_path: str) -> Dict[str, Any]:
|
||||
"""Load a metadata sidecar JSON, returning an empty dict when missing.
|
||||
|
||||
Thin equivalent of ``MetadataSyncService.load_local_metadata`` for callers
|
||||
(download manager, use cases) that do not hold a sync-service instance.
|
||||
"""
|
||||
|
||||
if not os.path.exists(metadata_path):
|
||||
return {}
|
||||
|
||||
try:
|
||||
with open(metadata_path, "r", encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to load metadata from %s: %s", metadata_path, exc)
|
||||
return {}
|
||||
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
|
||||
async def delete_model_artifacts(
|
||||
target_dir: str, file_name: str, main_extension: str | None = None
|
||||
) -> List[str]:
|
||||
@@ -404,6 +425,9 @@ class ModelLifecycleService:
|
||||
if metadata and new_metadata_path:
|
||||
metadata["file_name"] = new_file_name
|
||||
metadata["file_path"] = new_file_path
|
||||
# Preserve the pre-rename stem so the original download filename
|
||||
# stays recoverable after template-driven renames.
|
||||
metadata.setdefault("original_file_name", old_file_name)
|
||||
|
||||
if metadata.get("preview_url"):
|
||||
old_preview = str(metadata["preview_url"])
|
||||
|
||||
@@ -399,10 +399,14 @@ class ModelScanner:
|
||||
'skip_metadata_refresh': bool(get_value('skip_metadata_refresh', False)),
|
||||
# External model source (Hugging Face / ModelScope / TensorArt).
|
||||
# `source_url` + `source_platform` are canonical; `hf_url` stays in
|
||||
# sync as a legacy alias (normalised below).
|
||||
# sync as a legacy alias (normalised below). `source_model_id` /
|
||||
# `source_version_id` are the site-native identity ids version
|
||||
# grouping keys off (ModelScope; empty elsewhere).
|
||||
'source_platform': get_value('source_platform', '') or '',
|
||||
'source_url': get_value('source_url', '') or '',
|
||||
'hf_url': get_value('hf_url', '') or '',
|
||||
'source_model_id': get_value('source_model_id', '') or '',
|
||||
'source_version_id': get_value('source_version_id', '') or '',
|
||||
}
|
||||
normalize_metadata_source(entry)
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Iterable, Optional
|
||||
from typing import Any, Dict, Iterable, Mapping, Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
@@ -123,6 +123,20 @@ class ModelCardContext:
|
||||
trigger_words: list[str] = field(default_factory=list)
|
||||
"""Trigger words the site records for the requested model file."""
|
||||
|
||||
source_model_id: str = ""
|
||||
"""Site-native id of the *published model* the requested file belongs to.
|
||||
|
||||
Sites whose repository is not a model identity publish a separate,
|
||||
stable id per model (ModelScope's ``modelVersion.modelId`` — identical
|
||||
across every version of one published model, different between the
|
||||
models of a collection repository). It is the version-grouping key,
|
||||
persisted on the sidecar as ``source_model_id``.
|
||||
"""
|
||||
|
||||
source_version_id: str = ""
|
||||
"""Site-native id of the published version the requested file belongs to
|
||||
(ModelScope's ``modelVersion.id``), persisted as ``source_version_id``."""
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
"""Return ``True`` when the site contributed nothing extra."""
|
||||
|
||||
@@ -139,6 +153,8 @@ class ModelCardContext:
|
||||
self.official_tags,
|
||||
self.example_images,
|
||||
self.trigger_words,
|
||||
self.source_model_id,
|
||||
self.source_version_id,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -193,7 +209,9 @@ def is_valid_source_id(source_id: str) -> bool:
|
||||
)
|
||||
|
||||
|
||||
async def fetch_text(url: str, *, timeout: int = HTTP_TIMEOUT) -> str:
|
||||
async def fetch_text(
|
||||
url: str, *, timeout: int = HTTP_TIMEOUT, headers: Optional[Dict[str, str]] = None
|
||||
) -> str:
|
||||
"""Fetch *url* and return its body as text, or ``""`` on any failure.
|
||||
|
||||
Network problems are expected (offline installs, rate limits, dead
|
||||
@@ -202,8 +220,11 @@ async def fetch_text(url: str, *, timeout: int = HTTP_TIMEOUT) -> str:
|
||||
"""
|
||||
|
||||
try:
|
||||
request_headers = {"User-Agent": USER_AGENT}
|
||||
if headers:
|
||||
request_headers.update(headers)
|
||||
async with aiohttp.ClientSession(
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
headers=request_headers,
|
||||
timeout=aiohttp.ClientTimeout(total=timeout),
|
||||
) as session:
|
||||
async with session.get(url) as resp:
|
||||
@@ -216,7 +237,7 @@ async def fetch_text(url: str, *, timeout: int = HTTP_TIMEOUT) -> str:
|
||||
|
||||
|
||||
async def fetch_json(
|
||||
url: str, *, timeout: int = HTTP_TIMEOUT
|
||||
url: str, *, timeout: int = HTTP_TIMEOUT, headers: Optional[Dict[str, str]] = None
|
||||
) -> tuple[int, Any]:
|
||||
"""Fetch *url* and return ``(status, parsed_body)``.
|
||||
|
||||
@@ -227,8 +248,11 @@ async def fetch_json(
|
||||
"""
|
||||
|
||||
try:
|
||||
request_headers = {"User-Agent": USER_AGENT}
|
||||
if headers:
|
||||
request_headers.update(headers)
|
||||
async with aiohttp.ClientSession(
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
headers=request_headers,
|
||||
timeout=aiohttp.ClientTimeout(total=timeout),
|
||||
) as session:
|
||||
async with session.get(url) as resp:
|
||||
@@ -321,11 +345,20 @@ class ModelSource:
|
||||
|
||||
return ""
|
||||
|
||||
def group_key(self, source_id: str) -> str:
|
||||
"""Return the version-group key for *source_id*."""
|
||||
def group_key(self, ref: SourceRef, item: Mapping[str, Any]) -> Optional[str]:
|
||||
"""Return the version-group key for the model described by *item*.
|
||||
|
||||
The default groups by source id (``{prefix}:{owner}/{repo}``), which
|
||||
is only correct when the source id already identifies a single
|
||||
published model. Sources whose repository hosts many unrelated
|
||||
models override this: they either derive the key from a site-native
|
||||
model identity recorded in *item* (ModelScope's ``source_model_id``)
|
||||
or return ``None`` when the platform has no reliable model identity
|
||||
at all (Hugging Face), leaving the model ungrouped.
|
||||
"""
|
||||
|
||||
prefix = GROUP_PREFIXES.get(self.platform, self.platform)
|
||||
return f"{prefix}:{source_id}"
|
||||
return f"{prefix}:{ref.source_id}"
|
||||
|
||||
async def fetch_model_card(self, source_id: str) -> str:
|
||||
"""Fetch the raw model card (README) markdown for *source_id*."""
|
||||
@@ -381,6 +414,15 @@ class ModelSource:
|
||||
|
||||
return []
|
||||
|
||||
def auth_headers(self) -> Dict[str, str]:
|
||||
"""Extra request headers this site needs for API and file downloads.
|
||||
|
||||
Empty by default; sites with gated/private content (Hugging Face)
|
||||
override it to attach the user's access token when one is configured.
|
||||
"""
|
||||
|
||||
return {}
|
||||
|
||||
def file_download_url(
|
||||
self, source_id: str, filename: str, revision: str = ""
|
||||
) -> str:
|
||||
|
||||
@@ -4,10 +4,12 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Mapping, Optional
|
||||
|
||||
from .base import (
|
||||
ModelSource,
|
||||
ModelSourceError,
|
||||
SourceRef,
|
||||
fetch_json,
|
||||
fetch_text,
|
||||
filter_weight_files,
|
||||
@@ -27,6 +29,18 @@ _STRICT_URL_PATTERN = re.compile(
|
||||
)
|
||||
|
||||
|
||||
def _hf_token() -> str:
|
||||
"""Return the configured Hugging Face access token, or ``""``."""
|
||||
|
||||
try:
|
||||
from ..settings_manager import get_settings_manager
|
||||
|
||||
token = get_settings_manager().get("huggingface_api_key", "")
|
||||
except Exception: # pragma: no cover - settings must never break downloads
|
||||
return ""
|
||||
return token.strip() if isinstance(token, str) else ""
|
||||
|
||||
|
||||
class HuggingFaceSource(ModelSource):
|
||||
"""Hugging Face Hub (``huggingface.co``)."""
|
||||
|
||||
@@ -42,15 +56,33 @@ class HuggingFaceSource(ModelSource):
|
||||
def canonical_url(self, source_id: str) -> str:
|
||||
return f"https://huggingface.co/{source_id}"
|
||||
|
||||
def group_key(self, ref: SourceRef, item: Mapping[str, Any]) -> Optional[str]:
|
||||
"""Hugging Face models never auto-group.
|
||||
|
||||
A repository is not a model identity — collection repos host many
|
||||
unrelated models — and the Hub exposes no site-native published-model
|
||||
id, so there is no reliable key to group by.
|
||||
"""
|
||||
|
||||
return None
|
||||
|
||||
def asset_base_url(self, source_id: str, revision: str = "") -> str:
|
||||
return f"https://huggingface.co/{source_id}/resolve/{self.resolve_revision(revision)}"
|
||||
|
||||
def auth_headers(self) -> dict[str, str]:
|
||||
"""Bearer header for gated/private repositories, when a token is set."""
|
||||
|
||||
token = _hf_token()
|
||||
return {"Authorization": f"Bearer {token}"} if token else {}
|
||||
|
||||
async def fetch_model_card(self, source_id: str) -> str:
|
||||
"""Fetch ``README.md`` from Hugging Face (tries ``main``, then ``master``)."""
|
||||
|
||||
headers = self.auth_headers()
|
||||
for branch in ("main", "master"):
|
||||
text = await fetch_text(
|
||||
f"https://huggingface.co/{source_id}/raw/{branch}/README.md"
|
||||
f"https://huggingface.co/{source_id}/raw/{branch}/README.md",
|
||||
headers=headers,
|
||||
)
|
||||
if text:
|
||||
return text
|
||||
@@ -67,11 +99,26 @@ class HuggingFaceSource(ModelSource):
|
||||
|
||||
revision = self.resolve_revision(revision)
|
||||
status, payload = await fetch_json(
|
||||
f"https://huggingface.co/api/models/{source_id}/tree/{revision}"
|
||||
f"https://huggingface.co/api/models/{source_id}/tree/{revision}",
|
||||
headers=self.auth_headers(),
|
||||
)
|
||||
|
||||
if status == 404:
|
||||
raise ModelSourceError(f"Repository '{source_id}' not found", status=404)
|
||||
if status in (401, 403):
|
||||
if _hf_token():
|
||||
raise ModelSourceError(
|
||||
f"Access to '{source_id}' was denied (HTTP {status}). For a gated "
|
||||
"repository you must accept its terms on the Hugging Face page, "
|
||||
"and the configured token needs read permission for it.",
|
||||
status=403,
|
||||
)
|
||||
raise ModelSourceError(
|
||||
f"'{source_id}' requires a Hugging Face access token (gated or "
|
||||
"private repository). Configure one in Settings → Hugging Face "
|
||||
"Access Token, and accept the repository's terms on its page.",
|
||||
status=401,
|
||||
)
|
||||
if status != 200 or not isinstance(payload, list):
|
||||
raise ModelSourceError(
|
||||
f"Hugging Face API error while listing '{source_id}' (HTTP {status})"
|
||||
|
||||
@@ -44,12 +44,15 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any, Iterable, Optional
|
||||
from typing import TYPE_CHECKING, Any, Iterable, Mapping, Optional
|
||||
|
||||
from .base import (
|
||||
GROUP_PREFIXES,
|
||||
ModelCardContext,
|
||||
ModelSource,
|
||||
ModelSourceError,
|
||||
SourceRef,
|
||||
clean_source_url,
|
||||
fetch_json,
|
||||
fetch_text,
|
||||
filter_weight_files,
|
||||
@@ -111,6 +114,22 @@ class ModelScopeSource(ModelSource):
|
||||
def canonical_url(self, source_id: str) -> str:
|
||||
return f"{self.base_url}/models/{source_id}"
|
||||
|
||||
def group_key(self, ref: SourceRef, item: Mapping[str, Any]) -> Optional[str]:
|
||||
"""Group by ModelScope's published-model id, never by repository.
|
||||
|
||||
A collection repository hosts many unrelated published models, so
|
||||
the repo id is not a version-group identity. Only models whose
|
||||
metadata carries the site-native ``source_model_id`` (recorded at
|
||||
enrichment time from ``MuseInfo.versions[].modelVersion.modelId``)
|
||||
group together; unenriched models stay standalone.
|
||||
"""
|
||||
|
||||
model_id = clean_source_url(item.get("source_model_id"))
|
||||
if not model_id:
|
||||
return None
|
||||
prefix = GROUP_PREFIXES.get(self.platform, self.platform)
|
||||
return f"{prefix}:{model_id}"
|
||||
|
||||
def asset_base_url(self, source_id: str, revision: str = "") -> str:
|
||||
return (
|
||||
f"{self.base_url}/models/{source_id}/resolve/"
|
||||
@@ -350,9 +369,37 @@ def _build_card_context(
|
||||
context.version_name = _version_label(versions)
|
||||
context.example_images = _cover_image_urls(versions)
|
||||
context.trigger_words = _version_trigger_words(versions)
|
||||
context.source_model_id, context.source_version_id = _version_identity(
|
||||
versions
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
def _version_identity(versions: list[dict[str, Any]]) -> tuple[str, str]:
|
||||
"""Return the site-native ``(model id, version id)`` of the first match.
|
||||
|
||||
``modelVersion.modelId`` is identical across every version of one
|
||||
published model and differs between the models of a collection
|
||||
repository, which makes it the version-grouping identity;
|
||||
``modelVersion.id`` identifies the version itself. Both are ints in
|
||||
the payload and are stored as strings.
|
||||
"""
|
||||
|
||||
for version in versions:
|
||||
model_version = version.get("modelVersion")
|
||||
if not isinstance(model_version, dict):
|
||||
continue
|
||||
model_id = model_version.get("modelId")
|
||||
version_id = model_version.get("id")
|
||||
if model_id is None and version_id is None:
|
||||
continue
|
||||
return (
|
||||
str(model_id) if model_id is not None else "",
|
||||
str(version_id) if version_id is not None else "",
|
||||
)
|
||||
return "", ""
|
||||
|
||||
|
||||
def _base_model_aliases(data: dict[str, Any]) -> list[str]:
|
||||
"""Return the site's own names for the base model.
|
||||
|
||||
|
||||
@@ -196,8 +196,13 @@ def get_source_platform(item: Mapping[str, Any]) -> str:
|
||||
def source_group_key(item: Mapping[str, Any]) -> Optional[str]:
|
||||
"""Return the version-group key for *item*, or ``None``.
|
||||
|
||||
Hugging Face keeps the historical ``hf:{owner}/{repo}`` shape; other
|
||||
platforms use their own short prefix (see :data:`GROUP_PREFIXES`).
|
||||
Only sources with a site-native model identity yield a key: TensorArt
|
||||
groups by its numeric model id (``ta:<id>``) and ModelScope by the
|
||||
published-model id recorded at enrichment time (``ms:<id>`` /
|
||||
``msai:<id>``). Hugging Face yields no key at all — a repository is
|
||||
not a model identity — and unenriched ModelScope models stay
|
||||
standalone rather than collapsing a whole collection repository into
|
||||
one group.
|
||||
"""
|
||||
|
||||
ref = resolve_source_ref(item)
|
||||
@@ -206,7 +211,7 @@ def source_group_key(item: Mapping[str, Any]) -> Optional[str]:
|
||||
source = get_source(ref.platform)
|
||||
if source is None:
|
||||
return None
|
||||
return source.group_key(ref.source_id)
|
||||
return source.group_key(ref, item)
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
||||
@@ -69,6 +69,8 @@ class OtherModelService(BaseModelService):
|
||||
"version_count": model_data.get("version_count"),
|
||||
"source_platform": model_data.get("source_platform", ""),
|
||||
"source_url": model_data.get("source_url", ""),
|
||||
"source_model_id": model_data.get("source_model_id", ""),
|
||||
"source_version_id": model_data.get("source_version_id", ""),
|
||||
"hf_url": model_data.get("hf_url", ""),
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
|
||||
|
||||
from ..utils.cache_db import connect_cache_db
|
||||
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
|
||||
from ..utils.file_lock import exclusive_lock
|
||||
from .model_sources import normalize_metadata_source
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -66,6 +68,8 @@ class PersistentModelCache:
|
||||
"source_platform",
|
||||
"source_url",
|
||||
"hf_url",
|
||||
"source_model_id",
|
||||
"source_version_id",
|
||||
)
|
||||
_MODEL_UPDATE_COLUMNS: Tuple[str, ...] = _MODEL_COLUMNS[2:]
|
||||
_instances: Dict[str, "PersistentModelCache"] = {}
|
||||
@@ -212,6 +216,8 @@ class PersistentModelCache:
|
||||
"source_platform": row["source_platform"] or "",
|
||||
"source_url": row["source_url"] or "",
|
||||
"hf_url": row["hf_url"] or "",
|
||||
"source_model_id": row["source_model_id"] or "",
|
||||
"source_version_id": row["source_version_id"] or "",
|
||||
}
|
||||
# Legacy rows only carry `hf_url`; derive the canonical pair so
|
||||
# every consumer sees the same shape.
|
||||
@@ -257,267 +263,271 @@ class PersistentModelCache:
|
||||
return
|
||||
try:
|
||||
with self._db_lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.execute("BEGIN")
|
||||
# Cross-process serialization: another LoRA Manager instance may
|
||||
# share this settings directory, and the read-merge-write below
|
||||
# spans several statements.
|
||||
with exclusive_lock(self._db_path):
|
||||
conn = self._connect()
|
||||
try:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.execute("BEGIN")
|
||||
|
||||
model_rows = [self._prepare_model_row(model_type, item) for item in raw_data]
|
||||
model_map: Dict[str, Tuple[Any, ...]] = {
|
||||
row[1]: row for row in model_rows if row[1] # row[1] is file_path
|
||||
}
|
||||
model_rows = [self._prepare_model_row(model_type, item) for item in raw_data]
|
||||
model_map: Dict[str, Tuple[Any, ...]] = {
|
||||
row[1]: row for row in model_rows if row[1] # row[1] is file_path
|
||||
}
|
||||
|
||||
existing_models = conn.execute(
|
||||
"SELECT "
|
||||
+ ", ".join(self._MODEL_COLUMNS[1:])
|
||||
+ " FROM models WHERE model_type = ?",
|
||||
(model_type,),
|
||||
).fetchall()
|
||||
existing_model_map: Dict[str, sqlite3.Row] = {
|
||||
row["file_path"]: row for row in existing_models
|
||||
}
|
||||
|
||||
to_remove_models = [
|
||||
(model_type, path)
|
||||
for path in existing_model_map.keys()
|
||||
if path not in model_map
|
||||
]
|
||||
if to_remove_models:
|
||||
conn.executemany(
|
||||
"DELETE FROM models WHERE model_type = ? AND file_path = ?",
|
||||
to_remove_models,
|
||||
)
|
||||
conn.executemany(
|
||||
"DELETE FROM model_tags WHERE model_type = ? AND file_path = ?",
|
||||
to_remove_models,
|
||||
)
|
||||
conn.executemany(
|
||||
"DELETE FROM hash_index WHERE model_type = ? AND file_path = ?",
|
||||
to_remove_models,
|
||||
)
|
||||
conn.executemany(
|
||||
"DELETE FROM autov3_index WHERE model_type = ? AND file_path = ?",
|
||||
to_remove_models,
|
||||
)
|
||||
conn.executemany(
|
||||
"DELETE FROM excluded_models WHERE model_type = ? AND file_path = ?",
|
||||
to_remove_models,
|
||||
)
|
||||
|
||||
insert_rows: List[Tuple[Any, ...]] = []
|
||||
update_rows: List[Tuple[Any, ...]] = []
|
||||
|
||||
for file_path, row in model_map.items():
|
||||
existing = existing_model_map.get(file_path)
|
||||
if existing is None:
|
||||
insert_rows.append(row)
|
||||
continue
|
||||
|
||||
existing_values = tuple(
|
||||
existing[column] for column in self._MODEL_COLUMNS[1:]
|
||||
)
|
||||
current_values = row[1:]
|
||||
if existing_values != current_values:
|
||||
update_rows.append(row[2:] + (model_type, file_path))
|
||||
|
||||
if insert_rows:
|
||||
conn.executemany(self._insert_model_sql(), insert_rows)
|
||||
|
||||
if update_rows:
|
||||
set_clause = ", ".join(
|
||||
f"{column} = ?"
|
||||
for column in self._MODEL_UPDATE_COLUMNS
|
||||
)
|
||||
update_sql = (
|
||||
f"UPDATE models SET {set_clause} WHERE model_type = ? AND file_path = ?"
|
||||
)
|
||||
conn.executemany(update_sql, update_rows)
|
||||
|
||||
existing_tags_rows = conn.execute(
|
||||
"SELECT file_path, tag FROM model_tags WHERE model_type = ?",
|
||||
(model_type,),
|
||||
).fetchall()
|
||||
existing_tags: Dict[str, set[str]] = {}
|
||||
for row in existing_tags_rows:
|
||||
existing_tags.setdefault(row["file_path"], set()).add(row["tag"])
|
||||
|
||||
new_tags: Dict[str, set[str]] = {}
|
||||
for item in raw_data:
|
||||
file_path = item.get("file_path")
|
||||
if not file_path:
|
||||
continue
|
||||
tags = set(item.get("tags") or [])
|
||||
if tags:
|
||||
new_tags[file_path] = tags
|
||||
|
||||
tag_inserts: List[Tuple[str, str, str]] = []
|
||||
tag_deletes: List[Tuple[str, str, str]] = []
|
||||
|
||||
all_tag_paths = set(existing_tags.keys()) | set(new_tags.keys())
|
||||
for path in all_tag_paths:
|
||||
existing_set = existing_tags.get(path, set())
|
||||
new_set = new_tags.get(path, set())
|
||||
to_add = new_set - existing_set
|
||||
to_remove = existing_set - new_set
|
||||
|
||||
for tag in to_add:
|
||||
tag_inserts.append((model_type, path, tag))
|
||||
for tag in to_remove:
|
||||
tag_deletes.append((model_type, path, tag))
|
||||
|
||||
if tag_deletes:
|
||||
conn.executemany(
|
||||
"DELETE FROM model_tags WHERE model_type = ? AND file_path = ? AND tag = ?",
|
||||
tag_deletes,
|
||||
)
|
||||
if tag_inserts:
|
||||
conn.executemany(
|
||||
"INSERT INTO model_tags (model_type, file_path, tag) VALUES (?, ?, ?)",
|
||||
tag_inserts,
|
||||
)
|
||||
|
||||
existing_hash_rows = conn.execute(
|
||||
"SELECT sha256, file_path FROM hash_index WHERE model_type = ?",
|
||||
(model_type,),
|
||||
).fetchall()
|
||||
existing_hash_map: Dict[str, set[str]] = {}
|
||||
for row in existing_hash_rows:
|
||||
sha_value = (row["sha256"] or "").lower()
|
||||
if not sha_value:
|
||||
continue
|
||||
existing_hash_map.setdefault(sha_value, set()).add(row["file_path"])
|
||||
|
||||
new_hash_map: Dict[str, set[str]] = {}
|
||||
for sha_value, paths in hash_index.items():
|
||||
normalized_sha = (sha_value or "").lower()
|
||||
if not normalized_sha:
|
||||
continue
|
||||
bucket = new_hash_map.setdefault(normalized_sha, set())
|
||||
for path in paths:
|
||||
if path:
|
||||
bucket.add(path)
|
||||
|
||||
hash_inserts: List[Tuple[str, str, str]] = []
|
||||
hash_deletes: List[Tuple[str, str, str]] = []
|
||||
|
||||
all_shas = set(existing_hash_map.keys()) | set(new_hash_map.keys())
|
||||
for sha_value in all_shas:
|
||||
existing_paths = existing_hash_map.get(sha_value, set())
|
||||
new_paths = new_hash_map.get(sha_value, set())
|
||||
|
||||
for path in existing_paths - new_paths:
|
||||
hash_deletes.append((model_type, sha_value, path))
|
||||
for path in new_paths - existing_paths:
|
||||
hash_inserts.append((model_type, sha_value, path))
|
||||
|
||||
if hash_deletes:
|
||||
conn.executemany(
|
||||
"DELETE FROM hash_index WHERE model_type = ? AND sha256 = ? AND file_path = ?",
|
||||
hash_deletes,
|
||||
)
|
||||
if hash_inserts:
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO hash_index (model_type, sha256, file_path) VALUES (?, ?, ?)",
|
||||
hash_inserts,
|
||||
)
|
||||
|
||||
if autov3_hash_index is not None:
|
||||
existing_autov3_rows = conn.execute(
|
||||
"SELECT autov3, file_path FROM autov3_index WHERE model_type = ?",
|
||||
existing_models = conn.execute(
|
||||
"SELECT "
|
||||
+ ", ".join(self._MODEL_COLUMNS[1:])
|
||||
+ " FROM models WHERE model_type = ?",
|
||||
(model_type,),
|
||||
).fetchall()
|
||||
existing_autov3_map: Dict[str, set[str]] = {}
|
||||
for row in existing_autov3_rows:
|
||||
autov3_value = (row["autov3"] or "").lower()
|
||||
if not autov3_value:
|
||||
continue
|
||||
existing_autov3_map.setdefault(autov3_value, set()).add(row["file_path"])
|
||||
existing_model_map: Dict[str, sqlite3.Row] = {
|
||||
row["file_path"]: row for row in existing_models
|
||||
}
|
||||
|
||||
new_autov3_map: Dict[str, set[str]] = {}
|
||||
for autov3_value, paths in autov3_hash_index.items():
|
||||
normalized_autov3 = (autov3_value or "").lower()
|
||||
if not normalized_autov3:
|
||||
to_remove_models = [
|
||||
(model_type, path)
|
||||
for path in existing_model_map.keys()
|
||||
if path not in model_map
|
||||
]
|
||||
if to_remove_models:
|
||||
conn.executemany(
|
||||
"DELETE FROM models WHERE model_type = ? AND file_path = ?",
|
||||
to_remove_models,
|
||||
)
|
||||
conn.executemany(
|
||||
"DELETE FROM model_tags WHERE model_type = ? AND file_path = ?",
|
||||
to_remove_models,
|
||||
)
|
||||
conn.executemany(
|
||||
"DELETE FROM hash_index WHERE model_type = ? AND file_path = ?",
|
||||
to_remove_models,
|
||||
)
|
||||
conn.executemany(
|
||||
"DELETE FROM autov3_index WHERE model_type = ? AND file_path = ?",
|
||||
to_remove_models,
|
||||
)
|
||||
conn.executemany(
|
||||
"DELETE FROM excluded_models WHERE model_type = ? AND file_path = ?",
|
||||
to_remove_models,
|
||||
)
|
||||
|
||||
insert_rows: List[Tuple[Any, ...]] = []
|
||||
update_rows: List[Tuple[Any, ...]] = []
|
||||
|
||||
for file_path, row in model_map.items():
|
||||
existing = existing_model_map.get(file_path)
|
||||
if existing is None:
|
||||
insert_rows.append(row)
|
||||
continue
|
||||
bucket = new_autov3_map.setdefault(normalized_autov3, set())
|
||||
|
||||
existing_values = tuple(
|
||||
existing[column] for column in self._MODEL_COLUMNS[1:]
|
||||
)
|
||||
current_values = row[1:]
|
||||
if existing_values != current_values:
|
||||
update_rows.append(row[2:] + (model_type, file_path))
|
||||
|
||||
if insert_rows:
|
||||
conn.executemany(self._insert_model_sql(), insert_rows)
|
||||
|
||||
if update_rows:
|
||||
set_clause = ", ".join(
|
||||
f"{column} = ?"
|
||||
for column in self._MODEL_UPDATE_COLUMNS
|
||||
)
|
||||
update_sql = (
|
||||
f"UPDATE models SET {set_clause} WHERE model_type = ? AND file_path = ?"
|
||||
)
|
||||
conn.executemany(update_sql, update_rows)
|
||||
|
||||
existing_tags_rows = conn.execute(
|
||||
"SELECT file_path, tag FROM model_tags WHERE model_type = ?",
|
||||
(model_type,),
|
||||
).fetchall()
|
||||
existing_tags: Dict[str, set[str]] = {}
|
||||
for row in existing_tags_rows:
|
||||
existing_tags.setdefault(row["file_path"], set()).add(row["tag"])
|
||||
|
||||
new_tags: Dict[str, set[str]] = {}
|
||||
for item in raw_data:
|
||||
file_path = item.get("file_path")
|
||||
if not file_path:
|
||||
continue
|
||||
tags = set(item.get("tags") or [])
|
||||
if tags:
|
||||
new_tags[file_path] = tags
|
||||
|
||||
tag_inserts: List[Tuple[str, str, str]] = []
|
||||
tag_deletes: List[Tuple[str, str, str]] = []
|
||||
|
||||
all_tag_paths = set(existing_tags.keys()) | set(new_tags.keys())
|
||||
for path in all_tag_paths:
|
||||
existing_set = existing_tags.get(path, set())
|
||||
new_set = new_tags.get(path, set())
|
||||
to_add = new_set - existing_set
|
||||
to_remove = existing_set - new_set
|
||||
|
||||
for tag in to_add:
|
||||
tag_inserts.append((model_type, path, tag))
|
||||
for tag in to_remove:
|
||||
tag_deletes.append((model_type, path, tag))
|
||||
|
||||
if tag_deletes:
|
||||
conn.executemany(
|
||||
"DELETE FROM model_tags WHERE model_type = ? AND file_path = ? AND tag = ?",
|
||||
tag_deletes,
|
||||
)
|
||||
if tag_inserts:
|
||||
conn.executemany(
|
||||
"INSERT INTO model_tags (model_type, file_path, tag) VALUES (?, ?, ?)",
|
||||
tag_inserts,
|
||||
)
|
||||
|
||||
existing_hash_rows = conn.execute(
|
||||
"SELECT sha256, file_path FROM hash_index WHERE model_type = ?",
|
||||
(model_type,),
|
||||
).fetchall()
|
||||
existing_hash_map: Dict[str, set[str]] = {}
|
||||
for row in existing_hash_rows:
|
||||
sha_value = (row["sha256"] or "").lower()
|
||||
if not sha_value:
|
||||
continue
|
||||
existing_hash_map.setdefault(sha_value, set()).add(row["file_path"])
|
||||
|
||||
new_hash_map: Dict[str, set[str]] = {}
|
||||
for sha_value, paths in hash_index.items():
|
||||
normalized_sha = (sha_value or "").lower()
|
||||
if not normalized_sha:
|
||||
continue
|
||||
bucket = new_hash_map.setdefault(normalized_sha, set())
|
||||
for path in paths:
|
||||
if path:
|
||||
bucket.add(path)
|
||||
|
||||
autov3_inserts: List[Tuple[str, str, str]] = []
|
||||
autov3_deletes: List[Tuple[str, str, str]] = []
|
||||
hash_inserts: List[Tuple[str, str, str]] = []
|
||||
hash_deletes: List[Tuple[str, str, str]] = []
|
||||
|
||||
all_autov3 = set(existing_autov3_map.keys()) | set(new_autov3_map.keys())
|
||||
for autov3_value in all_autov3:
|
||||
existing_paths = existing_autov3_map.get(autov3_value, set())
|
||||
new_paths = new_autov3_map.get(autov3_value, set())
|
||||
all_shas = set(existing_hash_map.keys()) | set(new_hash_map.keys())
|
||||
for sha_value in all_shas:
|
||||
existing_paths = existing_hash_map.get(sha_value, set())
|
||||
new_paths = new_hash_map.get(sha_value, set())
|
||||
|
||||
for path in existing_paths - new_paths:
|
||||
autov3_deletes.append((model_type, autov3_value, path))
|
||||
hash_deletes.append((model_type, sha_value, path))
|
||||
for path in new_paths - existing_paths:
|
||||
autov3_inserts.append((model_type, autov3_value, path))
|
||||
hash_inserts.append((model_type, sha_value, path))
|
||||
|
||||
if autov3_deletes:
|
||||
if hash_deletes:
|
||||
conn.executemany(
|
||||
"DELETE FROM autov3_index WHERE model_type = ? AND autov3 = ? AND file_path = ?",
|
||||
autov3_deletes,
|
||||
"DELETE FROM hash_index WHERE model_type = ? AND sha256 = ? AND file_path = ?",
|
||||
hash_deletes,
|
||||
)
|
||||
if autov3_inserts:
|
||||
if hash_inserts:
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO autov3_index (model_type, autov3, file_path) VALUES (?, ?, ?)",
|
||||
autov3_inserts,
|
||||
"INSERT OR IGNORE INTO hash_index (model_type, sha256, file_path) VALUES (?, ?, ?)",
|
||||
hash_inserts,
|
||||
)
|
||||
|
||||
existing_excluded_rows = conn.execute(
|
||||
"SELECT file_path FROM excluded_models WHERE model_type = ?",
|
||||
(model_type,),
|
||||
).fetchall()
|
||||
existing_excluded = {row["file_path"] for row in existing_excluded_rows}
|
||||
new_excluded = {path for path in excluded_models if path}
|
||||
if autov3_hash_index is not None:
|
||||
existing_autov3_rows = conn.execute(
|
||||
"SELECT autov3, file_path FROM autov3_index WHERE model_type = ?",
|
||||
(model_type,),
|
||||
).fetchall()
|
||||
existing_autov3_map: Dict[str, set[str]] = {}
|
||||
for row in existing_autov3_rows:
|
||||
autov3_value = (row["autov3"] or "").lower()
|
||||
if not autov3_value:
|
||||
continue
|
||||
existing_autov3_map.setdefault(autov3_value, set()).add(row["file_path"])
|
||||
|
||||
excluded_deletes = [
|
||||
(model_type, path)
|
||||
for path in existing_excluded - new_excluded
|
||||
]
|
||||
excluded_inserts = [
|
||||
(model_type, path)
|
||||
for path in new_excluded - existing_excluded
|
||||
]
|
||||
new_autov3_map: Dict[str, set[str]] = {}
|
||||
for autov3_value, paths in autov3_hash_index.items():
|
||||
normalized_autov3 = (autov3_value or "").lower()
|
||||
if not normalized_autov3:
|
||||
continue
|
||||
bucket = new_autov3_map.setdefault(normalized_autov3, set())
|
||||
for path in paths:
|
||||
if path:
|
||||
bucket.add(path)
|
||||
|
||||
if excluded_deletes:
|
||||
conn.executemany(
|
||||
"DELETE FROM excluded_models WHERE model_type = ? AND file_path = ?",
|
||||
excluded_deletes,
|
||||
)
|
||||
if excluded_inserts:
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO excluded_models (model_type, file_path) VALUES (?, ?)",
|
||||
excluded_inserts,
|
||||
)
|
||||
autov3_inserts: List[Tuple[str, str, str]] = []
|
||||
autov3_deletes: List[Tuple[str, str, str]] = []
|
||||
|
||||
if all_folders is not None:
|
||||
conn.execute(
|
||||
"DELETE FROM folders WHERE model_type = ?",
|
||||
all_autov3 = set(existing_autov3_map.keys()) | set(new_autov3_map.keys())
|
||||
for autov3_value in all_autov3:
|
||||
existing_paths = existing_autov3_map.get(autov3_value, set())
|
||||
new_paths = new_autov3_map.get(autov3_value, set())
|
||||
|
||||
for path in existing_paths - new_paths:
|
||||
autov3_deletes.append((model_type, autov3_value, path))
|
||||
for path in new_paths - existing_paths:
|
||||
autov3_inserts.append((model_type, autov3_value, path))
|
||||
|
||||
if autov3_deletes:
|
||||
conn.executemany(
|
||||
"DELETE FROM autov3_index WHERE model_type = ? AND autov3 = ? AND file_path = ?",
|
||||
autov3_deletes,
|
||||
)
|
||||
if autov3_inserts:
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO autov3_index (model_type, autov3, file_path) VALUES (?, ?, ?)",
|
||||
autov3_inserts,
|
||||
)
|
||||
|
||||
existing_excluded_rows = conn.execute(
|
||||
"SELECT file_path FROM excluded_models WHERE model_type = ?",
|
||||
(model_type,),
|
||||
)
|
||||
folder_inserts = [
|
||||
(model_type, path) for path in all_folders if path
|
||||
]
|
||||
if folder_inserts:
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO folders (model_type, path) VALUES (?, ?)",
|
||||
folder_inserts,
|
||||
)
|
||||
# Mark the snapshot as having folder data even when the
|
||||
# library has no subfolders, so an empty list is not
|
||||
# mistaken for "never recorded" on load.
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO cache_meta (key, value) VALUES (?, ?)",
|
||||
(f"folders_recorded:{model_type}", "1"),
|
||||
)
|
||||
).fetchall()
|
||||
existing_excluded = {row["file_path"] for row in existing_excluded_rows}
|
||||
new_excluded = {path for path in excluded_models if path}
|
||||
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
excluded_deletes = [
|
||||
(model_type, path)
|
||||
for path in existing_excluded - new_excluded
|
||||
]
|
||||
excluded_inserts = [
|
||||
(model_type, path)
|
||||
for path in new_excluded - existing_excluded
|
||||
]
|
||||
|
||||
if excluded_deletes:
|
||||
conn.executemany(
|
||||
"DELETE FROM excluded_models WHERE model_type = ? AND file_path = ?",
|
||||
excluded_deletes,
|
||||
)
|
||||
if excluded_inserts:
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO excluded_models (model_type, file_path) VALUES (?, ?)",
|
||||
excluded_inserts,
|
||||
)
|
||||
|
||||
if all_folders is not None:
|
||||
conn.execute(
|
||||
"DELETE FROM folders WHERE model_type = ?",
|
||||
(model_type,),
|
||||
)
|
||||
folder_inserts = [
|
||||
(model_type, path) for path in all_folders if path
|
||||
]
|
||||
if folder_inserts:
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO folders (model_type, path) VALUES (?, ?)",
|
||||
folder_inserts,
|
||||
)
|
||||
# Mark the snapshot as having folder data even when the
|
||||
# library has no subfolders, so an empty list is not
|
||||
# mistaken for "never recorded" on load.
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO cache_meta (key, value) VALUES (?, ?)",
|
||||
(f"folders_recorded:{model_type}", "1"),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to persist cache for %s: %s", model_type, exc)
|
||||
|
||||
@@ -573,6 +583,8 @@ class PersistentModelCache:
|
||||
source_platform TEXT DEFAULT '',
|
||||
source_url TEXT DEFAULT '',
|
||||
hf_url TEXT DEFAULT '',
|
||||
source_model_id TEXT DEFAULT '',
|
||||
source_version_id TEXT DEFAULT '',
|
||||
PRIMARY KEY (model_type, file_path)
|
||||
);
|
||||
|
||||
@@ -642,6 +654,8 @@ class PersistentModelCache:
|
||||
"source_platform": "TEXT DEFAULT ''",
|
||||
"source_url": "TEXT DEFAULT ''",
|
||||
"hf_url": "TEXT DEFAULT ''",
|
||||
"source_model_id": "TEXT DEFAULT ''",
|
||||
"source_version_id": "TEXT DEFAULT ''",
|
||||
"autov3": "TEXT",
|
||||
}
|
||||
|
||||
@@ -650,16 +664,14 @@ class PersistentModelCache:
|
||||
conn.execute(f"ALTER TABLE models ADD COLUMN {column} {definition}")
|
||||
|
||||
def _connect(self, readonly: bool = False) -> sqlite3.Connection:
|
||||
uri = False
|
||||
path = self._db_path
|
||||
if readonly:
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(path)
|
||||
path = f"file:{path}?mode=ro"
|
||||
uri = True
|
||||
conn = sqlite3.connect(path, check_same_thread=False, uri=uri, detect_types=sqlite3.PARSE_DECLTYPES)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
if readonly and not os.path.exists(self._db_path):
|
||||
raise FileNotFoundError(self._db_path)
|
||||
return connect_cache_db(
|
||||
self._db_path,
|
||||
readonly=readonly,
|
||||
detect_types=sqlite3.PARSE_DECLTYPES,
|
||||
row_factory=sqlite3.Row,
|
||||
)
|
||||
|
||||
def _prepare_model_row(self, model_type: str, item: Dict[str, Any]) -> Tuple[Any, ...]:
|
||||
# Keep `source_*` and the legacy `hf_url` alias consistent no matter
|
||||
@@ -731,6 +743,8 @@ class PersistentModelCache:
|
||||
item.get("source_platform") or "",
|
||||
item.get("source_url") or "",
|
||||
item.get("hf_url") or "",
|
||||
item.get("source_model_id") or "",
|
||||
item.get("source_version_id") or "",
|
||||
)
|
||||
|
||||
def _insert_model_sql(self) -> str:
|
||||
|
||||
@@ -19,7 +19,9 @@ import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from ..utils.cache_db import connect_cache_db
|
||||
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
|
||||
from ..utils.file_lock import exclusive_lock
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -170,65 +172,98 @@ class PersistentRecipeCache:
|
||||
recipes: List[Dict[str, Any]],
|
||||
json_paths: Optional[Dict[str, str]] = None,
|
||||
image_id_map: Optional[Dict[str, str]] = None,
|
||||
) -> None:
|
||||
skip_if_empty: bool = False,
|
||||
) -> bool:
|
||||
"""Save all recipes to SQLite cache.
|
||||
|
||||
Args:
|
||||
recipes: List of recipe dictionaries to persist.
|
||||
json_paths: Optional mapping of recipe_id -> json_path for file stats.
|
||||
image_id_map: Optional precomputed civitai image_id → recipe_id mapping.
|
||||
skip_if_empty: When True, refuse to replace a non-empty cache with an
|
||||
empty one. This is the storage-level backstop against a scan that
|
||||
silently loses every recipe (unavailable drive / mis-resolved
|
||||
recipes directory): overwriting both deletes the user's data and
|
||||
destroys their only record of it. Intentional full clears (manual
|
||||
rebuild) must pass ``skip_if_empty=False``.
|
||||
|
||||
Returns:
|
||||
``True`` when the write happened, ``False`` when it was skipped.
|
||||
"""
|
||||
if not self.is_enabled():
|
||||
return
|
||||
return False
|
||||
if not self._schema_initialized:
|
||||
self._initialize_schema()
|
||||
if not self._schema_initialized:
|
||||
return
|
||||
return False
|
||||
|
||||
try:
|
||||
with self._db_lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.execute("BEGIN")
|
||||
# Cross-process serialization: another LoRA Manager instance may
|
||||
# share this settings directory, and a full-table replace is a
|
||||
# read-modify-write that SQLite alone cannot make atomic.
|
||||
with exclusive_lock(self._db_path):
|
||||
conn = self._connect()
|
||||
try:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.execute("BEGIN")
|
||||
|
||||
# Clear existing data
|
||||
conn.execute("DELETE FROM recipes")
|
||||
if skip_if_empty and not recipes:
|
||||
existing = conn.execute(
|
||||
"SELECT COUNT(*) FROM recipes"
|
||||
).fetchone()
|
||||
if existing and existing[0]:
|
||||
conn.rollback()
|
||||
logger.warning(
|
||||
"Refusing to persist an empty recipe cache: the "
|
||||
"stored cache still holds %d recipe(s). The scan "
|
||||
"found nothing, which usually means the recipes "
|
||||
"path was unavailable or resolved elsewhere; "
|
||||
"keeping the stored cache so the data stays "
|
||||
"recoverable.",
|
||||
existing[0],
|
||||
)
|
||||
return False
|
||||
|
||||
# Prepare and insert all rows
|
||||
recipe_rows = []
|
||||
for recipe in recipes:
|
||||
recipe_id = str(recipe.get("id", ""))
|
||||
if not recipe_id:
|
||||
continue
|
||||
# Clear existing data
|
||||
conn.execute("DELETE FROM recipes")
|
||||
|
||||
json_path = ""
|
||||
if json_paths:
|
||||
json_path = json_paths.get(recipe_id, "")
|
||||
# Prepare and insert all rows
|
||||
recipe_rows = []
|
||||
for recipe in recipes:
|
||||
recipe_id = str(recipe.get("id", ""))
|
||||
if not recipe_id:
|
||||
continue
|
||||
|
||||
row = self._prepare_recipe_row(recipe, json_path)
|
||||
recipe_rows.append(row)
|
||||
json_path = ""
|
||||
if json_paths:
|
||||
json_path = json_paths.get(recipe_id, "")
|
||||
|
||||
if recipe_rows:
|
||||
placeholders = ", ".join(["?"] * len(self._RECIPE_COLUMNS))
|
||||
columns = ", ".join(self._RECIPE_COLUMNS)
|
||||
conn.executemany(
|
||||
f"INSERT INTO recipes ({columns}) VALUES ({placeholders})",
|
||||
recipe_rows,
|
||||
row = self._prepare_recipe_row(recipe, json_path)
|
||||
recipe_rows.append(row)
|
||||
|
||||
if recipe_rows:
|
||||
placeholders = ", ".join(["?"] * len(self._RECIPE_COLUMNS))
|
||||
columns = ", ".join(self._RECIPE_COLUMNS)
|
||||
conn.executemany(
|
||||
f"INSERT INTO recipes ({columns}) VALUES ({placeholders})",
|
||||
recipe_rows,
|
||||
)
|
||||
|
||||
# Persist image_id_map for O(1) lookups on cache load
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO cache_metadata (key, value) VALUES (?, ?)",
|
||||
("image_id_map", json.dumps(image_id_map or {})),
|
||||
)
|
||||
|
||||
# Persist image_id_map for O(1) lookups on cache load
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO cache_metadata (key, value) VALUES (?, ?)",
|
||||
("image_id_map", json.dumps(image_id_map or {})),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
logger.debug("Persisted %d recipes to cache", len(recipe_rows))
|
||||
finally:
|
||||
conn.close()
|
||||
conn.commit()
|
||||
logger.debug("Persisted %d recipes to cache", len(recipe_rows))
|
||||
return True
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to persist recipe cache: %s", exc)
|
||||
return False
|
||||
|
||||
def get_file_stats(self) -> Dict[str, Tuple[float, int]]:
|
||||
"""Return stored file stats for all cached recipes.
|
||||
@@ -486,16 +521,14 @@ class PersistentRecipeCache:
|
||||
logger.warning("Failed to initialize persistent recipe cache schema: %s", exc)
|
||||
|
||||
def _connect(self, readonly: bool = False) -> sqlite3.Connection:
|
||||
uri = False
|
||||
path = self._db_path
|
||||
if readonly:
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(path)
|
||||
path = f"file:{path}?mode=ro"
|
||||
uri = True
|
||||
conn = sqlite3.connect(path, check_same_thread=False, uri=uri, detect_types=sqlite3.PARSE_DECLTYPES)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
if readonly and not os.path.exists(self._db_path):
|
||||
raise FileNotFoundError(self._db_path)
|
||||
return connect_cache_db(
|
||||
self._db_path,
|
||||
readonly=readonly,
|
||||
detect_types=sqlite3.PARSE_DECLTYPES,
|
||||
row_factory=sqlite3.Row,
|
||||
)
|
||||
|
||||
def _prepare_recipe_row(self, recipe: Dict[str, Any], json_path: str) -> Tuple[Any, ...]:
|
||||
"""Convert a recipe dict to a row tuple for SQLite insertion."""
|
||||
|
||||
@@ -16,6 +16,7 @@ import threading
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from ..utils.cache_db import connect_cache_db
|
||||
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -633,16 +634,13 @@ class RecipeFTSIndex:
|
||||
|
||||
def _connect(self, readonly: bool = False) -> sqlite3.Connection:
|
||||
"""Create a database connection."""
|
||||
uri = False
|
||||
path = self._db_path
|
||||
if readonly:
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(path)
|
||||
path = f"file:{path}?mode=ro"
|
||||
uri = True
|
||||
conn = sqlite3.connect(path, check_same_thread=False, uri=uri)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
if readonly and not os.path.exists(self._db_path):
|
||||
raise FileNotFoundError(self._db_path)
|
||||
return connect_cache_db(
|
||||
self._db_path,
|
||||
readonly=readonly,
|
||||
row_factory=sqlite3.Row,
|
||||
)
|
||||
|
||||
def _remove_recipe_locked(self, conn: sqlite3.Connection, recipe_id: str) -> None:
|
||||
"""Remove a recipe entry. Caller must hold the lock."""
|
||||
|
||||
+124
-16
@@ -116,6 +116,12 @@ class RecipeScanner:
|
||||
self._persistent_cache: Optional[PersistentRecipeCache] = None
|
||||
self._civitai_client: Any = None # Lazily initialized from registry
|
||||
self._json_path_map: Dict[str, str] = {} # recipe_id -> json_path
|
||||
# True when the last scan refused to prune the stored cache because
|
||||
# every recorded recipe file was missing (see
|
||||
# :meth:`_initialize_recipe_cache_sync`). Keeps dependent background
|
||||
# work (FTS index) aligned with the stored rows instead of the
|
||||
# intentionally out-of-sync in-memory view.
|
||||
self._prune_skipped: bool = False
|
||||
if lora_scanner:
|
||||
self._lora_scanner = lora_scanner
|
||||
if checkpoint_scanner:
|
||||
@@ -1651,8 +1657,12 @@ class RecipeScanner:
|
||||
'pageType': 'recipes',
|
||||
})
|
||||
self._schedule_post_scan_enrichment()
|
||||
# Schedule FTS index build in background (non-blocking)
|
||||
self._schedule_fts_index_build()
|
||||
# Schedule FTS index build in background (non-blocking). When the
|
||||
# prune was skipped the in-memory cache is intentionally out of sync
|
||||
# with the stored rows, so leave the existing index alone instead of
|
||||
# rebuilding it from the empty view.
|
||||
if not self._prune_skipped:
|
||||
self._schedule_fts_index_build()
|
||||
except Exception as e:
|
||||
logger.error(f"Recipe Scanner: Error initializing cache in background: {e}")
|
||||
# Ensure the cache is never None so the page stops showing the
|
||||
@@ -1723,6 +1733,7 @@ class RecipeScanner:
|
||||
"""
|
||||
loop = None
|
||||
scan_start_time: Optional[float] = None
|
||||
self._prune_skipped = False
|
||||
try:
|
||||
# Ensure cache exists to avoid None reference errors
|
||||
if self._cache is None:
|
||||
@@ -1749,14 +1760,38 @@ class RecipeScanner:
|
||||
logger.warning(f"Recipes directory not found: {recipes_dir}")
|
||||
return self._cache
|
||||
|
||||
# Record which directory the scan actually used. When the Recipes
|
||||
# Storage Path is empty this falls back to the first LoRA root, and
|
||||
# a support reader needs that path to tell a real wipe apart from a
|
||||
# scan that looked somewhere else (see the prune guard below).
|
||||
logger.info(f"Recipe scan directory: {recipes_dir}")
|
||||
|
||||
# Try to load from persistent cache first
|
||||
persisted = self._persistent_cache.load_cache()
|
||||
if persisted:
|
||||
recipes, changed, json_paths = self._reconcile_recipe_cache(
|
||||
persisted, recipes_dir
|
||||
)
|
||||
(
|
||||
recipes,
|
||||
changed,
|
||||
json_paths,
|
||||
skipped_prune_reason,
|
||||
) = self._reconcile_recipe_cache(persisted, recipes_dir)
|
||||
self._json_path_map = json_paths
|
||||
|
||||
if skipped_prune_reason:
|
||||
# Every persisted recipe file vanished at once. That is not a
|
||||
# reliable deletion signal: a drive that did not mount, a
|
||||
# recipes_path that silently fell back to another root, or a
|
||||
# shared cache touched by a second instance all look exactly
|
||||
# like this. Keep the stored cache and skip the prune, so the
|
||||
# only copy of the user's recipes is not destroyed.
|
||||
logger.warning(
|
||||
f"Recipe cache prune skipped: {skipped_prune_reason}. "
|
||||
f"Keeping {len(persisted.raw_data)} stored recipe(s); this "
|
||||
"session reports no recipes until the files are found again."
|
||||
)
|
||||
self._prune_skipped = True
|
||||
return self._cache
|
||||
|
||||
if not changed:
|
||||
# Fast path: use cached data directly
|
||||
logger.info(
|
||||
@@ -1770,7 +1805,10 @@ class RecipeScanner:
|
||||
if self._backfill_source_path_if_needed(recipes, json_paths):
|
||||
self._cache.image_id_map = self._build_image_id_map()
|
||||
self._persistent_cache.save_cache(
|
||||
recipes, json_paths, self._cache.image_id_map
|
||||
recipes,
|
||||
json_paths,
|
||||
self._cache.image_id_map,
|
||||
skip_if_empty=True,
|
||||
)
|
||||
else:
|
||||
# Use persisted map, or rebuild if empty (e.g. first startup
|
||||
@@ -1798,7 +1836,10 @@ class RecipeScanner:
|
||||
self._cache.image_id_map = self._build_image_id_map()
|
||||
# Persist updated cache
|
||||
self._persistent_cache.save_cache(
|
||||
recipes, json_paths, self._cache.image_id_map
|
||||
recipes,
|
||||
json_paths,
|
||||
self._cache.image_id_map,
|
||||
skip_if_empty=True,
|
||||
)
|
||||
return self._cache
|
||||
|
||||
@@ -1825,7 +1866,10 @@ class RecipeScanner:
|
||||
|
||||
# Persist for next startup
|
||||
self._persistent_cache.save_cache(
|
||||
recipes, json_paths, self._cache.image_id_map
|
||||
recipes,
|
||||
json_paths,
|
||||
self._cache.image_id_map,
|
||||
skip_if_empty=True,
|
||||
)
|
||||
|
||||
if report_progress:
|
||||
@@ -1862,7 +1906,7 @@ class RecipeScanner:
|
||||
self,
|
||||
persisted: PersistedRecipeData,
|
||||
recipes_dir: str,
|
||||
) -> Tuple[List[Dict[str, Any]], bool, Dict[str, str]]:
|
||||
) -> Tuple[List[Dict[str, Any]], bool, Dict[str, str], Optional[str]]:
|
||||
"""Reconcile persisted cache with current filesystem state.
|
||||
|
||||
Args:
|
||||
@@ -1870,7 +1914,11 @@ class RecipeScanner:
|
||||
recipes_dir: Path to the recipes directory.
|
||||
|
||||
Returns:
|
||||
Tuple of (recipes list, changed flag, json_paths dict).
|
||||
Tuple of (recipes list, changed flag, json_paths dict,
|
||||
skipped_prune_reason). The last element is ``None`` on a normal
|
||||
reconcile. When it is a string, the scan saw every persisted recipe
|
||||
file disappear at once; the caller must then keep the persisted
|
||||
cache instead of overwriting it. The reason text is user-facing.
|
||||
"""
|
||||
recipes: List[Dict[str, Any]] = []
|
||||
json_paths: Dict[str, str] = {}
|
||||
@@ -1951,12 +1999,67 @@ class RecipeScanner:
|
||||
time.sleep(0)
|
||||
|
||||
# Check for deleted files
|
||||
for json_path in persisted.file_stats.keys():
|
||||
if json_path not in current_files:
|
||||
changed = True
|
||||
logger.debug("Recipe file deleted: %s", json_path)
|
||||
orphaned_stats = [
|
||||
json_path
|
||||
for json_path in persisted.file_stats.keys()
|
||||
if json_path not in current_files
|
||||
]
|
||||
if orphaned_stats:
|
||||
changed = True
|
||||
# This single line plus the resolved scan directory logged by the
|
||||
# caller are the evidence a support reader gets for a recipes path
|
||||
# that moved; the per-file lines stay at debug to avoid flooding.
|
||||
if len(orphaned_stats) > 10:
|
||||
logger.info(
|
||||
f"Recipe reconcile: {len(orphaned_stats)} of "
|
||||
f"{len(persisted.file_stats)} cached recipe file(s) are not in "
|
||||
f"{recipes_dir} (first: {orphaned_stats[0]}, "
|
||||
f"last: {orphaned_stats[-1]})"
|
||||
)
|
||||
else:
|
||||
for json_path in orphaned_stats:
|
||||
logger.debug("Recipe file deleted: %s", json_path)
|
||||
|
||||
return recipes, changed, json_paths
|
||||
skipped_prune_reason: Optional[str] = None
|
||||
if not current_files and persisted.file_stats:
|
||||
metadata_is_coherent = self._persisted_metadata_is_coherent(persisted)
|
||||
if metadata_is_coherent:
|
||||
skipped_prune_reason = (
|
||||
f"every recipe file recorded in the cache "
|
||||
f"({len(persisted.file_stats)}) is missing from {recipes_dir}"
|
||||
)
|
||||
else:
|
||||
# The stored row set and its recorded file stats disagree, so
|
||||
# this cache is stale rather than a faithful record of recipes
|
||||
# that have just gone missing. Pruning it is safe.
|
||||
logger.info(
|
||||
f"Recipe reconcile: stored cache is inconsistent "
|
||||
f"({len(persisted.raw_data)} row(s) vs "
|
||||
f"{len(persisted.file_stats)} file record(s)); falling back "
|
||||
"to a normal prune."
|
||||
)
|
||||
|
||||
return recipes, changed, json_paths, skipped_prune_reason
|
||||
|
||||
@staticmethod
|
||||
def _persisted_metadata_is_coherent(persisted: PersistedRecipeData) -> bool:
|
||||
"""Return True when the stored rows and their file stats describe one set.
|
||||
|
||||
The prune guard treats "no recipe files found" as a signal that the
|
||||
directory moved out from under us, which is only meaningful when the
|
||||
stored cache is a faithful record of recipes that exist on disk. A cache
|
||||
whose row set and file-stat set have diverged (left behind by an older
|
||||
reconcile) carries recipes that were already orphaned, so it is not
|
||||
evidence of a fresh disappearance.
|
||||
"""
|
||||
stats_ids = {
|
||||
os.path.basename(json_path)[: -len(".recipe.json")]
|
||||
for json_path in persisted.file_stats
|
||||
if os.path.basename(json_path).lower().endswith(".recipe.json")
|
||||
}
|
||||
rows_ids = {str(recipe.get("id", "")) for recipe in persisted.raw_data}
|
||||
rows_ids.discard("")
|
||||
return bool(rows_ids) and rows_ids == stats_ids
|
||||
|
||||
# Metadata key recording that the one-shot source_path backfill has run.
|
||||
_SOURCE_PATH_BACKFILL_MARKER = "source_path_backfilled"
|
||||
@@ -2626,6 +2729,10 @@ class RecipeScanner:
|
||||
try:
|
||||
# Invalidate persistent cache so the sync path does a
|
||||
# full directory scan instead of reconciling stale data.
|
||||
# This is the deliberate escape hatch from the
|
||||
# all-missing prune guard: an explicit user rebuild is
|
||||
# allowed to clear the stored cache, while an implicit
|
||||
# startup scan is not.
|
||||
if self._persistent_cache:
|
||||
self._persistent_cache.save_cache([], {})
|
||||
self._json_path_map = {}
|
||||
@@ -2656,7 +2763,8 @@ class RecipeScanner:
|
||||
|
||||
# Schedule non-blocking background work
|
||||
self._schedule_post_scan_enrichment()
|
||||
self._schedule_fts_index_build()
|
||||
if not self._prune_skipped:
|
||||
self._schedule_fts_index_build()
|
||||
|
||||
return cast(RecipeCache, self._cache)
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from typing import (
|
||||
Mapping,
|
||||
Optional,
|
||||
Sequence,
|
||||
Set,
|
||||
Tuple,
|
||||
)
|
||||
|
||||
@@ -37,6 +38,7 @@ from ..utils.constants import (
|
||||
from ..utils.preview_selection import VALID_MATURE_BLUR_LEVELS
|
||||
from ..utils.settings_paths import (
|
||||
APP_NAME,
|
||||
_portable_env_override,
|
||||
ensure_settings_file,
|
||||
get_legacy_settings_path,
|
||||
get_settings_dir_override,
|
||||
@@ -45,6 +47,8 @@ from ..utils.settings_paths import (
|
||||
from ..utils.tag_priorities import (
|
||||
PriorityTagEntry,
|
||||
collect_canonical_tags,
|
||||
is_civitai_meta_tag,
|
||||
is_usable_path_tag,
|
||||
parse_priority_tag_string,
|
||||
resolve_priority_tag,
|
||||
)
|
||||
@@ -63,6 +67,7 @@ DEFAULT_KEYS_CLEANUP_THRESHOLD = 10
|
||||
|
||||
DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"civitai_api_key": "",
|
||||
"huggingface_api_key": "",
|
||||
"civitai_host": "civitai.com",
|
||||
"download_backend": "python",
|
||||
"aria2c_path": "",
|
||||
@@ -96,6 +101,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"recipes_path": "",
|
||||
"base_model_path_mappings": {},
|
||||
"download_path_templates": {},
|
||||
"download_filename_templates": {},
|
||||
"folder_paths": {},
|
||||
"extra_folder_paths": {},
|
||||
"example_images_path": "",
|
||||
@@ -172,13 +178,23 @@ class SettingsManager:
|
||||
self._check_environment_variables()
|
||||
self._collect_configuration_warnings()
|
||||
|
||||
if (
|
||||
os.environ.get("LORA_MANAGER_PORTABLE", "0") == "1"
|
||||
and not is_settings_dir_pinned()
|
||||
):
|
||||
portable_override = _portable_env_override()
|
||||
if portable_override is True and not is_settings_dir_pinned():
|
||||
if not self.settings.get("use_portable_settings"):
|
||||
self.settings["use_portable_settings"] = True
|
||||
self._save_settings()
|
||||
elif portable_override is False and self.settings.get(
|
||||
"use_portable_settings"
|
||||
):
|
||||
# Explicit opt-out from a persisted portable mode: clear the flag so
|
||||
# later runs go back to the shared settings directory instead of
|
||||
# requiring a manual edit of settings.json.
|
||||
logger.info(
|
||||
"Clearing the persisted portable-mode flag because %s=0",
|
||||
"LORA_MANAGER_PORTABLE",
|
||||
)
|
||||
self.settings["use_portable_settings"] = False
|
||||
self._save_settings()
|
||||
|
||||
if self._needs_initial_save:
|
||||
self._save_settings()
|
||||
@@ -297,6 +313,29 @@ class SettingsManager:
|
||||
|
||||
return payload == template
|
||||
|
||||
def get_template_folder_path_placeholders(self) -> Set[str]:
|
||||
"""Placeholder folder_paths values shipped in settings.json.example.
|
||||
|
||||
A fresh standalone install is seeded from the template, so its
|
||||
documentation-only placeholder paths end up in the live settings
|
||||
file. The Model Paths settings UI hides them; the first real save
|
||||
overwrites them via ``set("folder_paths")``.
|
||||
"""
|
||||
|
||||
template = self._read_template_payload()
|
||||
if not template:
|
||||
return set()
|
||||
|
||||
folder_paths = template.get("folder_paths")
|
||||
if not isinstance(folder_paths, Mapping):
|
||||
return set()
|
||||
|
||||
placeholders: Set[str] = set()
|
||||
for value in folder_paths.values():
|
||||
paths = value if isinstance(value, list) else [value]
|
||||
placeholders.update(p for p in paths if isinstance(p, str) and p)
|
||||
return placeholders
|
||||
|
||||
def _merge_template_with_defaults(
|
||||
self, defaults: Dict[str, Any], template: Mapping[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
@@ -1086,6 +1125,15 @@ class SettingsManager:
|
||||
self.settings["civitai_api_key"] = env_api_key
|
||||
self._save_settings()
|
||||
|
||||
# Hugging Face accepts either of its conventional variable names
|
||||
env_hf_token = os.environ.get("HF_TOKEN") or os.environ.get(
|
||||
"HUGGING_FACE_HUB_TOKEN"
|
||||
)
|
||||
if env_hf_token:
|
||||
logger.info("Found HF_TOKEN environment variable")
|
||||
self.settings["huggingface_api_key"] = env_hf_token
|
||||
self._save_settings()
|
||||
|
||||
# LLM provider overrides
|
||||
llm_env_map = {
|
||||
"LLM_API_KEY": "llm_api_key",
|
||||
@@ -1208,19 +1256,27 @@ class SettingsManager:
|
||||
if self._bootstrap_reason == "missing":
|
||||
message = (
|
||||
"LoRA Manager created a default settings.json because no configuration was found. "
|
||||
"Edit settings.json to add your model directories so library scanning can run."
|
||||
"Open Settings → Model Paths to add your model directories so library scanning can run."
|
||||
)
|
||||
else:
|
||||
message = (
|
||||
"LoRA Manager could not locate any configured model directories. "
|
||||
"Edit settings.json to add your model folders so library scanning can run."
|
||||
"Open Settings → Model Paths to add your model folders so library scanning can run."
|
||||
)
|
||||
self._add_startup_message(
|
||||
code="missing-model-paths",
|
||||
title="Model folders need setup",
|
||||
message=message,
|
||||
severity="warning",
|
||||
actions=self._default_settings_actions(),
|
||||
actions=[
|
||||
{
|
||||
"action": "open-model-paths-settings",
|
||||
"label": "Configure model folders",
|
||||
"type": "primary",
|
||||
"icon": "fas fa-cog",
|
||||
},
|
||||
*self._default_settings_actions(),
|
||||
],
|
||||
dismissible=False,
|
||||
)
|
||||
|
||||
@@ -1233,6 +1289,7 @@ class SettingsManager:
|
||||
defaults = copy.deepcopy(DEFAULT_SETTINGS)
|
||||
defaults["base_model_path_mappings"] = {}
|
||||
defaults["download_path_templates"] = {}
|
||||
defaults["download_filename_templates"] = {}
|
||||
defaults["priority_tags"] = DEFAULT_PRIORITY_TAG_CONFIG.copy()
|
||||
defaults.setdefault("folder_paths", {})
|
||||
defaults.setdefault("extra_folder_paths", {})
|
||||
@@ -1524,9 +1581,15 @@ class SettingsManager:
|
||||
if resolved:
|
||||
return resolved
|
||||
|
||||
# Fall back to the first tag that is usable as a folder name. The raw
|
||||
# tag list can contain keyword dumps that would become unusable folders
|
||||
# and break path length limits, and Civitai mixes in structural labels
|
||||
# like "base model" that mean nothing as a folder, so skip both (#1119).
|
||||
for tag in tags:
|
||||
if isinstance(tag, str) and tag:
|
||||
return tag
|
||||
if is_civitai_meta_tag(tag):
|
||||
continue
|
||||
if is_usable_path_tag(tag):
|
||||
return tag.strip()
|
||||
return ""
|
||||
|
||||
def get_priority_tag_suggestions(self) -> Dict[str, List[str]]:
|
||||
@@ -2381,6 +2444,49 @@ class SettingsManager:
|
||||
model_type, DEFAULT_DOWNLOAD_PATH_TEMPLATES.get(model_type, "")
|
||||
)
|
||||
|
||||
def get_download_filename_template(self, model_type: str) -> str:
|
||||
"""Get the download filename template for a specific model type.
|
||||
|
||||
Args:
|
||||
model_type: The type of model ('lora', 'checkpoint', 'embedding',
|
||||
'other')
|
||||
|
||||
Returns:
|
||||
Template string for the model type. Empty string (the default for
|
||||
every model type) means downloaded files keep their original
|
||||
filename.
|
||||
"""
|
||||
templates = self.settings.get("download_filename_templates", {})
|
||||
|
||||
# Handle edge case where templates might be stored as JSON string
|
||||
if isinstance(templates, str):
|
||||
try:
|
||||
parsed_templates = json.loads(templates)
|
||||
if isinstance(parsed_templates, dict):
|
||||
self.settings["download_filename_templates"] = parsed_templates
|
||||
self._save_settings()
|
||||
templates = parsed_templates
|
||||
logger.info(
|
||||
"Successfully parsed download_filename_templates from JSON string"
|
||||
)
|
||||
else:
|
||||
raise ValueError("Parsed JSON is not a dictionary")
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
logger.warning(
|
||||
f"Failed to parse download_filename_templates JSON string: {e}. Resetting to empty templates."
|
||||
)
|
||||
templates = {}
|
||||
self.settings["download_filename_templates"] = templates
|
||||
self._save_settings()
|
||||
|
||||
if not isinstance(templates, dict):
|
||||
templates = {}
|
||||
self.settings["download_filename_templates"] = templates
|
||||
self._save_settings()
|
||||
|
||||
template = templates.get(model_type, "")
|
||||
return template if isinstance(template, str) else ""
|
||||
|
||||
|
||||
_SETTINGS_MANAGER: Optional["SettingsManager"] = None
|
||||
_SETTINGS_MANAGER_LOCK = Lock()
|
||||
|
||||
@@ -20,6 +20,7 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
from ..utils.cache_db import connect_cache_db
|
||||
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -677,16 +678,13 @@ class TagFTSIndex:
|
||||
|
||||
def _connect(self, readonly: bool = False) -> sqlite3.Connection:
|
||||
"""Create a database connection."""
|
||||
uri = False
|
||||
path = self._db_path
|
||||
if readonly:
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(path)
|
||||
path = f"file:{path}?mode=ro"
|
||||
uri = True
|
||||
conn = sqlite3.connect(path, check_same_thread=False, uri=uri)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
if readonly and not os.path.exists(self._db_path):
|
||||
raise FileNotFoundError(self._db_path)
|
||||
return connect_cache_db(
|
||||
self._db_path,
|
||||
readonly=readonly,
|
||||
row_factory=sqlite3.Row,
|
||||
)
|
||||
|
||||
def _build_fts_query(self, query: str) -> str:
|
||||
"""Build an FTS5 query string with prefix matching.
|
||||
|
||||
@@ -20,6 +20,7 @@ from .example_images import (
|
||||
ImportExampleImagesUseCase,
|
||||
ImportExampleImagesValidationError,
|
||||
)
|
||||
from .filename_template_use_case import FilenameTemplateUseCase
|
||||
|
||||
__all__ = [
|
||||
"AutoOrganizeInProgressError",
|
||||
@@ -34,4 +35,5 @@ __all__ = [
|
||||
"DownloadExampleImagesUseCase",
|
||||
"ImportExampleImagesUseCase",
|
||||
"ImportExampleImagesValidationError",
|
||||
"FilenameTemplateUseCase",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Filename template use case: bulk-rename library models per the configured template.
|
||||
|
||||
An empty template reverts previously renamed models to the original filename
|
||||
recorded in their ``.metadata.json`` sidecar (``original_file_name``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional, Sequence
|
||||
|
||||
from ...utils.constants import AUTO_ORGANIZE_BATCH_SIZE
|
||||
from ...utils.utils import calculate_filename_for_model
|
||||
from ..model_file_service import AutoOrganizeResult, ProgressCallback
|
||||
from ..model_lifecycle_service import ModelLifecycleService, load_local_metadata
|
||||
from ..settings_manager import get_settings_manager
|
||||
from .auto_organize_use_case import (
|
||||
AutoOrganizeInProgressError,
|
||||
AutoOrganizeLockProvider,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PROGRESS_TYPE = "filename_template_progress"
|
||||
|
||||
|
||||
class FilenameTemplateUseCase:
|
||||
"""Apply the download filename template to existing library models.
|
||||
|
||||
An empty template restores the recorded original filename instead of
|
||||
rendering a template. Shares the auto-organize lock (and its in-progress
|
||||
error) so a bulk rename never runs concurrently with an auto-organize
|
||||
operation.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
scanner,
|
||||
lifecycle_service: ModelLifecycleService,
|
||||
lock_provider: AutoOrganizeLockProvider,
|
||||
model_type: str,
|
||||
metadata_loader: Callable[[str], Awaitable[Dict[str, Any]]] = load_local_metadata,
|
||||
) -> None:
|
||||
self._scanner = scanner
|
||||
self._lifecycle_service = lifecycle_service
|
||||
self._lock_provider = lock_provider
|
||||
self._model_type = model_type
|
||||
self._metadata_loader = metadata_loader
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
*,
|
||||
file_paths: Optional[Sequence[str]] = None,
|
||||
progress_callback: Optional[ProgressCallback] = None,
|
||||
) -> AutoOrganizeResult:
|
||||
"""Run the bulk rename guarded by the shared library-operation lock."""
|
||||
|
||||
is_running = getattr(self._lock_provider, "is_filename_template_running", None)
|
||||
if callable(is_running) and is_running():
|
||||
raise AutoOrganizeInProgressError(
|
||||
"A filename template operation is already running"
|
||||
)
|
||||
if self._lock_provider.is_auto_organize_running():
|
||||
raise AutoOrganizeInProgressError("Auto-organize is already running")
|
||||
|
||||
lock = await self._lock_provider.get_auto_organize_lock()
|
||||
if lock.locked():
|
||||
raise AutoOrganizeInProgressError(
|
||||
"Another library operation is already running"
|
||||
)
|
||||
|
||||
async with lock:
|
||||
return await self._run(
|
||||
file_paths=file_paths, progress_callback=progress_callback
|
||||
)
|
||||
|
||||
async def _run(
|
||||
self,
|
||||
*,
|
||||
file_paths: Optional[Sequence[str]],
|
||||
progress_callback: Optional[ProgressCallback],
|
||||
) -> AutoOrganizeResult:
|
||||
result = AutoOrganizeResult()
|
||||
result.operation_type = "filename_template"
|
||||
|
||||
self._scanner.reset_cancellation()
|
||||
|
||||
try:
|
||||
template = get_settings_manager().get_download_filename_template(
|
||||
self._model_type
|
||||
)
|
||||
|
||||
cache = await self._scanner.get_cached_data()
|
||||
models = list(cache.raw_data)
|
||||
if file_paths:
|
||||
wanted = set(file_paths)
|
||||
models = [
|
||||
model for model in models if model.get("file_path") in wanted
|
||||
]
|
||||
|
||||
result.total = len(models)
|
||||
|
||||
await self._emit_progress(progress_callback, result, "started")
|
||||
|
||||
for index in range(0, result.total, AUTO_ORGANIZE_BATCH_SIZE):
|
||||
if self._scanner.is_cancelled():
|
||||
logger.info(
|
||||
"Filename template apply cancelled for %s", self._model_type
|
||||
)
|
||||
break
|
||||
|
||||
batch = models[index : index + AUTO_ORGANIZE_BATCH_SIZE]
|
||||
for model in batch:
|
||||
if self._scanner.is_cancelled():
|
||||
break
|
||||
await self._process_model(model, template, result)
|
||||
result.processed += 1
|
||||
|
||||
await self._emit_progress(progress_callback, result, "processing")
|
||||
# Yield between batches so the server stays responsive.
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
if self._scanner.is_cancelled():
|
||||
result.status = "cancelled"
|
||||
await self._emit_progress(progress_callback, result, "cancelled")
|
||||
return result
|
||||
|
||||
await self._emit_progress(progress_callback, result, "completed")
|
||||
return result
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Error in filename template apply: %s", exc, exc_info=True)
|
||||
if progress_callback:
|
||||
await progress_callback.on_progress(
|
||||
{
|
||||
"type": _PROGRESS_TYPE,
|
||||
"status": "error",
|
||||
"error": str(exc),
|
||||
"operation_type": result.operation_type,
|
||||
}
|
||||
)
|
||||
raise
|
||||
|
||||
async def _process_model(
|
||||
self,
|
||||
model: Dict[str, Any],
|
||||
template: str,
|
||||
result: AutoOrganizeResult,
|
||||
) -> None:
|
||||
model_name = model.get("model_name", "Unknown")
|
||||
try:
|
||||
file_path = model.get("file_path")
|
||||
if not file_path:
|
||||
self._add_result(result, model_name, False, "No file path found")
|
||||
result.failure_count += 1
|
||||
return
|
||||
|
||||
if not template:
|
||||
# Empty template = revert to the original filename recorded
|
||||
# by the first rename; models without a record are skipped.
|
||||
new_stem = await self._resolve_recorded_original(file_path)
|
||||
else:
|
||||
new_stem = calculate_filename_for_model(model, self._model_type)
|
||||
if not new_stem:
|
||||
result.skipped_count += 1
|
||||
return
|
||||
|
||||
current_stem = os.path.splitext(os.path.basename(file_path))[0]
|
||||
if new_stem == current_stem or os.path.normcase(
|
||||
new_stem
|
||||
) == os.path.normcase(current_stem):
|
||||
result.skipped_count += 1
|
||||
return
|
||||
|
||||
await self._lifecycle_service.rename_model(
|
||||
file_path=file_path, new_file_name=new_stem
|
||||
)
|
||||
result.success_count += 1
|
||||
|
||||
except ValueError as exc:
|
||||
# Conflicts (e.g. target name already exists) count as failures
|
||||
# without aborting the batch.
|
||||
self._add_result(result, model_name, False, str(exc))
|
||||
result.failure_count += 1
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Error applying filename template to %s: %s", model_name, exc,
|
||||
exc_info=True,
|
||||
)
|
||||
self._add_result(result, model_name, False, f"Error: {exc}")
|
||||
result.failure_count += 1
|
||||
|
||||
async def _resolve_recorded_original(self, file_path: str) -> str:
|
||||
"""Return the original filename stem recorded at the first rename.
|
||||
|
||||
Reads the ``.metadata.json`` sidecar; returns an empty string when no
|
||||
sidecar or no ``original_file_name`` entry exists (models never
|
||||
renamed, or renamed before the recording shipped).
|
||||
"""
|
||||
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
|
||||
metadata = await self._metadata_loader(metadata_path)
|
||||
original = metadata.get("original_file_name")
|
||||
if not isinstance(original, str):
|
||||
return ""
|
||||
return original.strip()
|
||||
|
||||
async def _emit_progress(
|
||||
self,
|
||||
progress_callback: Optional[ProgressCallback],
|
||||
result: AutoOrganizeResult,
|
||||
status: str,
|
||||
) -> None:
|
||||
if not progress_callback:
|
||||
return
|
||||
await progress_callback.on_progress(
|
||||
{
|
||||
"type": _PROGRESS_TYPE,
|
||||
"status": status,
|
||||
"total": result.total,
|
||||
"processed": result.processed,
|
||||
"success": result.success_count,
|
||||
"failures": result.failure_count,
|
||||
"skipped": result.skipped_count,
|
||||
"operation_type": result.operation_type,
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _add_result(
|
||||
result: AutoOrganizeResult,
|
||||
model_name: str,
|
||||
success: bool,
|
||||
message: str,
|
||||
) -> None:
|
||||
"""Add a result entry if under the limit (mirrors ModelFileService)."""
|
||||
if len(result.results) < 100:
|
||||
result.results.append(
|
||||
{"model": model_name, "success": success, "message": message}
|
||||
)
|
||||
elif len(result.results) == 100:
|
||||
result.results_truncated = True
|
||||
result.sample_results = result.results[:50]
|
||||
@@ -20,6 +20,8 @@ class WebSocketManager:
|
||||
self._last_init_progress: Dict[str, Dict[str, Any]] = {}
|
||||
# Add auto-organize progress tracking
|
||||
self._auto_organize_progress: Optional[Dict[str, Any]] = None
|
||||
# Add filename template progress tracking
|
||||
self._filename_template_progress: Optional[Dict[str, Any]] = None
|
||||
# Add recipe rematch progress tracking
|
||||
self._recipe_rematch_progress: Optional[Dict[str, Any]] = None
|
||||
self._auto_organize_lock = asyncio.Lock()
|
||||
@@ -205,6 +207,26 @@ class WebSocketManager:
|
||||
def cleanup_auto_organize_progress(self):
|
||||
"""Clear auto-organize progress data"""
|
||||
self._auto_organize_progress = None
|
||||
|
||||
async def broadcast_filename_template_progress(self, data: Dict[str, Any]):
|
||||
"""Broadcast filename template progress to connected clients"""
|
||||
self._filename_template_progress = data
|
||||
await self.broadcast(data)
|
||||
|
||||
def get_filename_template_progress(self) -> Optional[Dict[str, Any]]:
|
||||
"""Get current filename template progress"""
|
||||
return self._filename_template_progress
|
||||
|
||||
def cleanup_filename_template_progress(self):
|
||||
"""Clear filename template progress data"""
|
||||
self._filename_template_progress = None
|
||||
|
||||
def is_filename_template_running(self) -> bool:
|
||||
"""Check if a filename template operation is currently running"""
|
||||
if not self._filename_template_progress:
|
||||
return False
|
||||
status = self._filename_template_progress.get('status')
|
||||
return status in ['started', 'processing']
|
||||
|
||||
async def broadcast_recipe_rematch_progress(self, data: Dict[str, Any]):
|
||||
"""Broadcast recipe rematch progress to connected clients"""
|
||||
|
||||
@@ -21,6 +21,14 @@ class WebSocketProgressCallback(ProgressCallback):
|
||||
await ws_manager.broadcast_auto_organize_progress(progress_data)
|
||||
|
||||
|
||||
class WebSocketFilenameTemplateProgressCallback(ProgressCallback):
|
||||
"""WebSocket progress callback for filename template operations."""
|
||||
|
||||
async def on_progress(self, progress_data: Dict[str, Any]) -> None:
|
||||
"""Send filename template progress via WebSocket."""
|
||||
await ws_manager.broadcast_filename_template_progress(progress_data)
|
||||
|
||||
|
||||
class WebSocketBroadcastCallback:
|
||||
"""Generic WebSocket progress callback broadcasting to all clients."""
|
||||
|
||||
|
||||
@@ -39,6 +39,51 @@ def contains_dynamic_syntax(text: str) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _is_prompt_link(value: Any) -> bool:
|
||||
"""Return True for ComfyUI prompt-graph links ([node_id, output_index])."""
|
||||
|
||||
return (
|
||||
isinstance(value, list)
|
||||
and len(value) == 2
|
||||
and isinstance(value[0], str)
|
||||
and isinstance(value[1], (int, float))
|
||||
)
|
||||
|
||||
|
||||
def linked_text_requires_rerun(prompt: Any, node_id: Any, input_name: str) -> bool:
|
||||
"""Decide if a linked text input forces re-execution for dynamic expansion.
|
||||
|
||||
IS_CHANGED only receives constant inputs, so a linked text arrives as None.
|
||||
This walks the prompt graph to the upstream node and returns False only
|
||||
when that node is fully constant and free of dynamic syntax. Dynamic
|
||||
syntax — or anything that cannot be statically resolved — returns True.
|
||||
"""
|
||||
|
||||
if not isinstance(prompt, dict) or node_id is None:
|
||||
return True
|
||||
node = prompt.get(str(node_id))
|
||||
if not isinstance(node, dict):
|
||||
return True
|
||||
inputs = node.get("inputs")
|
||||
if not isinstance(inputs, dict):
|
||||
return True
|
||||
value = inputs.get(input_name)
|
||||
if not _is_prompt_link(value):
|
||||
return contains_dynamic_syntax(value)
|
||||
upstream = prompt.get(value[0])
|
||||
if not isinstance(upstream, dict):
|
||||
return True
|
||||
upstream_inputs = upstream.get("inputs")
|
||||
if not isinstance(upstream_inputs, dict):
|
||||
return True
|
||||
for upstream_value in upstream_inputs.values():
|
||||
if _is_prompt_link(upstream_value):
|
||||
return True
|
||||
if contains_dynamic_syntax(upstream_value):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_wildcards_dir(create: bool = False) -> str:
|
||||
"""Return the managed wildcard directory inside the settings folder."""
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Shared SQLite connection setup for LoRA Manager cache databases.
|
||||
|
||||
Cache databases live under the settings directory (``cache/model/<library>.sqlite``,
|
||||
``cache/recipe/<library>.sqlite``, ``cache/fts/*.sqlite``). With portable mode or a
|
||||
pinned ``LORA_MANAGER_SETTINGS_DIR`` off, that directory is shared by every ComfyUI
|
||||
instance on the machine, so two processes can open the same cache file at once.
|
||||
|
||||
SQLite serializes writers, but the default ``timeout`` is 5 seconds: a second
|
||||
instance that writes while the first is mid-transaction fails with "database is
|
||||
locked". These settings make concurrent access wait instead of failing, and keep
|
||||
the write path in WAL so readers are never blocked by a writer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
# How long a connection waits for a competing writer before raising.
|
||||
CONCURRENT_TIMEOUT_SECONDS = 30.0
|
||||
|
||||
# PRAGMAs applied to every cache connection.
|
||||
#
|
||||
# ``busy_timeout`` mirrors the connection timeout so a busy database is retried
|
||||
# inside SQLite rather than surfacing as an immediate error. ``synchronous=NORMAL``
|
||||
# is the documented companion of WAL: still crash-safe, far fewer fsyncs.
|
||||
_TUNING_PRAGMAS = (
|
||||
"PRAGMA busy_timeout = 30000",
|
||||
"PRAGMA synchronous = NORMAL",
|
||||
)
|
||||
|
||||
|
||||
def connect_cache_db(
|
||||
path: str,
|
||||
*,
|
||||
readonly: bool = False,
|
||||
uri: bool = False,
|
||||
detect_types: int = 0,
|
||||
row_factory: Any = None,
|
||||
) -> sqlite3.Connection:
|
||||
"""Open a cache database with multi-instance-friendly settings.
|
||||
|
||||
Args:
|
||||
path: Database path, or a ``file:`` URI when *uri* is True.
|
||||
readonly: Open through a read-only URI. Callers still pass the
|
||||
plain path; the ``mode=ro`` suffix is added here. The
|
||||
write-oriented tuning pragmas are skipped in that case so a
|
||||
read-only connection never attempts to change the file.
|
||||
uri: Treat *path* as a SQLite URI.
|
||||
detect_types: Forwarded to :func:`sqlite3.connect`.
|
||||
row_factory: Optional ``row_factory`` for the connection.
|
||||
|
||||
Returns:
|
||||
A configured :class:`sqlite3.Connection`.
|
||||
"""
|
||||
if readonly:
|
||||
if not uri and not path.startswith("file:"):
|
||||
path = f"file:{path}?mode=ro"
|
||||
uri = True
|
||||
|
||||
conn = sqlite3.connect(
|
||||
path,
|
||||
check_same_thread=False,
|
||||
uri=uri,
|
||||
detect_types=detect_types,
|
||||
timeout=CONCURRENT_TIMEOUT_SECONDS,
|
||||
)
|
||||
if row_factory is not None:
|
||||
conn.row_factory = row_factory
|
||||
|
||||
try:
|
||||
for pragma in _TUNING_PRAGMAS:
|
||||
# A read-only connection may reject write PRAGMAs; they are not
|
||||
# needed there anyway.
|
||||
conn.execute(pragma)
|
||||
except sqlite3.Error:
|
||||
# Tuning is best-effort: a connection that cannot set pragmas still
|
||||
# works, just without the concurrency headroom.
|
||||
pass
|
||||
|
||||
return conn
|
||||
@@ -127,6 +127,29 @@ def other_sub_type_folder_keys() -> Dict[str, List[str]]:
|
||||
# Precomputed inverse of OTHER_MODEL_FOLDER_SUBTYPES, keeping the table order.
|
||||
OTHER_SUB_TYPE_FOLDER_KEYS: Dict[str, List[str]] = other_sub_type_folder_keys()
|
||||
|
||||
# Core folder_paths keys every LoRA Manager installation understands.
|
||||
CORE_FOLDER_PATH_KEYS: List[str] = ["loras", "checkpoints", "unet", "embeddings"]
|
||||
|
||||
|
||||
def folder_path_schema() -> List[Dict[str, Any]]:
|
||||
"""Ordered schema describing the editable folder_paths keys.
|
||||
|
||||
Drives the standalone-only Model Paths settings UI: the frontend renders
|
||||
one multi-path editor per entry and resolves labels via the
|
||||
``settings.modelPaths.folderKeys.<key>`` i18n keys, so adding a new model
|
||||
category is a constants + locale change only. ``sub_type`` lets the UI
|
||||
hide editors for other-model categories the user has not enabled.
|
||||
"""
|
||||
schema: List[Dict[str, Any]] = [
|
||||
{"key": key, "category": "core", "sub_type": None}
|
||||
for key in CORE_FOLDER_PATH_KEYS
|
||||
]
|
||||
schema.extend(
|
||||
{"key": folder_key, "category": "other", "sub_type": sub_type}
|
||||
for folder_key, sub_type in OTHER_MODEL_FOLDER_SUBTYPES.items()
|
||||
)
|
||||
return schema
|
||||
|
||||
|
||||
def normalize_other_sub_types(value: Any) -> List[str]:
|
||||
"""Normalize a stored/requested enabled-sub_type list.
|
||||
@@ -250,6 +273,16 @@ CIVITAI_MODEL_TAGS = [
|
||||
"action",
|
||||
]
|
||||
|
||||
# Civitai tags that describe the listing rather than the model's content.
|
||||
# Uploaders can also set these by hand, so they must not be picked as an
|
||||
# automatic folder name; a user who wants one can still name it explicitly in
|
||||
# their priority tag list.
|
||||
CIVITAI_META_TAGS = frozenset(
|
||||
{
|
||||
"base model",
|
||||
}
|
||||
)
|
||||
|
||||
# Default priority tag configuration strings for each model type
|
||||
DEFAULT_PRIORITY_TAG_CONFIG = {
|
||||
"lora": ", ".join(CIVITAI_MODEL_TAGS),
|
||||
@@ -270,6 +303,21 @@ DEFAULT_DOWNLOAD_PATH_TEMPLATES: Dict[str, str] = {
|
||||
"other": "",
|
||||
}
|
||||
|
||||
# Length guards for template placeholders that end up in file and folder names.
|
||||
# Windows enforces MAX_PATH (260 characters) on the full path and 255 on a
|
||||
# single path component. A model folder also holds the model file, the
|
||||
# ".metadata.json" sidecar written by LoRA Manager, preview images and the
|
||||
# metadata files other tools drop next to the model (for example
|
||||
# ".civitai.info", which LoRA Manager only reads), so names stay well below
|
||||
# those limits.
|
||||
#
|
||||
# Tags get a much tighter budget than other names: some CivitAI uploaders dump
|
||||
# their whole keyword list into a single tag (see issue #1119), and such a tag
|
||||
# is only useful as a folder name after truncation.
|
||||
MAX_FOLDER_NAME_LENGTH = 100
|
||||
MAX_PATH_TAG_LENGTH = 50
|
||||
MAX_FILENAME_STEM_LENGTH = 150
|
||||
|
||||
# baseModel values from CivitAI that should be treated as diffusion models (unet)
|
||||
# These model types are incorrectly labeled as "checkpoint" by CivitAI but are actually diffusion models
|
||||
DIFFUSION_MODEL_BASE_MODELS = frozenset(
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Shared directory-browsing logic for HTTP directory pickers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Tuple
|
||||
|
||||
# Virtual path token for the Windows drive list. Browsing up from a drive
|
||||
# root (e.g. C:\) lands here so users can switch drives without typing a
|
||||
# path. Only meaningful on Windows; elsewhere it falls through to normal
|
||||
# path handling and fails the existence check.
|
||||
WINDOWS_DRIVES_TOKEN = "__drives__"
|
||||
|
||||
_IMAGE_EXTENSIONS = {
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".gif",
|
||||
".webp",
|
||||
".bmp",
|
||||
".tiff",
|
||||
".tif",
|
||||
}
|
||||
|
||||
|
||||
def browse_directory(directory_path: str) -> Tuple[Dict[str, Any], int]:
|
||||
"""Browse a directory and return (payload, http_status).
|
||||
|
||||
The payload shape matches the JSON responses historically produced by
|
||||
``BatchImportHandler.browse_directory``: on success a dict with
|
||||
``success``, ``current_path``, ``parent_path``, ``directories``,
|
||||
``image_files``, ``image_count`` and ``directory_count``; on failure a
|
||||
``{"success": False, "error": ...}`` dict with a 400/403/404/500 status.
|
||||
"""
|
||||
if os.name == "nt" and directory_path == WINDOWS_DRIVES_TOKEN:
|
||||
return _windows_drives_payload(), 200
|
||||
|
||||
# Default to the user's home directory. The frontend previously
|
||||
# sent "/" as the initial path, which is POSIX-only: on Windows it
|
||||
# resolves to the current drive root and then fails the access
|
||||
# check below.
|
||||
if not directory_path:
|
||||
path = Path.home()
|
||||
else:
|
||||
path = Path(directory_path).expanduser().resolve()
|
||||
|
||||
# Access check: browsing intentionally covers the whole server
|
||||
# filesystem (the server operator browses their own machine). On
|
||||
# POSIX every absolute path is under "/", but Path("/") has no
|
||||
# drive letter on Windows and can never anchor a drive-qualified
|
||||
# path in relative_to(), so test for a drive there instead.
|
||||
if os.name == "nt":
|
||||
is_allowed = bool(path.drive)
|
||||
else:
|
||||
is_allowed = path.is_absolute()
|
||||
|
||||
if not is_allowed:
|
||||
return {"success": False, "error": "Access denied to this directory"}, 403
|
||||
|
||||
if not path.exists():
|
||||
return {"success": False, "error": "Directory does not exist"}, 404
|
||||
|
||||
if not path.is_dir():
|
||||
return {"success": False, "error": "Path is not a directory"}, 400
|
||||
|
||||
directories = []
|
||||
image_files = []
|
||||
|
||||
try:
|
||||
for item in path.iterdir():
|
||||
try:
|
||||
if item.is_dir():
|
||||
# Skip hidden directories and common system folders
|
||||
if not item.name.startswith(".") and item.name not in [
|
||||
"__pycache__",
|
||||
"node_modules",
|
||||
]:
|
||||
directories.append(
|
||||
{
|
||||
"name": item.name,
|
||||
"path": str(item),
|
||||
"is_parent": False,
|
||||
}
|
||||
)
|
||||
elif item.is_file() and item.suffix.lower() in _IMAGE_EXTENSIONS:
|
||||
image_files.append(
|
||||
{
|
||||
"name": item.name,
|
||||
"path": str(item),
|
||||
"size": item.stat().st_size,
|
||||
}
|
||||
)
|
||||
except (PermissionError, OSError):
|
||||
# Skip files/directories we can't access
|
||||
continue
|
||||
|
||||
directories.sort(key=lambda x: x["name"].lower())
|
||||
image_files.sort(key=lambda x: x["name"].lower())
|
||||
|
||||
# Parent directory. A filesystem root is its own parent
|
||||
# (parent == path): POSIX "/" gets no parent, while a Windows
|
||||
# drive root (C:\) links up to the virtual drive list so users
|
||||
# can switch drives. The previous str(path) != str(path.root)
|
||||
# check misfired on Windows, where a drive root's parent is
|
||||
# itself, producing an infinite self-loop.
|
||||
if path.parent == path:
|
||||
parent_path = WINDOWS_DRIVES_TOKEN if os.name == "nt" else None
|
||||
else:
|
||||
parent_path = str(path.parent)
|
||||
|
||||
return (
|
||||
{
|
||||
"success": True,
|
||||
"current_path": str(path),
|
||||
"parent_path": parent_path,
|
||||
"directories": directories,
|
||||
"image_files": image_files,
|
||||
"image_count": len(image_files),
|
||||
"directory_count": len(directories),
|
||||
},
|
||||
200,
|
||||
)
|
||||
|
||||
except PermissionError:
|
||||
return {"success": False, "error": "Permission denied"}, 403
|
||||
except OSError as exc:
|
||||
return {"success": False, "error": f"Error reading directory: {str(exc)}"}, 500
|
||||
|
||||
|
||||
def _windows_drives_payload() -> Dict[str, Any]:
|
||||
"""List available drive letters as a virtual directory (Windows only)."""
|
||||
try:
|
||||
drives = os.listdrives()
|
||||
except AttributeError: # Python < 3.12
|
||||
drives = [
|
||||
f"{letter}:\\"
|
||||
for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
if os.path.exists(f"{letter}:\\")
|
||||
]
|
||||
directories = [{"name": drive, "path": drive, "is_parent": False} for drive in drives]
|
||||
return {
|
||||
"success": True,
|
||||
# Empty current_path marks the virtual level; the frontend
|
||||
# disables folder selection there.
|
||||
"current_path": "",
|
||||
"parent_path": None,
|
||||
"directories": directories,
|
||||
"image_files": [],
|
||||
"image_count": 0,
|
||||
"directory_count": len(directories),
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import inspect
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any, Dict, Optional
|
||||
from typing import TYPE_CHECKING, Any, Dict, Mapping, MutableMapping, Optional
|
||||
|
||||
from ..recipes.constants import GEN_PARAM_KEYS
|
||||
from ..services.metadata_service import get_default_metadata_provider, get_metadata_provider
|
||||
@@ -13,9 +13,20 @@ from ..services.downloader import get_downloader
|
||||
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from ..utils.video_metadata import get_video_dimensions
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Placeholder dimensions written when the real ones cannot be determined.
|
||||
# Kept for backwards compatibility with pre-existing metadata entries.
|
||||
_DEFAULT_MEDIA_WIDTH = 720
|
||||
_DEFAULT_MEDIA_HEIGHT = 1280
|
||||
|
||||
# Example metadata entries carry a marker: ``customImages`` use their ``id``
|
||||
# while ``images`` use the positional index. Either way the marker must be a
|
||||
# plain filename-safe token, never a path fragment.
|
||||
_ENTRY_MARKER_PATTERN = re.compile(r"^(?:custom_|image_)?([^./\\]+)$")
|
||||
|
||||
_preview_service = PreviewAssetService(
|
||||
metadata_manager=MetadataManager,
|
||||
downloader_factory=get_downloader,
|
||||
@@ -66,6 +77,141 @@ def _build_metadata_sync_service(settings_manager: "SettingsManager") -> Metadat
|
||||
)
|
||||
|
||||
|
||||
def _read_media_dimensions(path: str, is_video: bool) -> tuple[int, int]:
|
||||
"""Return ``(width, height)`` for an example image or video file.
|
||||
|
||||
Videos are read from their container headers (PIL cannot open them) so the
|
||||
showcase viewer sizes the gallery to the real aspect ratio. Falls back to
|
||||
the legacy ``720x1280`` placeholder when the dimensions cannot be
|
||||
determined — e.g. an unreadable file or an exotic codec — which only
|
||||
affects the displayed aspect ratio, never the file itself.
|
||||
"""
|
||||
|
||||
dimensions = None
|
||||
|
||||
if is_video:
|
||||
dimensions = get_video_dimensions(path)
|
||||
else:
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
if os.path.exists(path):
|
||||
with Image.open(path) as img:
|
||||
dimensions = img.size
|
||||
except Exception:
|
||||
dimensions = None
|
||||
|
||||
if dimensions:
|
||||
width, height = dimensions
|
||||
if width > 0 and height > 0:
|
||||
return int(width), int(height)
|
||||
|
||||
return _DEFAULT_MEDIA_WIDTH, _DEFAULT_MEDIA_HEIGHT
|
||||
|
||||
|
||||
def _is_video_entry(file_path: Optional[str], entry: Mapping[str, Any]) -> bool:
|
||||
"""Return True when an example entry points at a video file.
|
||||
|
||||
The local file extension wins over the recorded ``type`` because files in
|
||||
the wild are frequently mislabelled (animated WebP saved as ``.mp4``);
|
||||
``_read_media_dimensions`` handles that correctly either way.
|
||||
"""
|
||||
|
||||
if file_path:
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
if ext in SUPPORTED_MEDIA_EXTENSIONS["videos"]:
|
||||
return True
|
||||
if ext in SUPPORTED_MEDIA_EXTENSIONS["images"]:
|
||||
return False
|
||||
return str(entry.get("type", "")).lower() == "video"
|
||||
|
||||
|
||||
def _resolve_local_file(
|
||||
entry: Mapping[str, Any],
|
||||
index: int,
|
||||
local_files: Mapping[str, str],
|
||||
) -> Optional[str]:
|
||||
"""Map a metadata entry onto its example file inside the model folder.
|
||||
|
||||
Reads the entry's own marker (``id`` for ``customImages``, positional
|
||||
``index`` for ``images``) with an anchored regex, so the identifier can
|
||||
never bleed into a neighbouring filename the way a prefix comparison can.
|
||||
"""
|
||||
|
||||
marker = entry.get("id")
|
||||
if not isinstance(marker, str) or not marker:
|
||||
marker = str(index)
|
||||
|
||||
match = _ENTRY_MARKER_PATTERN.fullmatch(marker)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
return local_files.get(match.group(1))
|
||||
|
||||
|
||||
def repair_local_video_dimensions(
|
||||
metadata: MutableMapping[str, Any],
|
||||
local_files: Mapping[str, str],
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
) -> int:
|
||||
"""Backfill real video dimensions for an entry that has local files.
|
||||
|
||||
Only entries with an empty ``url`` are considered: those have no remote
|
||||
source, so the local file is the single source of truth for their size and
|
||||
rewriting them cannot discard API-supplied data. Entries whose dimensions
|
||||
already match the file are left byte-identical.
|
||||
|
||||
Args:
|
||||
metadata: Raw metadata payload (mutated in place unless ``dry_run``).
|
||||
local_files: ``{identifier: path}`` for files present in the model's
|
||||
example folder, where the identifier is the entry's ``id`` (for
|
||||
``customImages``) or its positional index (for ``images``).
|
||||
dry_run: Count the fixes without mutating ``metadata``.
|
||||
|
||||
Returns:
|
||||
The number of entries that were (or would be) repaired.
|
||||
"""
|
||||
|
||||
civitai = metadata.get("civitai")
|
||||
if not isinstance(civitai, dict):
|
||||
return 0
|
||||
|
||||
repaired = 0
|
||||
|
||||
for key in ("customImages", "images"):
|
||||
entries = civitai.get(key)
|
||||
if not isinstance(entries, list) or not entries:
|
||||
continue
|
||||
|
||||
for index, entry in enumerate(entries):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if entry.get("url", "") != "":
|
||||
# Remote-backed entry: never rebuilt from local state.
|
||||
continue
|
||||
|
||||
file_path = _resolve_local_file(entry, index, local_files)
|
||||
if not file_path or not os.path.isfile(file_path):
|
||||
continue
|
||||
|
||||
dimensions = _read_media_dimensions(
|
||||
file_path, _is_video_entry(file_path, entry)
|
||||
)
|
||||
width, height = dimensions
|
||||
if width <= 0 or height <= 0:
|
||||
continue
|
||||
if entry.get("width") == width and entry.get("height") == height:
|
||||
continue
|
||||
|
||||
if not dry_run:
|
||||
entry["width"] = width
|
||||
entry["height"] = height
|
||||
repaired += 1
|
||||
|
||||
return repaired
|
||||
|
||||
|
||||
def _get_metadata_sync_service() -> MetadataSyncService:
|
||||
"""Return the shared metadata sync service, initialising it lazily."""
|
||||
|
||||
@@ -230,29 +376,21 @@ class MetadataUpdater:
|
||||
# Determine if video or image
|
||||
file_ext = os.path.splitext(path)[1].lower()
|
||||
is_video = file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
|
||||
|
||||
|
||||
width, height = _read_media_dimensions(path, is_video)
|
||||
|
||||
# Create image metadata entry
|
||||
image_entry = {
|
||||
"url": "", # Empty URL as required
|
||||
"nsfwLevel": 0,
|
||||
"width": 720, # Default dimensions
|
||||
"height": 1280,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"type": "video" if is_video else "image",
|
||||
"meta": None,
|
||||
"hasMeta": False,
|
||||
"hasPositivePrompt": False
|
||||
}
|
||||
|
||||
# If it's an image, try to get actual dimensions (optional enhancement)
|
||||
try:
|
||||
from PIL import Image
|
||||
if not is_video and os.path.exists(path):
|
||||
with Image.open(path) as img:
|
||||
image_entry["width"], image_entry["height"] = img.size
|
||||
except:
|
||||
# If PIL fails or is unavailable, use default dimensions
|
||||
pass
|
||||
|
||||
|
||||
images.append(image_entry)
|
||||
|
||||
# Update the model's civitai.images field
|
||||
@@ -321,14 +459,16 @@ class MetadataUpdater:
|
||||
# Determine if video or image
|
||||
file_ext = os.path.splitext(path)[1].lower()
|
||||
is_video = file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
|
||||
|
||||
|
||||
width, height = _read_media_dimensions(path, is_video)
|
||||
|
||||
# Create image metadata entry
|
||||
image_entry = {
|
||||
"url": "", # Empty URL as requested
|
||||
"id": short_id,
|
||||
"nsfwLevel": 0,
|
||||
"width": 720, # Default dimensions
|
||||
"height": 1280,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"type": "video" if is_video else "image",
|
||||
"meta": None,
|
||||
"hasMeta": False,
|
||||
@@ -353,16 +493,6 @@ class MetadataUpdater:
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to extract metadata from {os.path.basename(path)}: {e}")
|
||||
|
||||
# If it's an image, try to get actual dimensions
|
||||
try:
|
||||
from PIL import Image
|
||||
if not is_video and os.path.exists(path):
|
||||
with Image.open(path) as img:
|
||||
image_entry["width"], image_entry["height"] = img.size
|
||||
except:
|
||||
# If PIL fails or is unavailable, use default dimensions
|
||||
pass
|
||||
|
||||
# Append to existing customImages array
|
||||
custom_images.append(image_entry)
|
||||
|
||||
|
||||
@@ -15,12 +15,20 @@ from ..utils.example_images_paths import (
|
||||
)
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from ..utils.example_images_processor import ExampleImagesProcessor
|
||||
from ..utils.example_images_metadata import update_cache_from_metadata
|
||||
from ..utils.example_images_metadata import (
|
||||
repair_local_video_dimensions,
|
||||
update_cache_from_metadata,
|
||||
)
|
||||
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CURRENT_NAMING_VERSION = 2 # Increment this when naming conventions change
|
||||
CURRENT_NAMING_VERSION = 3 # Increment this when naming conventions change
|
||||
|
||||
# Example files worth inspecting during the dimension repair.
|
||||
_REPAIRABLE_EXTENSIONS = frozenset(
|
||||
SUPPORTED_MEDIA_EXTENSIONS["images"] + SUPPORTED_MEDIA_EXTENSIONS["videos"]
|
||||
)
|
||||
|
||||
|
||||
class _SettingsProxy:
|
||||
@@ -185,6 +193,9 @@ class ExampleImagesMigration:
|
||||
if from_version < 2 and to_version >= 2:
|
||||
await ExampleImagesMigration._migrate_to_v2(model_folders)
|
||||
|
||||
if from_version < 3 and to_version >= 3:
|
||||
await ExampleImagesMigration._migrate_to_v3(example_images_path, model_folders)
|
||||
|
||||
# Update version in progress file
|
||||
progress_file = os.path.join(example_images_path, '.download_progress.json')
|
||||
try:
|
||||
@@ -437,4 +448,137 @@ class ExampleImagesMigration:
|
||||
logger.error(f"Error migrating folder {folder}: {e}")
|
||||
migration_errors += 1
|
||||
|
||||
logger.info(f"Migration to v2 complete: migrated {count} custom examples across {updated_models} models with {migration_errors} errors")
|
||||
logger.info(f"Migration to v2 complete: migrated {count} custom examples across {updated_models} models with {migration_errors} errors")
|
||||
|
||||
@staticmethod
|
||||
def _build_local_file_map(folder):
|
||||
"""Map entry markers to their files inside a model's example folder.
|
||||
|
||||
Keys are the marker alone (``custom_<id>`` → ``<id>``,
|
||||
``image_<index>`` → ``<index>``) so they line up with the metadata
|
||||
entries' ``id``/positional index without any prefix ambiguity.
|
||||
"""
|
||||
|
||||
local_files = {}
|
||||
try:
|
||||
entries = os.listdir(folder)
|
||||
except OSError as exc:
|
||||
logger.debug("Could not list example folder %s: %s", folder, exc)
|
||||
return local_files
|
||||
|
||||
for name in entries:
|
||||
stem, ext = os.path.splitext(name)
|
||||
if ext.lower() not in _REPAIRABLE_EXTENSIONS:
|
||||
continue
|
||||
if stem.startswith("custom_"):
|
||||
local_files[stem[len("custom_"):]] = os.path.join(folder, name)
|
||||
elif stem.startswith("image_"):
|
||||
local_files[stem[len("image_"):]] = os.path.join(folder, name)
|
||||
|
||||
return local_files
|
||||
|
||||
@staticmethod
|
||||
async def _find_scanner_for_hash(model_hash):
|
||||
"""Return the scanner owning ``model_hash``, or ``None``."""
|
||||
|
||||
lora_scanner = await ServiceRegistry.get_lora_scanner()
|
||||
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||
|
||||
for scanner in (lora_scanner, checkpoint_scanner, embedding_scanner):
|
||||
if scanner is None:
|
||||
continue
|
||||
try:
|
||||
if scanner.has_hash(model_hash):
|
||||
return scanner
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.debug("has_hash check failed for %s: %s", type(scanner).__name__, exc)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def _migrate_to_v3(example_images_path, model_folders):
|
||||
"""Backfill real dimensions for locally imported example videos.
|
||||
|
||||
Imported videos were stored with a hardcoded ``720x1280`` placeholder
|
||||
(issue #1115), so landscape clips were rendered inside a portrait
|
||||
container. Only entries with an empty ``url`` are touched — those have
|
||||
no remote source, which makes the local file authoritative and the
|
||||
rewrite lossless. Entries already carrying the right size are left
|
||||
untouched, so re-running this migration is a no-op.
|
||||
|
||||
This runs once per library via the ``naming_version`` gate in
|
||||
``run_migrations``; it is deliberately not wired into any request path.
|
||||
"""
|
||||
|
||||
repaired_entries = 0
|
||||
updated_models = 0
|
||||
migration_errors = 0
|
||||
|
||||
logger.info(
|
||||
"Starting v3 migration (local example video dimensions) for %d model folders",
|
||||
len(model_folders),
|
||||
)
|
||||
|
||||
for folder in model_folders:
|
||||
try:
|
||||
model_hash = os.path.basename(folder)
|
||||
if not model_hash or len(model_hash) != 64:
|
||||
continue
|
||||
|
||||
local_files = ExampleImagesMigration._build_local_file_map(folder)
|
||||
if not local_files:
|
||||
continue
|
||||
|
||||
scanner = await ExampleImagesMigration._find_scanner_for_hash(model_hash)
|
||||
if scanner is None:
|
||||
logger.debug(
|
||||
"Model %s not found in any scanner cache, skipping dimension repair",
|
||||
model_hash,
|
||||
)
|
||||
continue
|
||||
|
||||
cache = await scanner.get_cached_data()
|
||||
model_data = None
|
||||
for item in cache.raw_data:
|
||||
if item.get("sha256") == model_hash:
|
||||
model_data = item
|
||||
break
|
||||
|
||||
if not model_data:
|
||||
continue
|
||||
|
||||
file_path = model_data.get("file_path")
|
||||
if not file_path:
|
||||
continue
|
||||
|
||||
payload = await MetadataManager.load_metadata_payload(file_path)
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
|
||||
repaired = repair_local_video_dimensions(payload, local_files)
|
||||
if repaired <= 0:
|
||||
continue
|
||||
|
||||
# The model cache shape differs from the on-disk payload, so
|
||||
# persist the file first and let the cache sync re-read it.
|
||||
await MetadataManager.save_metadata(file_path, payload)
|
||||
await update_cache_from_metadata(scanner, file_path, payload)
|
||||
|
||||
repaired_entries += repaired
|
||||
updated_models += 1
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to repair example video dimensions for %s: %s",
|
||||
folder,
|
||||
exc,
|
||||
)
|
||||
migration_errors += 1
|
||||
|
||||
logger.info(
|
||||
"Migration to v3 complete: repaired %d example entr(ies) across %d model(s) "
|
||||
"with %d error(s)",
|
||||
repaired_entries,
|
||||
updated_models,
|
||||
migration_errors,
|
||||
)
|
||||
@@ -177,6 +177,11 @@ class ExifUtils:
|
||||
return brotli_meta
|
||||
|
||||
with Image.open(image_path) as img:
|
||||
# PNG text chunks may legally follow IDAT. Pillow reads those only
|
||||
# when loading the image, so inspecting info immediately after open
|
||||
# can incorrectly report a metadata-free image.
|
||||
if img.format == "PNG":
|
||||
img.load()
|
||||
info = getattr(img, "info", {}) or {}
|
||||
|
||||
if "parameters" in info:
|
||||
@@ -193,6 +198,18 @@ class ExifUtils:
|
||||
exif[piexif.ExifIFD.UserComment]
|
||||
)
|
||||
|
||||
# ComfyUI's WebP exporter stores JSON in EXIF Make/Model with
|
||||
# prompt:/workflow: prefixes instead of UserComment.
|
||||
exif = img.getexif()
|
||||
for tag in (piexif.ImageIFD.Make, piexif.ImageIFD.Model):
|
||||
text = ExifUtils._decode_exif_text(exif.get(tag))
|
||||
if not text:
|
||||
continue
|
||||
for key in ("prompt", "workflow"):
|
||||
prefix = key + ":"
|
||||
if text.startswith(prefix) and not metadata[key]:
|
||||
metadata[key] = text[len(prefix):].rstrip("\x00")
|
||||
|
||||
try:
|
||||
exif_dict = piexif.load(image_path)
|
||||
except Exception as e:
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Cross-process advisory locking for shared LoRA Manager state.
|
||||
|
||||
Two LoRA Manager processes (the ComfyUI plugin and a standalone server, or two
|
||||
ComfyUI installs pointed at the same settings directory) can open the same cache
|
||||
database. SQLite serializes individual statements, but it cannot make a
|
||||
read-modify-write *sequence* atomic across processes: two full-table cache
|
||||
replacements can interleave so that one process's snapshot overwrites the
|
||||
other's.
|
||||
|
||||
This module provides a small advisory file lock for those sequences. It is
|
||||
deliberately non-fatal: if locking is unavailable or the wait times out, callers
|
||||
keep working with SQLite's own ``busy_timeout`` as the fallback.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# How long to wait for another process to release the lock before giving up.
|
||||
DEFAULT_LOCK_TIMEOUT_SECONDS = 30.0
|
||||
_POLL_INTERVAL_SECONDS = 0.05
|
||||
|
||||
# Windows byte-range locks; fcntl.flock on POSIX.
|
||||
try: # pragma: no cover - platform dependent
|
||||
import fcntl
|
||||
except ImportError: # pragma: no cover - Windows
|
||||
fcntl = None # type: ignore[assignment]
|
||||
|
||||
try: # pragma: no cover - Windows only
|
||||
import msvcrt
|
||||
except ImportError: # pragma: no cover - POSIX
|
||||
msvcrt = None # type: ignore[assignment]
|
||||
|
||||
|
||||
class FileLockUnavailable(RuntimeError):
|
||||
"""Raised when the lock could not be acquired within the timeout."""
|
||||
|
||||
|
||||
def lock_path_for(db_path: str) -> str:
|
||||
"""Return the sibling lock file path used for *db_path*."""
|
||||
absolute = os.path.abspath(db_path)
|
||||
directory = os.path.dirname(absolute)
|
||||
if not directory:
|
||||
raise ValueError(f"Cannot derive a lock directory from {db_path!r}")
|
||||
return os.path.join(directory, f".{os.path.basename(absolute)}.lock")
|
||||
|
||||
|
||||
class CrossProcessLock:
|
||||
"""A best-effort advisory lock backed by a lock file.
|
||||
|
||||
The lock file is a sibling of the guarded resource and is never deleted:
|
||||
unlinking it would let a second process create a fresh inode and lock that
|
||||
instead, defeating mutual exclusion.
|
||||
"""
|
||||
|
||||
def __init__(self, path: str, timeout: float = DEFAULT_LOCK_TIMEOUT_SECONDS):
|
||||
self.path = path
|
||||
self.timeout = timeout
|
||||
self._handle = None
|
||||
|
||||
def acquire(self) -> bool:
|
||||
"""Try to take the lock, waiting up to ``timeout`` seconds.
|
||||
|
||||
Returns:
|
||||
True when the lock is held (including when another lock is already
|
||||
held by *this* process — the calls are not reentrant, so callers must
|
||||
not nest them). False when locking is unsupported or timed out; the
|
||||
caller should proceed and rely on the SQLite busy timeout instead.
|
||||
"""
|
||||
if fcntl is None and msvcrt is None: # pragma: no cover - exotic platform
|
||||
return False
|
||||
|
||||
os.makedirs(os.path.dirname(self.path), exist_ok=True)
|
||||
try:
|
||||
handle = open(self.path, "a+b")
|
||||
except OSError as exc:
|
||||
logger.debug("Could not open lock file %s: %s", self.path, exc)
|
||||
return False
|
||||
|
||||
deadline = time.monotonic() + max(0.0, self.timeout)
|
||||
while True:
|
||||
if self._try_lock(handle):
|
||||
self._handle = handle
|
||||
return True
|
||||
if time.monotonic() >= deadline:
|
||||
handle.close()
|
||||
return False
|
||||
time.sleep(_POLL_INTERVAL_SECONDS)
|
||||
|
||||
def release(self) -> None:
|
||||
"""Release the lock if held. Safe to call more than once."""
|
||||
handle = self._handle
|
||||
if handle is None:
|
||||
return
|
||||
self._handle = None
|
||||
try:
|
||||
self._unlock(handle)
|
||||
except OSError as exc: # pragma: no cover - defensive
|
||||
logger.debug("Failed to release lock %s: %s", self.path, exc)
|
||||
finally:
|
||||
try:
|
||||
handle.close()
|
||||
except OSError: # pragma: no cover - defensive
|
||||
pass
|
||||
|
||||
def __enter__(self) -> "CrossProcessLock":
|
||||
self.acquire()
|
||||
return self
|
||||
|
||||
def __exit__(self, *_exc_info: object) -> None:
|
||||
self.release()
|
||||
|
||||
# -- platform primitives -------------------------------------------------
|
||||
|
||||
def _try_lock(self, handle) -> bool:
|
||||
if fcntl is not None:
|
||||
try:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
if msvcrt is not None: # pragma: no cover - Windows
|
||||
try:
|
||||
handle.seek(0)
|
||||
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
return False
|
||||
|
||||
def _unlock(self, handle) -> None:
|
||||
if fcntl is not None:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||
return
|
||||
if msvcrt is not None: # pragma: no cover - Windows
|
||||
handle.seek(0)
|
||||
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
|
||||
|
||||
def exclusive_lock(db_path: str, timeout: float = DEFAULT_LOCK_TIMEOUT_SECONDS):
|
||||
"""Return a :class:`CrossProcessLock` for the database at *db_path*."""
|
||||
return CrossProcessLock(lock_path_for(db_path), timeout=timeout)
|
||||
@@ -0,0 +1,578 @@
|
||||
"""Offline extraction of reusable generation settings from image metadata.
|
||||
|
||||
Embedded graphs are data: only explicit adapters are followed, never executed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
class MetadataError(ValueError):
|
||||
"""Metadata cannot be interpreted without a user decision."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class GenerationMetadata:
|
||||
values: dict[str, Any] = field(default_factory=dict)
|
||||
loras: list[tuple[str, float, float]] = field(default_factory=list)
|
||||
issues: dict[str, str] = field(default_factory=dict)
|
||||
notes: list[str] = field(default_factory=list)
|
||||
resource_hints: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
|
||||
LORA_PATTERN = re.compile(r"<lora:([^<>]+?):([+-]?[\d.eE]+)(?::([+-]?[\d.eE]+))?>", re.I)
|
||||
SAMPLERS = {
|
||||
"euler": "euler", "euler a": "euler_ancestral", "heun": "heun",
|
||||
"lms": "lms", "dpm2": "dpm_2", "dpm2 a": "dpm_2_ancestral",
|
||||
"dpm++ 2m": "dpmpp_2m", "dpm++ 2s a": "dpmpp_2s_ancestral",
|
||||
"dpm++ sde": "dpmpp_sde", "dpm++ 2m sde": "dpmpp_2m_sde",
|
||||
"dpm++ 3m sde": "dpmpp_3m_sde", "ddim": "ddim", "uni pc": "uni_pc",
|
||||
}
|
||||
|
||||
|
||||
def finite_number(value: Any) -> float:
|
||||
if isinstance(value, bool):
|
||||
raise MetadataError("Boolean is not a numeric generation setting")
|
||||
number = float(value)
|
||||
if not math.isfinite(number):
|
||||
raise MetadataError("Generation settings must be finite numbers")
|
||||
return number
|
||||
|
||||
|
||||
def split_lora_tags(text: str) -> tuple[str, list[tuple[str, float, float]]]:
|
||||
loras = []
|
||||
|
||||
def remove(match: re.Match[str]) -> str:
|
||||
model = finite_number(match[2])
|
||||
clip = finite_number(match[3]) if match[3] is not None else model
|
||||
loras.append((match[1].strip(), model, clip))
|
||||
return ""
|
||||
|
||||
clean = LORA_PATTERN.sub(remove, text).strip()
|
||||
if re.search(r"<lora:", clean, re.I):
|
||||
raise MetadataError("Malformed LoRA directive; correct the prompt with overrides_json")
|
||||
return clean, loras
|
||||
|
||||
|
||||
def _json_object(value: Any) -> dict[str, Any]:
|
||||
if isinstance(value, str):
|
||||
if len(value) > 16 * 1024 * 1024:
|
||||
raise MetadataError("Metadata exceeds the 16 MiB parsing limit")
|
||||
value = json.loads(value)
|
||||
if not isinstance(value, dict):
|
||||
raise MetadataError("Expected a metadata JSON object")
|
||||
return value
|
||||
|
||||
|
||||
class GraphReader:
|
||||
"""Follow a selected sampler's inputs without mixing workflow branches."""
|
||||
|
||||
def __init__(self, graph: dict[str, Any], inactive_ids: set[str] | None = None) -> None:
|
||||
if len(graph) > 10000:
|
||||
raise MetadataError("Workflow exceeds the 10,000 node parsing limit")
|
||||
self.graph = {str(key): value for key, value in graph.items()}
|
||||
self.inactive_ids = inactive_ids or set()
|
||||
self.result = GenerationMetadata()
|
||||
|
||||
def node(self, link: Any, seen: tuple[str, ...]) -> tuple[str, str, dict[str, Any]]:
|
||||
if not (isinstance(link, list) and len(link) == 2 and isinstance(link[1], int)):
|
||||
raise MetadataError("Expected a workflow connection")
|
||||
node_id = str(link[0])
|
||||
if node_id in seen or len(seen) >= 100:
|
||||
raise MetadataError("Cyclic or excessively deep workflow connection")
|
||||
node = self.graph.get(node_id)
|
||||
if not isinstance(node, dict) or not isinstance(node.get("inputs"), dict):
|
||||
raise MetadataError(f"Missing or malformed node {node_id}")
|
||||
return node_id, node.get("class_type", ""), node["inputs"]
|
||||
|
||||
def scalar(self, value: Any, seen: tuple[str, ...] = ()) -> Any:
|
||||
if not isinstance(value, list):
|
||||
if isinstance(value, (str, int, float)) and not isinstance(value, bool):
|
||||
return value
|
||||
raise MetadataError("Missing or non-scalar setting")
|
||||
node_id, kind, inputs = self.node(value, seen)
|
||||
if kind == "Input Parameters (Image Saver)":
|
||||
keys = ("seed", "steps", "cfg", "sampler", "scheduler", "denoise")
|
||||
if not 0 <= value[1] < len(keys):
|
||||
raise MetadataError(f"Unsupported parameter output {value[1]} on {node_id}")
|
||||
return self.scalar(inputs.get(keys[value[1]]), (*seen, node_id))
|
||||
if value[1] != 0:
|
||||
raise MetadataError(f"Unsupported output {value[1]} on {kind} ({node_id})")
|
||||
keys = {
|
||||
"PrimitiveNode": "value", "PrimitiveInt": "value", "PrimitiveFloat": "value",
|
||||
"PrimitiveString": "value", "PrimitiveStringMultiline": "value",
|
||||
"easy int": "value", "easy float": "value", "easy string": "value",
|
||||
"Seed (rgthree)": "seed",
|
||||
"Sampler Selector (Image Saver)": "sampler_name",
|
||||
"Scheduler Selector (Image Saver)": "scheduler",
|
||||
"Text (LoraManager)": "text", "Reroute": "value",
|
||||
}
|
||||
if kind not in keys:
|
||||
raise MetadataError(f"Unsupported value node {kind} ({node_id})")
|
||||
resolved = self.scalar(inputs.get(keys[kind]), (*seen, node_id))
|
||||
if kind == "Text (LoraManager)" and isinstance(resolved, str) and re.search(r"__[^\n]+?__|\{[^{}]*\|[^{}]*\}", resolved):
|
||||
raise MetadataError("Dynamic text expansion requires an explicit prompt override")
|
||||
return resolved
|
||||
|
||||
def text(self, link: Any, seen: tuple[str, ...] = ()) -> str:
|
||||
node_id, kind, inputs = self.node(link, seen)
|
||||
if link[1] != 0:
|
||||
raise MetadataError(f"Unsupported conditioning output on {kind} ({node_id})")
|
||||
if kind in ("CLIPTextEncode", "Prompt (LoraManager)"):
|
||||
if kind == "Prompt (LoraManager)" and any(k.startswith("trigger_words") for k in inputs):
|
||||
raise MetadataError("Prompt has dynamic trigger words; provide an explicit prompt override")
|
||||
value = self.scalar(inputs.get("text"), (*seen, node_id))
|
||||
if not isinstance(value, str):
|
||||
raise MetadataError("Prompt is not text")
|
||||
if kind == "Prompt (LoraManager)" and re.search(r"__[^\n]+?__|\{[^{}]*\|[^{}]*\}", value):
|
||||
raise MetadataError("Dynamic prompt expansion cannot be recovered from source text; provide an explicit prompt override")
|
||||
return value
|
||||
if kind in ("CLIPTextEncodeSDXL", "CLIPTextEncodeFlux"):
|
||||
keys = ("text_g", "text_l") if kind == "CLIPTextEncodeSDXL" else ("clip_l", "t5xxl")
|
||||
texts = [self.scalar(inputs.get(key), (*seen, node_id)) for key in keys]
|
||||
if texts[0] != texts[1] or not isinstance(texts[0], str):
|
||||
raise MetadataError(f"{kind} has distinct encoder prompts; a single string cannot reproduce it")
|
||||
self.result.notes.append(f"{kind}: restore architecture-specific conditioning separately.")
|
||||
return texts[0]
|
||||
if kind == "ConditioningZeroOut":
|
||||
raise MetadataError("Zeroed conditioning is not equivalent to encoding an empty prompt")
|
||||
raise MetadataError(f"Unsupported conditioning node {kind} ({node_id}); use a prompt override")
|
||||
|
||||
def widget_loras(self, value: Any) -> list[tuple[str, float, float]]:
|
||||
if isinstance(value, dict):
|
||||
value = value.get("__value__")
|
||||
if isinstance(value, list) and len(value) == 1 and isinstance(value[0], list):
|
||||
value = value[0]
|
||||
if not isinstance(value, list):
|
||||
raise MetadataError("Unsupported LoRA widget data")
|
||||
entries = []
|
||||
for item in value:
|
||||
if not isinstance(item, dict):
|
||||
raise MetadataError("Malformed LoRA widget entry")
|
||||
if item.get("active", False):
|
||||
name = item.get("name")
|
||||
if not isinstance(name, str) or not name:
|
||||
raise MetadataError("LoRA name is missing")
|
||||
strength = finite_number(item.get("strength"))
|
||||
entries.append((name, strength, finite_number(item.get("clipStrength", strength))))
|
||||
return entries
|
||||
|
||||
def stack(self, link: Any, seen: tuple[str, ...] = ()) -> list[tuple[str, float, float]]:
|
||||
node_id, kind, inputs = self.node(link, seen)
|
||||
if link[1] != 0:
|
||||
raise MetadataError("Unsupported LoRA stack output")
|
||||
seen = (*seen, node_id)
|
||||
if kind == "Lora Stacker (LoraManager)":
|
||||
previous = self.stack(inputs["lora_stack"], seen) if "lora_stack" in inputs else []
|
||||
return previous + self.widget_loras(inputs.get("loras", []))
|
||||
if kind == "Lora Stack Combiner (LoraManager)":
|
||||
entries = []
|
||||
keys = [key for key in inputs if re.fullmatch(r"lora_stack\d+", key)]
|
||||
for key in sorted(keys, key=lambda key: int(key[len("lora_stack"):])):
|
||||
entries.extend(self.stack(inputs[key], seen))
|
||||
return entries
|
||||
raise MetadataError(f"Unsupported LoRA stack node {kind} ({node_id})")
|
||||
|
||||
def model(self, link: Any, seen: tuple[str, ...] = ()) -> None:
|
||||
node_id, kind, inputs = self.node(link, seen)
|
||||
if link[1] != 0:
|
||||
raise MetadataError("Unsupported model output")
|
||||
seen = (*seen, node_id)
|
||||
loaders = {
|
||||
"CheckpointLoaderSimple": ("checkpoint_name", "ckpt_name"),
|
||||
"CheckpointLoader": ("checkpoint_name", "ckpt_name"),
|
||||
"Checkpoint Loader (LoraManager)": ("checkpoint_name", "ckpt_name"),
|
||||
"UNETLoader": ("unet_name", "unet_name"),
|
||||
"Unet Loader (LoraManager)": ("unet_name", "unet_name"),
|
||||
}
|
||||
if kind in loaders:
|
||||
output, key = loaders[kind]
|
||||
self.result.values[output] = self.scalar(inputs.get(key), seen)
|
||||
return
|
||||
if kind in ("LoraLoader", "LoraLoaderModelOnly", "Lora Loader (LoraManager)", "LoraLoaderLM", "LoRA Text Loader (LoraManager)"):
|
||||
self.model(inputs.get("model"), seen)
|
||||
if "lora_stack" in inputs:
|
||||
self.result.loras.extend(self.stack(inputs["lora_stack"], seen))
|
||||
if kind in ("LoraLoader", "LoraLoaderModelOnly"):
|
||||
strength = finite_number(self.scalar(inputs.get("strength_model"), seen))
|
||||
clip = 0.0 if kind == "LoraLoaderModelOnly" else finite_number(self.scalar(inputs.get("strength_clip"), seen))
|
||||
name = self.scalar(inputs.get("lora_name"), seen)
|
||||
if not isinstance(name, str):
|
||||
raise MetadataError("LoRA name is not text")
|
||||
self.result.loras.append((name, strength, clip))
|
||||
elif kind == "LoRA Text Loader (LoraManager)":
|
||||
_, entries = split_lora_tags(self.scalar(inputs.get("lora_syntax"), seen))
|
||||
self.result.loras.extend(entries)
|
||||
else:
|
||||
self.result.loras.extend(self.widget_loras(inputs.get("loras", [])))
|
||||
return
|
||||
raise MetadataError(f"Unsupported model node {kind} ({node_id}); model/LoRA chain is incomplete")
|
||||
|
||||
def clip_loras(self, link: Any, seen: tuple[str, ...] = ()) -> list[tuple[str, float]]:
|
||||
"""Check that prompt CLIP branches actually use the recovered LoRA stack."""
|
||||
node_id, kind, inputs = self.node(link, seen)
|
||||
seen = (*seen, node_id)
|
||||
if kind in ("CheckpointLoaderSimple", "CheckpointLoader", "Checkpoint Loader (LoraManager)") and link[1] == 1:
|
||||
return []
|
||||
if kind in ("CLIPLoader", "DualCLIPLoader", "TripleCLIPLoader") and link[1] == 0:
|
||||
return []
|
||||
if kind in ("LoraLoader", "Lora Loader (LoraManager)", "LoraLoaderLM", "LoRA Text Loader (LoraManager)") and link[1] == 1:
|
||||
previous = self.clip_loras(inputs.get("clip"), seen)
|
||||
entries = self.stack(inputs["lora_stack"], seen) if "lora_stack" in inputs else []
|
||||
if kind == "LoraLoader":
|
||||
entries.append((self.scalar(inputs.get("lora_name")), 0, finite_number(self.scalar(inputs.get("strength_clip")))))
|
||||
elif kind == "LoRA Text Loader (LoraManager)":
|
||||
_, parsed = split_lora_tags(self.scalar(inputs.get("lora_syntax")))
|
||||
entries.extend(parsed)
|
||||
else:
|
||||
entries.extend(self.widget_loras(inputs.get("loras", [])))
|
||||
return previous + [(name, clip) for name, _, clip in entries if clip != 0]
|
||||
raise MetadataError(f"Unsupported CLIP branch {kind} ({node_id}); restore text encoder/conditioning separately")
|
||||
|
||||
def select_sampler(self, sampler_id: str) -> str:
|
||||
candidates = [key for key, node in self.graph.items() if isinstance(node, dict) and node.get("class_type") in ("KSampler", "KSamplerAdvanced", "SamplerCustomAdvanced")
|
||||
and node.get("mode", 0) == 0
|
||||
and not any(key == prefix or key.startswith(prefix + ":") for prefix in self.inactive_ids)]
|
||||
selector = sampler_id.strip()
|
||||
if selector in candidates:
|
||||
return selector
|
||||
# ComfyUI API prompts expand native subgraphs into colon-qualified IDs.
|
||||
# Accept slash paths too, as well as an unambiguous container/leaf ID.
|
||||
selector = selector.replace("/", ":")
|
||||
if selector in self.graph and selector not in candidates:
|
||||
raise MetadataError(f"Sampler {selector} is muted, bypassed or unsupported; active sampler IDs: {', '.join(candidates) or 'none'}")
|
||||
if selector in candidates:
|
||||
return selector
|
||||
matches = candidates if not selector else [key for key in candidates if key.startswith(selector + ":") or key.endswith(":" + selector)]
|
||||
if len(matches) == 1:
|
||||
return matches[0]
|
||||
choices = ", ".join(matches or candidates) or "none"
|
||||
raise MetadataError(f"Choose a unique sampler_node_id; supported sampler IDs: {choices}")
|
||||
|
||||
def custom_sampler_inputs(self, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Adapt the core advanced sampling pipeline without executing any nodes."""
|
||||
result = {"latent_image": inputs.get("latent_image")}
|
||||
adapters = (
|
||||
("noise", {"RandomNoise": {"seed": "noise_seed"}}, ("seed",)),
|
||||
("guider", {
|
||||
"CFGGuider": {"cfg": "cfg", "model": "model", "positive": "positive", "negative": "negative"},
|
||||
"BasicGuider": {"model": "model", "positive": "conditioning"},
|
||||
}, ("cfg", "model", "positive", "negative")),
|
||||
("sigmas", {"BasicScheduler": {"steps": "steps", "scheduler": "scheduler", "denoise": "denoise"}}, ("steps", "scheduler", "denoise")),
|
||||
)
|
||||
for key, kinds, fields in adapters:
|
||||
try:
|
||||
link = inputs.get(key)
|
||||
node_id, kind, upstream = self.node(link, ())
|
||||
if link[1] != 0 or kind not in kinds:
|
||||
raise MetadataError(f"Unsupported {key} node {kind} ({node_id})")
|
||||
for output, source in kinds[kind].items():
|
||||
result[output] = upstream.get(source)
|
||||
if kind == "BasicGuider":
|
||||
result["cfg"] = 1.0
|
||||
self.result.issues["negative"] = "BasicGuider has no negative conditioning; restore that architecture-specific setup separately"
|
||||
except MetadataError as exc:
|
||||
for field in fields:
|
||||
self.result.issues[field] = str(exc)
|
||||
try:
|
||||
link = inputs.get("sampler")
|
||||
seen = ()
|
||||
while True:
|
||||
node_id, kind, upstream = self.node(link, seen)
|
||||
seen = (*seen, node_id)
|
||||
if link[1] != 0:
|
||||
raise MetadataError("Unsupported sampler output")
|
||||
if kind == "KSamplerSelect":
|
||||
result["sampler_name"] = upstream.get("sampler_name")
|
||||
break
|
||||
if kind == "DetailDaemonSamplerNode":
|
||||
self.result.issues["sampler_effects"] = "Detail Daemon modifies sampling; recovered base sampler settings do not reproduce this effect"
|
||||
link = upstream.get("sampler")
|
||||
continue
|
||||
raise MetadataError(f"Unsupported sampler node {kind} ({node_id})")
|
||||
except MetadataError as exc:
|
||||
self.result.issues["sampler_name"] = str(exc)
|
||||
return result
|
||||
|
||||
def read(self, sampler_id: str) -> GenerationMetadata:
|
||||
sampler_id = self.select_sampler(sampler_id)
|
||||
node = self.graph[sampler_id]
|
||||
inputs = node.get("inputs")
|
||||
if not isinstance(inputs, dict):
|
||||
raise MetadataError("Malformed sampler inputs")
|
||||
self.result.notes.append(f"ComfyUI API graph; sampler {sampler_id} ({node['class_type']}).")
|
||||
if node["class_type"] == "SamplerCustomAdvanced":
|
||||
inputs = self.custom_sampler_inputs(inputs)
|
||||
for output, key in {"seed": "noise_seed" if node["class_type"] == "KSamplerAdvanced" else "seed", "steps": "steps", "cfg": "cfg", "sampler_name": "sampler_name", "scheduler": "scheduler"}.items():
|
||||
try:
|
||||
self.result.values[output] = self.scalar(inputs.get(key))
|
||||
except (ValueError, TypeError) as exc:
|
||||
self.result.issues[output] = str(exc)
|
||||
if node["class_type"] == "KSamplerAdvanced":
|
||||
self.result.issues["denoise"] = "KSamplerAdvanced start/end/noise settings cannot be represented by denoise alone"
|
||||
else:
|
||||
try:
|
||||
self.result.values["denoise"] = self.scalar(inputs.get("denoise", 1.0))
|
||||
except (ValueError, TypeError) as exc:
|
||||
self.result.issues["denoise"] = str(exc)
|
||||
for key in ("positive", "negative"):
|
||||
try:
|
||||
self.result.values[key] = self.text(inputs.get(key))
|
||||
except (ValueError, TypeError) as exc:
|
||||
self.result.issues[key] = str(exc)
|
||||
try:
|
||||
self.model(inputs.get("model"))
|
||||
except (ValueError, TypeError) as exc:
|
||||
self.result.issues["model"] = str(exc)
|
||||
self.result.issues["loras"] = "Model/LoRA chain could not be fully recovered"
|
||||
expected_clip = [(name, clip) for name, _, clip in self.result.loras if clip != 0]
|
||||
for polarity in ("positive", "negative"):
|
||||
if polarity in self.result.issues:
|
||||
continue
|
||||
try:
|
||||
_, _, encoder = self.node(inputs.get(polarity), ())
|
||||
if "clip" in encoder:
|
||||
actual_clip = self.clip_loras(encoder["clip"])
|
||||
if actual_clip != expected_clip:
|
||||
self.result.issues["loras"] = "Model and prompt CLIP branches use different LoRAs; explicitly choose a reusable stack with a loras override"
|
||||
except MetadataError as exc:
|
||||
self.result.issues[polarity] = str(exc)
|
||||
try:
|
||||
_, kind, latent = self.node(inputs.get("latent_image"), ())
|
||||
if kind in ("EmptyLatentImage", "EmptySD3LatentImage"):
|
||||
for key in ("width", "height"):
|
||||
self.result.values[key] = self.scalar(latent.get(key))
|
||||
else:
|
||||
self.result.notes.append("Latent dimensions unavailable; using image dimensions. Restore the original latent/img2img setup separately.")
|
||||
except MetadataError:
|
||||
self.result.notes.append("Latent dimensions unavailable; using image dimensions.")
|
||||
return self.result
|
||||
|
||||
|
||||
def _parameter_fields(text: str) -> dict[str, str]:
|
||||
"""Split multiline parameters without splitting JSON objects or quoted names."""
|
||||
parts = []
|
||||
start = 0
|
||||
depth = 0
|
||||
quoted = False
|
||||
escaped = False
|
||||
for index, char in enumerate(text):
|
||||
if quoted:
|
||||
if escaped:
|
||||
escaped = False
|
||||
elif char == "\\":
|
||||
escaped = True
|
||||
elif char == '"':
|
||||
quoted = False
|
||||
elif char == '"':
|
||||
quoted = True
|
||||
elif char in "[{":
|
||||
depth += 1
|
||||
elif char in "]}":
|
||||
depth = max(0, depth - 1)
|
||||
elif char == "," and depth == 0:
|
||||
parts.append(text[start:index])
|
||||
start = index + 1
|
||||
parts.append(text[start:])
|
||||
fields = {}
|
||||
for part in parts:
|
||||
match = re.match(r"^\s*([\w ]+):\s*([\s\S]*)$", part)
|
||||
if match:
|
||||
fields[match[1].strip()] = match[2].strip()
|
||||
return fields
|
||||
|
||||
|
||||
def _parameter_loras(fields: dict[str, str], result: GenerationMetadata) -> None:
|
||||
for key in ("positive", "negative"):
|
||||
result.values[key], entries = split_lora_tags(result.values[key])
|
||||
result.loras.extend(entries)
|
||||
try:
|
||||
hashes = json.loads(fields.get("Hashes", "{}"))
|
||||
resources = json.loads(fields.get("Civitai resources", "[]"))
|
||||
if not isinstance(hashes, dict) or not isinstance(resources, list):
|
||||
raise ValueError("Invalid resource containers")
|
||||
except (ValueError, TypeError) as exc:
|
||||
result.issues["loras"] = f"Malformed embedded resource metadata: {exc}"
|
||||
return
|
||||
names = [(key[5:], value) for key, value in hashes.items() if key.upper().startswith("LORA:")]
|
||||
weighted = [item for item in resources if isinstance(item, dict) and "weight" in item]
|
||||
result.resource_hints = [{"name": name, "hash": value} for name, value in names]
|
||||
if result.loras:
|
||||
if len(names) == 1 and len(weighted) == 1:
|
||||
strength = finite_number(weighted[0]["weight"])
|
||||
single = (names[0][0], strength, strength)
|
||||
if len(result.loras) > 1 and all(entry == single for entry in result.loras):
|
||||
result.loras = [single]
|
||||
result.notes.append("Repeated identical prompt tags collapsed to the single LoRA recorded in resource metadata.")
|
||||
return
|
||||
# Without a catalog there is no general mapping between a hash name and
|
||||
# a Civitai version ID. One name and one resource are unambiguous; multiple
|
||||
# resources must not be paired by their incidental JSON ordering.
|
||||
if len(names) == 1 and len(weighted) == 1:
|
||||
strength = finite_number(weighted[0]["weight"])
|
||||
result.loras.append((names[0][0], strength, strength))
|
||||
result.resource_hints[0].update(weighted[0])
|
||||
result.notes.append("LoRA name recovered from Hashes and its sole resource weight; separate CLIP strength was not saved, so model strength is used for both.")
|
||||
elif names or weighted:
|
||||
result.issues["loras"] = "LoRA resource names/weights cannot be paired unambiguously without a catalog; provide an explicit loras override"
|
||||
|
||||
|
||||
def parse_parameters(text: str) -> GenerationMetadata:
|
||||
match = re.search(r"^Steps:\s*\d+.*$", text, re.M)
|
||||
if not match:
|
||||
raise MetadataError("No supported A1111/Forge generation parameters found")
|
||||
prompt = text[:match.start()].strip()
|
||||
positive, separator, negative = prompt.partition("Negative prompt:")
|
||||
fields = _parameter_fields(text[match.start():])
|
||||
result = GenerationMetadata(notes=["A1111/Forge parameters."])
|
||||
result.values.update(positive=positive.strip(), negative=negative.strip() if separator else "")
|
||||
for output, key in {"seed": "Seed", "steps": "Steps", "cfg": "CFG scale", "sampler_name": "Sampler", "scheduler": "Schedule type", "checkpoint_name": "Model", "denoise": "Denoising strength"}.items():
|
||||
if key in fields:
|
||||
result.values[output] = fields[key].strip().strip('"')
|
||||
result.values.setdefault("denoise", 1.0)
|
||||
size = re.fullmatch(r"(\d+)x(\d+)", fields.get("Size", "").strip())
|
||||
if size:
|
||||
result.values.update(width=int(size[1]), height=int(size[2]))
|
||||
sampler = str(result.values.get("sampler_name", "")).lower().strip()
|
||||
for suffix, scheduler in (
|
||||
(" sgm uniform", "sgm_uniform"), (" sgm_uniform", "sgm_uniform"),
|
||||
(" karras", "karras"), (" exponential", "exponential"),
|
||||
(" simple", "simple"), ("_simple", "simple"),
|
||||
(" normal", "normal"), ("_normal", "normal"), ("_sgm_uniform", "sgm_uniform"),
|
||||
(" ddim uniform", "ddim_uniform"),
|
||||
(" beta", "beta"), (" linear quadratic", "linear_quadratic"),
|
||||
):
|
||||
if sampler.endswith(suffix):
|
||||
sampler = sampler[:-len(suffix)]
|
||||
result.values.setdefault("scheduler", scheduler)
|
||||
break
|
||||
result.values["sampler_name"] = SAMPLERS.get(sampler, sampler)
|
||||
if "scheduler" in result.values:
|
||||
result.values["scheduler"] = result.values["scheduler"].lower()
|
||||
if result.values["scheduler"] == "automatic":
|
||||
result.values.pop("scheduler")
|
||||
if "scheduler" not in result.values:
|
||||
result.issues["scheduler"] = "A1111 scheduler is unspecified/Automatic; choose an explicit ComfyUI scheduler"
|
||||
for key in ("Clip skip", "Hires upscale", "Hires steps", "Hires upscaler"):
|
||||
if key in fields:
|
||||
result.notes.append(f"Restore separately: {key}: {fields[key]}")
|
||||
_parameter_loras(fields, result)
|
||||
return result
|
||||
|
||||
|
||||
def inactive_workflow_nodes(workflow: dict[str, Any]) -> set[str]:
|
||||
"""Map muted/bypassed instances and nested nodes to API-qualified IDs."""
|
||||
inactive: set[str] = set()
|
||||
definitions = {str(item["id"]): item for item in workflow.get("definitions", {}).get("subgraphs", []) if isinstance(item, dict) and "id" in item}
|
||||
count = 0
|
||||
|
||||
def visit(container: dict[str, Any], prefix: str, ancestors: tuple[str, ...]) -> None:
|
||||
nonlocal count
|
||||
for node in container.get("nodes", []):
|
||||
count += 1
|
||||
if count > 10000 or len(ancestors) > 100:
|
||||
raise MetadataError("Workflow subgraph traversal limit exceeded")
|
||||
if not isinstance(node, dict) or "id" not in node:
|
||||
continue
|
||||
node_id = prefix + str(node["id"])
|
||||
if node.get("mode", 0) != 0:
|
||||
inactive.add(node_id)
|
||||
continue
|
||||
kind = node.get("type")
|
||||
if kind in definitions:
|
||||
if kind in ancestors:
|
||||
raise MetadataError("Cyclic workflow subgraph definition")
|
||||
visit(definitions[kind], node_id + ":", (*ancestors, kind))
|
||||
|
||||
visit(workflow, "", ())
|
||||
return inactive
|
||||
|
||||
|
||||
def extract_generation_metadata(
|
||||
fields: dict[str, Any], sampler_id: str = "", prefer_saved_image_metadata: bool = True,
|
||||
) -> GenerationMetadata:
|
||||
parameters = fields.get("parameters") or fields.get("comment")
|
||||
saved_text = isinstance(parameters, str) and bool(parameters.strip()) and not parameters.lstrip().startswith("{")
|
||||
recovery_notes = []
|
||||
if prefer_saved_image_metadata and saved_text:
|
||||
try:
|
||||
result = parse_parameters(parameters)
|
||||
result.notes.append("Source: saved image generation parameters (preferred).")
|
||||
if sampler_id.strip():
|
||||
result.notes.append("sampler_node_id is ignored while using saved image generation parameters.")
|
||||
return result
|
||||
except (ValueError, TypeError) as exc:
|
||||
recovery_notes.append(f"ERROR: Saved image metadata could not be parsed: {exc}; trying workflow metadata.")
|
||||
prompt = fields.get("prompt")
|
||||
workflow = _json_object(fields["workflow"]) if fields.get("workflow") else None
|
||||
if prompt:
|
||||
try:
|
||||
graph = _json_object(prompt)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise MetadataError(f"Malformed embedded prompt: {exc}") from exc
|
||||
result = GraphReader(graph, inactive_workflow_nodes(workflow) if workflow else None).read(sampler_id.strip())
|
||||
elif isinstance(parameters, str) and parameters.lstrip().startswith("{"):
|
||||
result = GraphReader(_json_object(parameters), inactive_workflow_nodes(workflow) if workflow else None).read(sampler_id.strip())
|
||||
elif workflow:
|
||||
result = GraphReader(workflow_to_prompt(workflow)).read(sampler_id.strip())
|
||||
result.notes.insert(0, "UI workflow fallback: only known core widget layouts are supported; saved widget values may differ from executed values.")
|
||||
elif saved_text:
|
||||
result = parse_parameters(parameters)
|
||||
result.notes.append("Source: saved image generation parameters; no workflow metadata available.")
|
||||
else:
|
||||
raise MetadataError("Image contains no supported generation metadata")
|
||||
result.notes.extend(recovery_notes)
|
||||
return result
|
||||
|
||||
|
||||
def workflow_to_prompt(workflow: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Decode only known core widget layouts; preserve links to unknown nodes."""
|
||||
nodes = workflow.get("nodes")
|
||||
links = workflow.get("links", [])
|
||||
if not isinstance(nodes, list) or not isinstance(links, list) or len(nodes) > 10000:
|
||||
raise MetadataError("Malformed or excessively large UI workflow")
|
||||
link_map = {}
|
||||
for link in links:
|
||||
if isinstance(link, list) and len(link) >= 5:
|
||||
link_map[str(link[0])] = [str(link[1]), link[2]]
|
||||
layouts = {
|
||||
"CheckpointLoaderSimple": ["ckpt_name"],
|
||||
"UNETLoader": ["unet_name", "weight_dtype"],
|
||||
"LoraLoader": ["lora_name", "strength_model", "strength_clip"],
|
||||
"LoraLoaderModelOnly": ["lora_name", "strength_model"],
|
||||
"CLIPTextEncode": ["text"],
|
||||
"EmptyLatentImage": ["width", "height", "batch_size"],
|
||||
"EmptySD3LatentImage": ["width", "height", "batch_size"],
|
||||
"KSampler": ["seed", "control_after_generate", "steps", "cfg", "sampler_name", "scheduler", "denoise"],
|
||||
"PrimitiveNode": ["value"],
|
||||
"PrimitiveInt": ["value"], "PrimitiveFloat": ["value"],
|
||||
"PrimitiveString": ["value"], "PrimitiveStringMultiline": ["value"],
|
||||
}
|
||||
graph = {}
|
||||
for node in nodes:
|
||||
if not isinstance(node, dict) or "id" not in node:
|
||||
raise MetadataError("Malformed workflow node")
|
||||
kind = node.get("type", "")
|
||||
widgets = node.get("widgets_values", [])
|
||||
inputs = {}
|
||||
layout = layouts.get(kind)
|
||||
if node.get("mode", 0) != 0:
|
||||
kind = "Unsupported muted/bypassed " + kind
|
||||
elif layout is not None:
|
||||
if not isinstance(widgets, list):
|
||||
raise MetadataError(f"Unsupported widget layout for {kind}")
|
||||
if kind == "KSampler" and len(widgets) == 6:
|
||||
layout = [key for key in layout if key != "control_after_generate"]
|
||||
for key, value in zip(layout, widgets):
|
||||
inputs[key] = value
|
||||
for slot in node.get("inputs", []):
|
||||
if not isinstance(slot, dict) or not isinstance(slot.get("name"), str):
|
||||
raise MetadataError("Malformed workflow input")
|
||||
if slot.get("link") is not None:
|
||||
inputs[slot["name"]] = link_map.get(str(slot["link"]), ["missing", 0])
|
||||
graph[str(node["id"])] = {"class_type": kind, "inputs": inputs}
|
||||
return graph
|
||||
@@ -174,12 +174,42 @@ def ensure_settings_file(logger: Optional[logging.Logger] = None) -> str:
|
||||
return target_path
|
||||
|
||||
|
||||
def _portable_env_override() -> Optional[bool]:
|
||||
"""Return the portable mode forced by ``LORA_MANAGER_PORTABLE``, if any.
|
||||
|
||||
Returns:
|
||||
``True`` when the variable enables portable mode, ``False`` when it is
|
||||
explicitly set to ``"0"``, and ``None`` when it is unset or holds some
|
||||
other value (in which case the persisted settings flag decides).
|
||||
"""
|
||||
|
||||
raw = os.environ.get(_LM_PORTABLE_ENV)
|
||||
if raw is None:
|
||||
return None
|
||||
if raw == "1":
|
||||
return True
|
||||
if raw == "0":
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def _should_use_portable_settings(path: str, logger: logging.Logger) -> bool:
|
||||
"""Return ``True`` when the env var forces it or the settings file enables it."""
|
||||
|
||||
if os.environ.get(_LM_PORTABLE_ENV, "0") == "1":
|
||||
override = _portable_env_override()
|
||||
if override is True:
|
||||
logger.debug("Portable mode enabled via %s", _LM_PORTABLE_ENV)
|
||||
return True
|
||||
if override is False:
|
||||
# Explicit opt-out. Without this, a single `LORA_MANAGER_PORTABLE=1`
|
||||
# run would pin the shared plugin settings.json to portable mode
|
||||
# forever, with no way back except editing that file by hand.
|
||||
logger.info(
|
||||
"Portable mode disabled via %s=%s",
|
||||
_LM_PORTABLE_ENV,
|
||||
os.environ.get(_LM_PORTABLE_ENV, ""),
|
||||
)
|
||||
return False
|
||||
|
||||
if not os.path.exists(path):
|
||||
return False
|
||||
|
||||
@@ -5,6 +5,8 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Iterable, List, Optional, Sequence, Set
|
||||
|
||||
from .constants import CIVITAI_META_TAGS, MAX_PATH_TAG_LENGTH
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PriorityTagEntry:
|
||||
@@ -102,3 +104,43 @@ def collect_canonical_tags(entries: Iterable[PriorityTagEntry]) -> List[str]:
|
||||
"""Return the ordered list of canonical tags from the parsed entries."""
|
||||
|
||||
return [entry.canonical for entry in entries]
|
||||
|
||||
|
||||
def is_usable_path_tag(tag: object) -> bool:
|
||||
"""Return True when a tag is a sane single-concept folder-name candidate.
|
||||
|
||||
CivitAI tags are normally short labels ("character", "anime"), but some
|
||||
uploaders dump their whole keyword list into a single tag, e.g.
|
||||
``"lora, character, rosie, irish, ... face"``. Using such a tag as a folder
|
||||
name produces unwieldy and path-length-breaking directories (#1119), so
|
||||
tag-derived path segments only accept single-concept tags.
|
||||
"""
|
||||
|
||||
if not isinstance(tag, str):
|
||||
return False
|
||||
|
||||
candidate = tag.strip()
|
||||
if not candidate:
|
||||
return False
|
||||
|
||||
# Commas mean the tag is a keyword dump rather than one concept.
|
||||
if "," in candidate:
|
||||
return False
|
||||
|
||||
return len(candidate) <= MAX_PATH_TAG_LENGTH
|
||||
|
||||
|
||||
def is_civitai_meta_tag(tag: object) -> bool:
|
||||
"""Return True for Civitai labels that describe the listing, not content.
|
||||
|
||||
Civitai attaches structural tags such as "base model" to the same list as
|
||||
real content tags. They carry no organisational meaning, so the automatic
|
||||
fallback must not turn one into a folder name. A user who does want such a
|
||||
folder can still put the label in their priority tag list, because explicit
|
||||
priority matches bypass this check.
|
||||
"""
|
||||
|
||||
if not isinstance(tag, str):
|
||||
return False
|
||||
|
||||
return tag.strip().casefold() in CIVITAI_META_TAGS
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Helpers for generating URLs that survive reverse-proxy subpath mounts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def relative_root_prefix(request_path: str) -> str:
|
||||
"""Return the relative prefix ("", "../", ...) that takes a manager page
|
||||
back to the mount root.
|
||||
|
||||
Templates reference assets and pages with relative URLs (e.g.
|
||||
``{{ rel_prefix }}loras_static/...``) so the browser keeps whatever
|
||||
subpath a reverse proxy (llama-swap, SwarmUI, ...) mounted ComfyUI under.
|
||||
The backend only ever sees the stripped path, so the depth of the page
|
||||
route is all that matters: "/loras" -> "", "/loras/recipes" -> "../".
|
||||
"""
|
||||
|
||||
segments = [segment for segment in request_path.split("/") if segment]
|
||||
return "../" * max(len(segments) - 1, 0)
|
||||
+151
-3
@@ -1,12 +1,20 @@
|
||||
from difflib import SequenceMatcher
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..config import config
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
from .constants import (
|
||||
MAX_FILENAME_STEM_LENGTH,
|
||||
MAX_FOLDER_NAME_LENGTH,
|
||||
MAX_PATH_TAG_LENGTH,
|
||||
)
|
||||
import asyncio
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_lora_info(lora_name):
|
||||
"""Get the lora path and trigger words from cache"""
|
||||
@@ -111,6 +119,16 @@ def get_lora_info_absolute(lora_name):
|
||||
scanner = await ServiceRegistry.get_lora_scanner()
|
||||
cache = await scanner.get_cached_data()
|
||||
|
||||
# Stack producers can resolve an exact business path. Preserve it even
|
||||
# when several indexed LoRAs share the same basename.
|
||||
if os.path.isabs(lora_name):
|
||||
for item in cache.raw_data:
|
||||
file_path = item.get("file_path")
|
||||
if file_path and os.path.abspath(file_path) == os.path.abspath(lora_name):
|
||||
civitai = item.get("civitai") or {}
|
||||
return file_path, civitai.get("trainedWords", [])
|
||||
return lora_name, []
|
||||
|
||||
lora_name_normalized = lora_name.replace("\\", "/")
|
||||
lora_name_no_ext = lora_name_normalized
|
||||
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||
@@ -414,12 +432,17 @@ def fuzzy_match(text: str, pattern: str, threshold: float = 0.85) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def sanitize_folder_name(name: str, replacement: str = "_") -> str:
|
||||
def sanitize_folder_name(
|
||||
name: str, replacement: str = "_", max_length: Optional[int] = None
|
||||
) -> str:
|
||||
"""Sanitize a folder name by removing or replacing invalid characters.
|
||||
|
||||
Args:
|
||||
name: The original folder name.
|
||||
replacement: The character to use when replacing invalid characters.
|
||||
max_length: Optional maximum length for the resulting name. Longer
|
||||
names are truncated (and re-trimmed) so that a single untrusted
|
||||
value cannot blow past filesystem path limits.
|
||||
|
||||
Returns:
|
||||
A sanitized folder name safe to use across common filesystems.
|
||||
@@ -446,6 +469,15 @@ def sanitize_folder_name(name: str, replacement: str = "_") -> str:
|
||||
# If no replacement, just strip spaces and dots from right, spaces from left
|
||||
sanitized = sanitized.rstrip(" .").lstrip(" ")
|
||||
|
||||
if max_length is not None and max_length > 0 and len(sanitized) > max_length:
|
||||
sanitized = sanitized[:max_length]
|
||||
# Re-trim separators and spaces exposed by the cut so the truncated
|
||||
# name stays filesystem-safe.
|
||||
if replacement:
|
||||
sanitized = sanitized.rstrip(" ." + replacement).lstrip(" " + replacement)
|
||||
else:
|
||||
sanitized = sanitized.rstrip(" .").lstrip(" ")
|
||||
|
||||
if not sanitized:
|
||||
return "unnamed"
|
||||
|
||||
@@ -572,12 +604,20 @@ def calculate_relative_path_for_model(
|
||||
if not first_tag:
|
||||
first_tag = "no tags" # Default if no tags available
|
||||
|
||||
# Tags are user-generated on CivitAI, so sanitize the value before it
|
||||
# becomes a path segment and cap its length (#1119).
|
||||
first_tag = sanitize_folder_name(first_tag, max_length=MAX_PATH_TAG_LENGTH)
|
||||
|
||||
# Format the template with available data
|
||||
model_name = sanitize_folder_name(model_data.get("model_name", ""))
|
||||
model_name = sanitize_folder_name(
|
||||
model_data.get("model_name", ""), max_length=MAX_FOLDER_NAME_LENGTH
|
||||
)
|
||||
version_name = ""
|
||||
|
||||
if isinstance(civitai_data, dict):
|
||||
version_name = sanitize_folder_name(civitai_data.get("name") or "")
|
||||
version_name = sanitize_folder_name(
|
||||
civitai_data.get("name") or "", max_length=MAX_FOLDER_NAME_LENGTH
|
||||
)
|
||||
|
||||
formatted_path = path_template
|
||||
formatted_path = formatted_path.replace("{base_model}", mapped_base_model)
|
||||
@@ -598,6 +638,114 @@ def calculate_relative_path_for_model(
|
||||
return formatted_path
|
||||
|
||||
|
||||
def calculate_filename_for_model(
|
||||
model_data: Dict[str, Any], model_type: str = "lora"
|
||||
) -> str:
|
||||
"""Calculate the filename stem for a model using the filename template.
|
||||
|
||||
Mirrors the data extraction of :func:`calculate_relative_path_for_model`
|
||||
but renders a single filename (no path segments). Missing values resolve
|
||||
to empty segments instead of the path-oriented defaults ("Anonymous" /
|
||||
"no tags") so templates degrade gracefully.
|
||||
|
||||
Args:
|
||||
model_data: Model data from scanner cache
|
||||
model_type: Type of model ('lora', 'checkpoint', 'embedding')
|
||||
|
||||
Returns:
|
||||
Sanitized filename stem without extension, or an empty string when no
|
||||
template is configured, the template is invalid, or the rendered name
|
||||
is empty.
|
||||
"""
|
||||
settings_manager = get_settings_manager()
|
||||
template = settings_manager.get_download_filename_template(model_type)
|
||||
|
||||
if not template:
|
||||
return ""
|
||||
|
||||
# A filename template must render a single name, never folder segments.
|
||||
if "/" in template or "\\" in template:
|
||||
logger.warning(
|
||||
"Filename template for %s contains a path separator and is ignored: %r",
|
||||
model_type,
|
||||
template,
|
||||
)
|
||||
return ""
|
||||
|
||||
civitai_data = model_data.get("civitai", {})
|
||||
|
||||
author = ""
|
||||
if isinstance(civitai_data, dict) and civitai_data.get("id") is not None:
|
||||
creator_info = civitai_data.get("creator") or {}
|
||||
author = creator_info.get("username") or ""
|
||||
|
||||
base_model = model_data.get("base_model", "")
|
||||
base_model_mappings = settings_manager.get("base_model_path_mappings", {})
|
||||
mapped_base_model = base_model_mappings.get(base_model, base_model)
|
||||
|
||||
lowercase_tags = [
|
||||
tag.lower() for tag in model_data.get("tags", []) if isinstance(tag, str)
|
||||
]
|
||||
first_tag = settings_manager.resolve_priority_tag_for_model(
|
||||
lowercase_tags, model_type
|
||||
)
|
||||
|
||||
model_name = model_data.get("model_name", "")
|
||||
version_name = ""
|
||||
if isinstance(civitai_data, dict):
|
||||
version_name = civitai_data.get("name") or ""
|
||||
|
||||
sha256 = model_data.get("sha256") or ""
|
||||
hash_short = sha256[:10].lower() if isinstance(sha256, str) else ""
|
||||
|
||||
file_path = model_data.get("file_path") or ""
|
||||
if isinstance(file_path, str) and file_path:
|
||||
original_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
else:
|
||||
original_name = os.path.splitext(str(model_data.get("file_name", "")))[0]
|
||||
|
||||
def _sanitize_value(value: Any, max_length: Optional[int] = None) -> str:
|
||||
# sanitize_folder_name falls back to "unnamed" for empty input; for
|
||||
# templates an empty value must stay empty so segments collapse.
|
||||
text = str(value) if value else ""
|
||||
if not text:
|
||||
return ""
|
||||
return sanitize_folder_name(text, max_length=max_length)
|
||||
|
||||
replacements = {
|
||||
"{model_name}": _sanitize_value(model_name, MAX_FILENAME_STEM_LENGTH),
|
||||
"{version_name}": _sanitize_value(version_name, MAX_FILENAME_STEM_LENGTH),
|
||||
"{base_model}": _sanitize_value(mapped_base_model, MAX_FILENAME_STEM_LENGTH),
|
||||
"{author}": _sanitize_value(author, MAX_FILENAME_STEM_LENGTH),
|
||||
"{first_tag}": _sanitize_value(first_tag, MAX_PATH_TAG_LENGTH),
|
||||
"{hash_short}": hash_short,
|
||||
"{original_name}": _sanitize_value(original_name, MAX_FILENAME_STEM_LENGTH),
|
||||
}
|
||||
|
||||
result = template
|
||||
for placeholder, value in replacements.items():
|
||||
result = result.replace(placeholder, value)
|
||||
|
||||
if model_type == "embedding":
|
||||
result = result.replace(" ", "_")
|
||||
|
||||
# Strip characters that are illegal in filenames on common filesystems.
|
||||
result = re.sub(r'[:*?"<>|]', "", result)
|
||||
# Collapse runs of identical separators introduced by empty substitutions.
|
||||
result = re.sub(r"([-_. ])\1+", r"\1", result)
|
||||
# Drop separators left dangling next to each other ("- -" -> "-").
|
||||
result = re.sub(r" ?([-_.]) (?=[-_.])", r"\1", result)
|
||||
# A stem must not start or end with separators, spaces or dots.
|
||||
result = result.strip("-_. ")
|
||||
|
||||
# A template can concatenate several values, so cap the rendered stem as
|
||||
# well and re-trim the cut.
|
||||
if len(result) > MAX_FILENAME_STEM_LENGTH:
|
||||
result = result[:MAX_FILENAME_STEM_LENGTH].strip("-_. ")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def remove_empty_dirs(path):
|
||||
"""Recursively remove empty directories starting from the given path.
|
||||
|
||||
|
||||
@@ -0,0 +1,623 @@
|
||||
"""Read intrinsic dimensions from video containers without external tooling.
|
||||
|
||||
PIL cannot open ``.mp4``/``.webm`` files, so example videos imported through
|
||||
the "Add examples" flow used to fall back to a hardcoded ``720x1280`` (portrait)
|
||||
entry, which forced the showcase viewer to letterbox landscape videos.
|
||||
|
||||
This module reads the dimensions out of the container headers themselves:
|
||||
|
||||
* ISO base media files (``.mp4``/``.mov``/``.m4v``) — ``moov/trak/tkhd``,
|
||||
falling back to the sample description of the video track.
|
||||
* WebM/Matroska (``.webm``/``.mkv``) — ``Segment/Tracks/TrackEntry/Video``
|
||||
``PixelWidth``/``PixelHeight``.
|
||||
* Animated WebP (``RIFF``/``WEBP``) — handled because users routinely save
|
||||
animated examples with a video extension.
|
||||
|
||||
The container signature decides which reader runs, so a mislabelled file
|
||||
(a ``.mp4`` that is really WebM) still reports the right dimensions.
|
||||
|
||||
Both readers stream over the file: only container headers are read, so a
|
||||
multi-gigabyte ``mdat`` is never pulled into memory (it is seeked past).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import struct
|
||||
from typing import BinaryIO, Iterator, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ISO_MEDIA_EXTENSIONS = frozenset({".mp4", ".m4v", ".mov"})
|
||||
EBML_MEDIA_EXTENSIONS = frozenset({".webm", ".mkv"})
|
||||
|
||||
_EBML_MAGIC = b"\x1a\x45\xdf\xa3"
|
||||
|
||||
# Cap recursion into nesting containers so a crafted/corrupt file cannot blow
|
||||
# the Python stack.
|
||||
_MAX_BOX_DEPTH = 12
|
||||
_MAX_EBML_DEPTH = 12
|
||||
|
||||
# Header structs (``tkhd``, sample entries) are tiny; guard against a bogus
|
||||
# size claiming the whole file.
|
||||
_MAX_HEADER_PAYLOAD = 1024 * 1024
|
||||
|
||||
_WIDTH_HEIGHT_UNSET = (0, 0)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=4096)
|
||||
def _get_video_dimensions_cached(
|
||||
path: str, _mtime_ns: int, _size: int
|
||||
) -> Optional[Tuple[int, int]]:
|
||||
"""Return ``(width, height)`` for ``path``, or ``None`` on any failure.
|
||||
|
||||
``_mtime_ns`` and ``_size`` participate in the cache key only so a replaced
|
||||
file is re-probed; they are never read by the parser.
|
||||
"""
|
||||
try:
|
||||
return _read_video_dimensions(path)
|
||||
except Exception:
|
||||
logger.debug("Failed to read video dimensions for %s", path, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _read_video_dimensions(path: str) -> Optional[Tuple[int, int]]:
|
||||
"""Dispatch to the ISO or EBML reader based on the container's magic bytes.
|
||||
|
||||
Real libraries contain files whose extension lies about their container
|
||||
(a ``.mp4`` that is really WebM, typically), so the sniffed signature wins
|
||||
and the extension is only a fallback.
|
||||
"""
|
||||
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
file_size = os.path.getsize(path)
|
||||
|
||||
with open(path, "rb") as stream:
|
||||
magic = stream.read(12)
|
||||
|
||||
if _looks_like_iso_media(magic):
|
||||
return _read_iso_media_dimensions(stream, file_size)
|
||||
if magic[:4] == _EBML_MAGIC:
|
||||
return _read_ebml_dimensions(stream, file_size)
|
||||
if magic[:4] == b"RIFF" and magic[8:12] == b"WEBP":
|
||||
return _read_riff_webp_dimensions(stream, file_size)
|
||||
|
||||
# Signature is inconclusive (truncated or unusual file): fall back to
|
||||
# the extension.
|
||||
if ext in EBML_MEDIA_EXTENSIONS:
|
||||
return _read_ebml_dimensions(stream, file_size)
|
||||
if ext in ISO_MEDIA_EXTENSIONS:
|
||||
return _read_iso_media_dimensions(stream, file_size)
|
||||
return None
|
||||
|
||||
|
||||
def _looks_like_iso_media(magic: bytes) -> bool:
|
||||
"""Return True when the leading bytes are an ISO base media box header."""
|
||||
|
||||
return len(magic) >= 8 and magic[4:8] in {
|
||||
b"ftyp",
|
||||
b"moov",
|
||||
b"mdat",
|
||||
b"free",
|
||||
b"skip",
|
||||
b"wide",
|
||||
}
|
||||
|
||||
|
||||
def get_video_dimensions(path: str) -> Optional[Tuple[int, int]]:
|
||||
"""Return the intrinsic ``(width, height)`` of a local video file.
|
||||
|
||||
Returns ``None`` when the extension is unsupported, the file is missing or
|
||||
corrupt, or the dimensions cannot be determined. Never raises.
|
||||
"""
|
||||
if not path:
|
||||
return None
|
||||
try:
|
||||
stat = os.stat(path)
|
||||
except OSError:
|
||||
return None
|
||||
return _get_video_dimensions_cached(path, stat.st_mtime_ns, stat.st_size)
|
||||
|
||||
|
||||
def _clear_video_dimensions_cache() -> None:
|
||||
"""Drop the dimension cache (used by tests)."""
|
||||
|
||||
_get_video_dimensions_cached.cache_clear()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ISO base media (MP4 / MOV)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _iter_boxes(
|
||||
stream: BinaryIO, end: int, depth: int = 0
|
||||
) -> Iterator[Tuple[bytes, int, int]]:
|
||||
"""Yield ``(type, payload_start, box_end)`` for boxes in ``[tell, end)``.
|
||||
|
||||
The stream is left at the next box boundary after each yielded box.
|
||||
"""
|
||||
if depth > _MAX_BOX_DEPTH:
|
||||
return
|
||||
|
||||
while True:
|
||||
start = stream.tell()
|
||||
if start + 8 > end:
|
||||
return
|
||||
|
||||
header = stream.read(8)
|
||||
if len(header) < 8:
|
||||
return
|
||||
|
||||
size, box_type = struct.unpack(">I4s", header)
|
||||
header_size = 8
|
||||
|
||||
if size == 1:
|
||||
# 64-bit ``largesize`` follows the type.
|
||||
extended = stream.read(8)
|
||||
if len(extended) < 8:
|
||||
return
|
||||
size = struct.unpack(">Q", extended)[0]
|
||||
header_size = 16
|
||||
elif size == 0:
|
||||
# Box extends to the end of the enclosing container.
|
||||
size = end - start
|
||||
|
||||
if size < header_size or start + size > end:
|
||||
return
|
||||
|
||||
yield box_type, start + header_size, start + size
|
||||
stream.seek(start + size)
|
||||
|
||||
|
||||
def _read_iso_media_dimensions(
|
||||
stream: BinaryIO, file_size: int
|
||||
) -> Optional[Tuple[int, int]]:
|
||||
"""Walk ``moov`` looking for the video track's dimensions."""
|
||||
|
||||
stream.seek(0)
|
||||
moov: Optional[Tuple[int, int]] = None
|
||||
for box_type, payload_start, box_end in _iter_boxes(stream, file_size):
|
||||
if box_type == b"moov":
|
||||
moov = (payload_start, box_end)
|
||||
break
|
||||
|
||||
if moov is None:
|
||||
return None
|
||||
|
||||
stream.seek(moov[0])
|
||||
for box_type, payload_start, box_end in _iter_boxes(stream, moov[1], depth=1):
|
||||
if box_type != b"trak":
|
||||
continue
|
||||
dimensions = _read_trak_dimensions(stream, payload_start, box_end)
|
||||
if dimensions is not None:
|
||||
return dimensions
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _read_trak_dimensions(
|
||||
stream: BinaryIO, trak_start: int, trak_end: int
|
||||
) -> Optional[Tuple[int, int]]:
|
||||
"""Return the dimensions of a ``trak`` when it describes a video track."""
|
||||
|
||||
stream.seek(trak_start)
|
||||
|
||||
is_video = False
|
||||
tkhd_dimensions = _WIDTH_HEIGHT_UNSET
|
||||
stsd_dimensions = _WIDTH_HEIGHT_UNSET
|
||||
|
||||
for box_type, payload_start, box_end in _iter_boxes(stream, trak_end, depth=2):
|
||||
if box_type == b"tkhd":
|
||||
tkhd_dimensions = _parse_tkhd(stream, payload_start, box_end)
|
||||
elif box_type == b"mdia":
|
||||
stream.seek(payload_start)
|
||||
media = _read_mdia_dimensions(stream, payload_start, box_end)
|
||||
if media is not None:
|
||||
is_video, stsd_dimensions = media
|
||||
|
||||
if not is_video:
|
||||
return None
|
||||
|
||||
# ``tkhd`` is preferred: it is display space, and its 16.16 fixed point
|
||||
# encoding keeps non-integer dimensions (odd crops produce those).
|
||||
for width, height in (tkhd_dimensions, stsd_dimensions):
|
||||
if width > 0 and height > 0:
|
||||
return int(round(width)), int(round(height))
|
||||
return None
|
||||
|
||||
|
||||
def _read_mdia_dimensions(
|
||||
stream: BinaryIO, mdia_start: int, mdia_end: int
|
||||
) -> Optional[Tuple[bool, Tuple[float, float]]]:
|
||||
"""Return ``(is_video, dimensions)`` for a ``mdia`` box."""
|
||||
|
||||
handler_type = b""
|
||||
stsd_dimensions = _WIDTH_HEIGHT_UNSET
|
||||
|
||||
for box_type, payload_start, box_end in _iter_boxes(stream, mdia_end, depth=3):
|
||||
if box_type == b"hdlr":
|
||||
handler_type = _parse_handler_type(stream, payload_start, box_end)
|
||||
elif box_type == b"minf":
|
||||
stream.seek(payload_start)
|
||||
stsd_dimensions = _read_minf_dimensions(stream, payload_start, box_end)
|
||||
|
||||
return handler_type == b"vide", stsd_dimensions
|
||||
|
||||
|
||||
def _read_minf_dimensions(
|
||||
stream: BinaryIO, minf_start: int, minf_end: int
|
||||
) -> Tuple[float, float]:
|
||||
"""Return the sample-entry dimensions declared under ``minf/stbl/stsd``."""
|
||||
|
||||
for box_type, payload_start, box_end in _iter_boxes(stream, minf_end, depth=4):
|
||||
if box_type != b"stbl":
|
||||
continue
|
||||
stream.seek(payload_start)
|
||||
for inner_type, inner_start, inner_end in _iter_boxes(
|
||||
stream, box_end, depth=5
|
||||
):
|
||||
if inner_type == b"stsd":
|
||||
return _parse_stsd(stream, inner_start, inner_end)
|
||||
return _WIDTH_HEIGHT_UNSET
|
||||
|
||||
|
||||
def _parse_tkhd(
|
||||
stream: BinaryIO, payload_start: int, box_end: int
|
||||
) -> Tuple[float, float]:
|
||||
"""Parse the 16.16 fixed point width/height trailer of a ``tkhd`` box."""
|
||||
|
||||
size = box_end - payload_start
|
||||
if size < 8 or size > _MAX_HEADER_PAYLOAD:
|
||||
return _WIDTH_HEIGHT_UNSET
|
||||
|
||||
stream.seek(box_end - 8)
|
||||
trailer = stream.read(8)
|
||||
if len(trailer) < 8:
|
||||
return _WIDTH_HEIGHT_UNSET
|
||||
|
||||
width, height = struct.unpack(">II", trailer)
|
||||
return width / 65536.0, height / 65536.0
|
||||
|
||||
|
||||
def _parse_handler_type(
|
||||
stream: BinaryIO, payload_start: int, box_end: int
|
||||
) -> bytes:
|
||||
"""Parse the handler type from an ``hdlr`` box.
|
||||
|
||||
Layout: version/flags (4) + pre_defined (4) + handler_type (4).
|
||||
"""
|
||||
|
||||
if box_end - payload_start < 12:
|
||||
return b""
|
||||
stream.seek(payload_start)
|
||||
data = stream.read(12)
|
||||
if len(data) < 12:
|
||||
return b""
|
||||
return data[8:12]
|
||||
|
||||
|
||||
def _parse_stsd(
|
||||
stream: BinaryIO, payload_start: int, box_end: int
|
||||
) -> Tuple[float, float]:
|
||||
"""Parse the visual sample entry dimensions from an ``stsd`` box.
|
||||
|
||||
Only the first entry is inspected: video tracks are single-entry in every
|
||||
container we import from.
|
||||
"""
|
||||
|
||||
if box_end - payload_start < 16:
|
||||
return _WIDTH_HEIGHT_UNSET
|
||||
|
||||
stream.seek(payload_start)
|
||||
header = stream.read(8) # version/flags + entry_count
|
||||
if len(header) < 8:
|
||||
return _WIDTH_HEIGHT_UNSET
|
||||
|
||||
entry_start = payload_start + 8
|
||||
if entry_start + 8 > box_end:
|
||||
return _WIDTH_HEIGHT_UNSET
|
||||
|
||||
stream.seek(entry_start)
|
||||
entry_header = stream.read(8)
|
||||
if len(entry_header) < 8:
|
||||
return _WIDTH_HEIGHT_UNSET
|
||||
|
||||
entry_size = struct.unpack(">I", entry_header[:4])[0]
|
||||
header_size = 8
|
||||
|
||||
if entry_size == 1:
|
||||
extended = stream.read(8)
|
||||
if len(extended) < 8:
|
||||
return _WIDTH_HEIGHT_UNSET
|
||||
entry_size = struct.unpack(">Q", extended)[0]
|
||||
header_size = 16
|
||||
elif entry_size == 0:
|
||||
entry_size = box_end - entry_start
|
||||
|
||||
if entry_size < header_size + 8 or entry_start + entry_size > box_end:
|
||||
return _WIDTH_HEIGHT_UNSET
|
||||
|
||||
# Visual sample entries: 6 bytes reserved + 2 bytes data_reference_index,
|
||||
# then width (2) and height (2).
|
||||
stream.seek(entry_start + header_size + 6 + 2)
|
||||
dimensions = stream.read(4)
|
||||
if len(dimensions) < 4:
|
||||
return _WIDTH_HEIGHT_UNSET
|
||||
|
||||
width, height = struct.unpack(">HH", dimensions)
|
||||
return float(width), float(height)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# WebM / Matroska (EBML)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
# EBML element IDs (stored with their length marker, as they appear on disk).
|
||||
_ID_SEGMENT = 0x18538067
|
||||
_ID_TRACKS = 0x1654AE6B
|
||||
_ID_TRACK_ENTRY = 0xAE
|
||||
_ID_TRACK_TYPE = 0x83
|
||||
_ID_VIDEO = 0xE0
|
||||
_ID_PIXEL_WIDTH = 0xB0
|
||||
_ID_PIXEL_HEIGHT = 0xBA
|
||||
|
||||
# Nested containers we descend into while hunting for video dimensions.
|
||||
_EBML_CONTAINER_IDS = frozenset({_ID_SEGMENT, _ID_TRACKS, _ID_TRACK_ENTRY})
|
||||
|
||||
|
||||
def _read_ebml_vint(stream: BinaryIO, *, keep_marker: bool) -> Optional[Tuple[int, int]]:
|
||||
"""Read an EBML variable-length integer.
|
||||
|
||||
Returns ``(value, byte_length)``. For element IDs the marker bit is kept
|
||||
(``keep_marker=True``) because IDs are compared in their on-disk form; for
|
||||
sizes the marker is stripped to yield the actual payload length.
|
||||
"""
|
||||
|
||||
first = stream.read(1)
|
||||
if not first:
|
||||
return None
|
||||
|
||||
first_byte = first[0]
|
||||
if first_byte == 0:
|
||||
return None
|
||||
|
||||
length = 1
|
||||
mask = 0x80
|
||||
while not first_byte & mask:
|
||||
mask >>= 1
|
||||
length += 1
|
||||
if length > 8:
|
||||
return None
|
||||
|
||||
value = first_byte if keep_marker else first_byte & (mask - 1)
|
||||
remaining = length - 1
|
||||
|
||||
if remaining:
|
||||
extra = stream.read(remaining)
|
||||
if len(extra) < remaining:
|
||||
return None
|
||||
for byte in extra:
|
||||
value = (value << 8) | byte
|
||||
|
||||
return value, length
|
||||
|
||||
|
||||
def _read_ebml_dimensions(
|
||||
stream: BinaryIO, file_size: int
|
||||
) -> Optional[Tuple[int, int]]:
|
||||
"""Parse ``Segment/Tracks`` for the first video ``TrackEntry``."""
|
||||
|
||||
stream.seek(0)
|
||||
header = stream.read(4)
|
||||
if header != _EBML_MAGIC:
|
||||
return None
|
||||
|
||||
return _walk_ebml(stream, 0, file_size, depth=0)
|
||||
|
||||
|
||||
def _walk_ebml(
|
||||
stream: BinaryIO, start: int, end: int, *, depth: int
|
||||
) -> Optional[Tuple[int, int]]:
|
||||
"""Recursively scan EBML elements in ``[start, end)`` for video dimensions."""
|
||||
|
||||
if depth > _MAX_EBML_DEPTH:
|
||||
return None
|
||||
|
||||
stream.seek(start)
|
||||
|
||||
while stream.tell() < end:
|
||||
element_start = stream.tell()
|
||||
|
||||
element_id = _read_ebml_vint(stream, keep_marker=True)
|
||||
if element_id is None:
|
||||
return None
|
||||
element_id_value = element_id[0]
|
||||
|
||||
size_field = _read_ebml_vint(stream, keep_marker=False)
|
||||
if size_field is None:
|
||||
return None
|
||||
payload_size, size_length = size_field
|
||||
|
||||
payload_start = element_start + element_id[1] + size_length
|
||||
|
||||
# A size field of all-ones marks an unknown-size element, which is
|
||||
# legal for Segment/Tracks; treat it as "until the parent ends".
|
||||
unknown_size = payload_size == (1 << (7 * size_length)) - 1
|
||||
payload_end = end if unknown_size else payload_start + payload_size
|
||||
|
||||
if payload_end > end:
|
||||
return None
|
||||
|
||||
if element_id_value == _ID_VIDEO:
|
||||
dimensions = _read_ebml_video(stream, payload_start, min(payload_end, end))
|
||||
if dimensions is not None:
|
||||
return dimensions
|
||||
elif element_id_value == _ID_TRACK_ENTRY:
|
||||
track = _read_ebml_track_entry(
|
||||
stream, payload_start, min(payload_end, end)
|
||||
)
|
||||
if track is not None:
|
||||
return track
|
||||
elif element_id_value in _EBML_CONTAINER_IDS:
|
||||
found = _walk_ebml(
|
||||
stream, payload_start, min(payload_end, end), depth=depth + 1
|
||||
)
|
||||
if found is not None:
|
||||
return found
|
||||
|
||||
if unknown_size:
|
||||
# Cannot resume after an unknown-size element; its siblings cannot
|
||||
# be located reliably, so stop scanning this level.
|
||||
return None
|
||||
|
||||
stream.seek(payload_end)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _read_ebml_track_entry(
|
||||
stream: BinaryIO, start: int, end: int
|
||||
) -> Optional[Tuple[int, int]]:
|
||||
"""Return dimensions when a ``TrackEntry`` is a video track."""
|
||||
|
||||
track_type: Optional[int] = None
|
||||
dimensions: Optional[Tuple[int, int]] = None
|
||||
|
||||
stream.seek(start)
|
||||
while stream.tell() < end:
|
||||
element_start = stream.tell()
|
||||
|
||||
element_id = _read_ebml_vint(stream, keep_marker=True)
|
||||
if element_id is None:
|
||||
return None
|
||||
|
||||
size_field = _read_ebml_vint(stream, keep_marker=False)
|
||||
if size_field is None:
|
||||
return None
|
||||
payload_size, size_length = size_field
|
||||
|
||||
payload_start = element_start + element_id[1] + size_length
|
||||
payload_end = min(payload_start + payload_size, end)
|
||||
|
||||
if element_id[0] == _ID_TRACK_TYPE:
|
||||
track_type = _read_ebml_uint(stream, payload_start, payload_end)
|
||||
elif element_id[0] == _ID_VIDEO:
|
||||
dimensions = _read_ebml_video(stream, payload_start, payload_end)
|
||||
|
||||
stream.seek(payload_end)
|
||||
|
||||
# Track type 1 is video.
|
||||
if track_type == 1 and dimensions is not None:
|
||||
return dimensions
|
||||
return None
|
||||
|
||||
|
||||
def _read_ebml_video(
|
||||
stream: BinaryIO, start: int, end: int
|
||||
) -> Optional[Tuple[int, int]]:
|
||||
"""Return ``PixelWidth``/``PixelHeight`` from a ``Video`` element."""
|
||||
|
||||
width: Optional[int] = None
|
||||
height: Optional[int] = None
|
||||
|
||||
stream.seek(start)
|
||||
while stream.tell() < end:
|
||||
element_start = stream.tell()
|
||||
|
||||
element_id = _read_ebml_vint(stream, keep_marker=True)
|
||||
if element_id is None:
|
||||
return None
|
||||
|
||||
size_field = _read_ebml_vint(stream, keep_marker=False)
|
||||
if size_field is None:
|
||||
return None
|
||||
payload_size, size_length = size_field
|
||||
|
||||
payload_start = element_start + element_id[1] + size_length
|
||||
payload_end = min(payload_start + payload_size, end)
|
||||
|
||||
if element_id[0] == _ID_PIXEL_WIDTH:
|
||||
width = _read_ebml_uint(stream, payload_start, payload_end)
|
||||
elif element_id[0] == _ID_PIXEL_HEIGHT:
|
||||
height = _read_ebml_uint(stream, payload_start, payload_end)
|
||||
|
||||
stream.seek(payload_end)
|
||||
|
||||
if width and height and width > 0 and height > 0:
|
||||
return width, height
|
||||
return None
|
||||
|
||||
|
||||
def _read_ebml_uint(stream: BinaryIO, start: int, end: int) -> Optional[int]:
|
||||
"""Read an unsigned big-endian integer element payload."""
|
||||
|
||||
length = end - start
|
||||
if length <= 0 or length > 8:
|
||||
return None
|
||||
|
||||
stream.seek(start)
|
||||
raw = stream.read(length)
|
||||
if len(raw) < length:
|
||||
return None
|
||||
|
||||
value = 0
|
||||
for byte in raw:
|
||||
value = (value << 8) | byte
|
||||
return value
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# RIFF / WebP (animated examples are often renamed to ``.mp4``)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _read_riff_webp_dimensions(
|
||||
stream: BinaryIO, file_size: int
|
||||
) -> Optional[Tuple[int, int]]:
|
||||
"""Return dimensions from a WebP file's first dimension-bearing chunk."""
|
||||
|
||||
stream.seek(12)
|
||||
|
||||
while stream.tell() + 8 <= file_size:
|
||||
header = stream.read(8)
|
||||
if len(header) < 8:
|
||||
return None
|
||||
|
||||
fourcc, chunk_size = struct.unpack("<4sI", header)
|
||||
payload_start = stream.tell()
|
||||
|
||||
if fourcc == b"VP8X":
|
||||
payload = stream.read(10)
|
||||
if len(payload) < 10:
|
||||
return None
|
||||
# Canvas size is stored minus one, as 24-bit little endian values.
|
||||
width = int.from_bytes(payload[4:7], "little") + 1
|
||||
height = int.from_bytes(payload[7:10], "little") + 1
|
||||
return width, height
|
||||
|
||||
if fourcc == b"VP8 ":
|
||||
# Frame tag (3 bytes, bit 0 = key frame) then the key frame start
|
||||
# code 0x9d 0x01 0x2a and the 16-bit dimensions.
|
||||
payload = stream.read(10)
|
||||
if len(payload) < 10:
|
||||
return None
|
||||
start = payload.find(b"\x9d\x01\x2a")
|
||||
if start < 0 or start + 7 > len(payload):
|
||||
return None
|
||||
width, height = struct.unpack("<HH", payload[start + 3 : start + 7])
|
||||
return width & 0x3FFF, height & 0x3FFF
|
||||
|
||||
if fourcc == b"VP8L":
|
||||
payload = stream.read(5)
|
||||
if len(payload) < 5 or payload[0] != 0x2F:
|
||||
return None
|
||||
bits = int.from_bytes(payload[1:5], "little")
|
||||
return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
|
||||
|
||||
# Skip this chunk (payloads are padded to an even byte boundary).
|
||||
stream.seek(payload_start + chunk_size + (chunk_size & 1))
|
||||
|
||||
return None
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-lora-manager"
|
||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||
version = "1.2.3"
|
||||
version = "1.2.4"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
|
||||
@@ -225,10 +225,9 @@ def main() -> int:
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Get project root (parent of .agents directory)
|
||||
# Get project root: this script lives in <project_root>/scripts/e2e/.
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
skill_dir = os.path.dirname(script_dir)
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.dirname(skill_dir)))
|
||||
project_root = os.path.dirname(os.path.dirname(script_dir))
|
||||
|
||||
managed_pids = read_managed_pids(args.port)
|
||||
|
||||
|
||||
@@ -118,6 +118,44 @@
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Banner Pager (cycles through multiple active banners) */
|
||||
.banner-pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
margin-left: var(--space-2);
|
||||
}
|
||||
|
||||
.banner-pager-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: var(--transition-base);
|
||||
font-size: 0.75em;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.banner-pager-btn:hover {
|
||||
background: oklch(var(--lora-accent) / 0.1);
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.banner-pager-indicator {
|
||||
font-size: 0.8em;
|
||||
color: var(--text-muted);
|
||||
min-width: 2.8em;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Dismiss Button */
|
||||
.banner-dismiss {
|
||||
position: absolute;
|
||||
@@ -183,6 +221,10 @@
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.banner-pager {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.banner-action {
|
||||
flex: 1;
|
||||
|
||||
@@ -27,6 +27,12 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Self-managed by SettingsManager: stacks above the settings modal like the
|
||||
directory picker (settings panels sit at 10000/10002). */
|
||||
#filenameTemplateConfirmModal {
|
||||
z-index: 10010;
|
||||
}
|
||||
|
||||
.delete-modal-content {
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/* Directory Picker Modal */
|
||||
/* Stacks above the settings modal: settings tooltips/combobox panels sit at
|
||||
10000/10002, so 10010 keeps the picker on top of everything settings-side. */
|
||||
#directoryPickerModal {
|
||||
z-index: 10010;
|
||||
}
|
||||
|
||||
.directory-picker-content {
|
||||
max-width: 560px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.directory-picker-content h3 {
|
||||
color: var(--text-color);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
/* Manual path row */
|
||||
#directoryPickerModal .directory-picker-path-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
#directoryPickerModal .directory-picker-path-row input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
font-family: inherit;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
#directoryPickerModal .directory-picker-path-row input:focus {
|
||||
outline: none;
|
||||
border-color: var(--lora-accent);
|
||||
box-shadow: 0 0 0 2px oklch(from var(--lora-accent) l c h / 0.2);
|
||||
}
|
||||
|
||||
/* Directory browser (class names shared with the batch import browser) */
|
||||
#directoryPickerModal .directory-browser {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: var(--lora-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#directoryPickerModal .browser-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
#directoryPickerModal .back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: var(--card-bg);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
#directoryPickerModal .back-btn:hover {
|
||||
border-color: var(--lora-accent);
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
#directoryPickerModal .back-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
#directoryPickerModal .current-path {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 0.9em;
|
||||
color: var(--text-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
#directoryPickerModal .browser-content {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
#directoryPickerModal .folder-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
#directoryPickerModal .folder-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-base);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
#directoryPickerModal .folder-item:hover {
|
||||
background: var(--lora-surface-hover, oklch(from var(--lora-accent) l c h / 0.1));
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
#directoryPickerModal .folder-item i {
|
||||
color: #fbbf24;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
#directoryPickerModal .item-name {
|
||||
flex: 1;
|
||||
font-size: 0.9em;
|
||||
color: var(--text-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
#directoryPickerModal .browser-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-color);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
#directoryPickerModal .directory-picker-error {
|
||||
margin-top: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: oklch(from var(--lora-error) l c h / 0.12);
|
||||
color: var(--lora-error);
|
||||
font-size: 0.85em;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
#directoryPickerModal .directory-picker-empty {
|
||||
padding: var(--space-2);
|
||||
text-align: center;
|
||||
color: var(--text-color);
|
||||
opacity: 0.6;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Dark theme adjustments */
|
||||
[data-theme="dark"] #directoryPickerModal .directory-browser {
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
[data-theme="dark"] #directoryPickerModal .browser-header,
|
||||
[data-theme="dark"] #directoryPickerModal .browser-footer {
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
|
||||
[data-theme="dark"] #directoryPickerModal .folder-item i {
|
||||
color: #fcd34d;
|
||||
}
|
||||
@@ -1692,6 +1692,87 @@ input:checked + .toggle-slider:before {
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Browse (directory picker) button — boxed accent style used on the dynamic
|
||||
extra-folder-path / model-path rows, mirroring .remove-path-btn. Static
|
||||
path fields use the .inset variant below instead. */
|
||||
#settingsModal .browse-path-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border-radius: var(--border-radius-xs);
|
||||
border: 1px solid var(--lora-accent);
|
||||
background: transparent;
|
||||
color: var(--lora-accent);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: var(--transition-base);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#settingsModal .browse-path-btn:hover {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Inset variant (static path fields): the button floats inside the right
|
||||
edge of the input, so the setting row keeps its single-control look and
|
||||
narrow columns never push it onto a second line. */
|
||||
#settingsModal .browse-path-btn.inset {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-color);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
#settingsModal .browse-path-btn.inset:hover {
|
||||
background: transparent;
|
||||
color: var(--lora-accent);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#settingsModal input.has-inset-browse {
|
||||
padding-right: 34px;
|
||||
}
|
||||
|
||||
/* Advisory path validation feedback (wraps below the input row) */
|
||||
#settingsModal .text-input-wrapper,
|
||||
#settingsModal .path-control {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#settingsModal .path-control > .text-input-wrapper {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.path-validation {
|
||||
display: none;
|
||||
flex-basis: 100%;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
font-size: 0.8em;
|
||||
line-height: 1.4;
|
||||
color: var(--lora-error);
|
||||
}
|
||||
|
||||
.path-validation.visible {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.path-validation.valid {
|
||||
color: var(--lora-success);
|
||||
}
|
||||
|
||||
/* Highlight animation for setting items targeted from Doctor actions */
|
||||
@keyframes settings-highlight-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(from var(--lora-accent) r g b / 0.4); }
|
||||
@@ -1780,3 +1861,38 @@ input:checked + .toggle-slider:before {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Standalone Model Paths: pending-restart cues */
|
||||
.settings-nav-item.has-pending-restart {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.settings-nav-item.has-pending-restart::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--lora-warning, #e67e22);
|
||||
}
|
||||
|
||||
.model-paths-restart-notice {
|
||||
display: none;
|
||||
margin-top: 8px;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
border: 1px solid var(--lora-warning, #e67e22);
|
||||
background: rgba(230, 126, 34, 0.08);
|
||||
color: var(--lora-warning, #e67e22);
|
||||
font-size: 0.85em;
|
||||
line-height: 1.4;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.model-paths-restart-notice.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
@import 'components/modal/example-access-modal.css';
|
||||
@import 'components/modal/support-modal.css';
|
||||
@import 'components/modal/download-modal.css';
|
||||
@import 'components/modal/directory-picker-modal.css';
|
||||
@import 'components/toast.css';
|
||||
@import 'components/loading.css';
|
||||
@import 'components/menu.css';
|
||||
|
||||
@@ -122,6 +122,9 @@ export function getApiEndpoints(modelType) {
|
||||
autoOrganize: `/api/lm/${modelType}/auto-organize`,
|
||||
autoOrganizeProgress: `/api/lm/${modelType}/auto-organize-progress`,
|
||||
|
||||
// Filename template operations
|
||||
applyFilenameTemplate: `/api/lm/${modelType}/apply-filename-template`,
|
||||
|
||||
// Model-specific endpoints (will be merged with specific configs)
|
||||
specific: {}
|
||||
};
|
||||
|
||||
@@ -2175,6 +2175,135 @@ export class BaseModelApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the configured download filename template to models, renaming their files
|
||||
* @param {Array} filePaths - Optional array of file paths to rename. If not provided, applies to all models.
|
||||
* @returns {Promise} - Promise that resolves when the operation is complete
|
||||
*/
|
||||
async applyFilenameTemplate(filePaths = null) {
|
||||
let ws = null;
|
||||
|
||||
await state.loadingManager.showWithProgress(async (loading) => {
|
||||
loading.showCancelButton(() => this.cancelTask());
|
||||
try {
|
||||
// Connect to WebSocket for progress updates
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
ws = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`);
|
||||
|
||||
const operationComplete = new Promise((resolve, reject) => {
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type !== 'filename_template_progress') return;
|
||||
|
||||
switch (data.status) {
|
||||
case 'started':
|
||||
loading.setProgress(0);
|
||||
const operationType = data.operation_type === 'bulk' ? 'selected models' : 'all models';
|
||||
loading.setStatus(translate('loras.bulkOperations.filenameTemplateProgress.starting', { type: operationType }, `Applying filename template to ${operationType}...`));
|
||||
break;
|
||||
|
||||
case 'processing':
|
||||
const percent = data.total > 0 ? ((data.processed / data.total) * 90).toFixed(1) : 0;
|
||||
loading.setProgress(percent);
|
||||
loading.setStatus(
|
||||
translate('loras.bulkOperations.filenameTemplateProgress.processing', {
|
||||
processed: data.processed,
|
||||
total: data.total,
|
||||
success: data.success,
|
||||
failures: data.failures,
|
||||
skipped: data.skipped
|
||||
}, `Processing (${data.processed}/${data.total}) - ${data.success} renamed, ${data.skipped} skipped, ${data.failures} failed`)
|
||||
);
|
||||
break;
|
||||
|
||||
case 'completed':
|
||||
loading.setProgress(100);
|
||||
loading.setStatus(
|
||||
translate('loras.bulkOperations.filenameTemplateProgress.completed', {
|
||||
success: data.success,
|
||||
skipped: data.skipped,
|
||||
failures: data.failures,
|
||||
total: data.total
|
||||
}, `Completed: ${data.success} renamed, ${data.skipped} skipped, ${data.failures} failed`)
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
resolve(data);
|
||||
}, 1500);
|
||||
break;
|
||||
|
||||
case 'cancelled':
|
||||
loading.setStatus(translate('toast.api.operationCancelled', {}, 'Operation cancelled by user'));
|
||||
resolve(data);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
loading.setStatus(translate('loras.bulkOperations.filenameTemplateProgress.error', { error: data.error }, `Error: ${data.error}`));
|
||||
reject(new Error(data.error));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error during filename template apply:', error);
|
||||
reject(new Error('Connection error'));
|
||||
};
|
||||
});
|
||||
|
||||
// Start the filename template operation
|
||||
const endpoint = this.apiConfig.endpoints.applyFilenameTemplate;
|
||||
|
||||
const requestBody = {};
|
||||
if (filePaths) {
|
||||
requestBody.file_paths = filePaths;
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || 'Failed to start filename template operation');
|
||||
}
|
||||
|
||||
// Wait for the operation to complete via WebSocket
|
||||
const result = await operationComplete;
|
||||
|
||||
// Show appropriate success message based on results
|
||||
if (result.status === 'cancelled') {
|
||||
showToast('toast.api.operationCancelledPartial', { success: result.success, total: result.total }, 'info');
|
||||
} else if (result.failures === 0) {
|
||||
showToast('toast.loras.filenameTemplateSuccess', {
|
||||
count: result.success,
|
||||
type: result.operation_type === 'bulk' ? 'selected models' : 'all models'
|
||||
}, 'success');
|
||||
} else {
|
||||
showToast('toast.loras.filenameTemplatePartialSuccess', {
|
||||
success: result.success,
|
||||
failures: result.failures,
|
||||
total: result.total
|
||||
}, 'warning');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error applying filename template:', error);
|
||||
showToast('toast.loras.filenameTemplateFailed', { error: error.message }, 'error');
|
||||
throw error;
|
||||
} finally {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.close();
|
||||
}
|
||||
}
|
||||
}, {
|
||||
initialMessage: translate('loras.bulkOperations.filenameTemplateProgress.initializing', {}, 'Initializing filename template apply...'),
|
||||
completionMessage: translate('loras.bulkOperations.filenameTemplateProgress.complete', {}, 'Filename template apply complete')
|
||||
});
|
||||
}
|
||||
|
||||
async stopExampleImages() {
|
||||
try {
|
||||
const response = await fetch('/api/lm/stop-example-images', {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { moveManager } from '../../managers/MoveManager.js';
|
||||
import { rematchModalManager } from '../../managers/RematchModalManager.js';
|
||||
import { showRematchSummary } from '../RematchSummaryModal.js';
|
||||
import { probeExtension, delegateReimport, getCivitaiImageInfo } from '../../utils/extensionReimportBridge.js';
|
||||
import { withBasePath } from '../../utils/basePath.js';
|
||||
|
||||
export class RecipeContextMenu extends BaseContextMenu {
|
||||
constructor() {
|
||||
@@ -180,7 +181,7 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
setSessionItem('filterRecipeName', recipe.title);
|
||||
|
||||
// Navigate to the LoRAs page
|
||||
window.location.href = '/loras';
|
||||
window.location.href = withBasePath('/loras');
|
||||
} else {
|
||||
showToast('recipes.contextMenu.viewLoras.noLorasFound', {}, 'info');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
|
||||
/**
|
||||
* Reusable directory picker modal backed by POST /api/lm/browse-directory.
|
||||
* Self-managed (NOT registered with ModalManager): it stacks above the
|
||||
* settings modal, so ModalManager's "close current modal on open" behavior
|
||||
* would kill the modal underneath.
|
||||
*/
|
||||
class DirectoryPickerModal {
|
||||
constructor() {
|
||||
this.isOpen = false;
|
||||
this.currentPath = '';
|
||||
this.parentPath = null;
|
||||
this.onSelect = null;
|
||||
this.elements = {};
|
||||
this._bindings = [];
|
||||
}
|
||||
|
||||
open({ initialPath = '', onSelect } = {}) {
|
||||
this._cacheElements();
|
||||
if (!this.elements.modal) {
|
||||
console.warn('DirectoryPickerModal: #directoryPickerModal not found in DOM');
|
||||
return;
|
||||
}
|
||||
|
||||
this._unbindEvents();
|
||||
this.onSelect = typeof onSelect === 'function' ? onSelect : null;
|
||||
this.currentPath = '';
|
||||
this.parentPath = null;
|
||||
this._clearError();
|
||||
this.elements.folderList.innerHTML = '';
|
||||
this.elements.currentPathEl.textContent = '';
|
||||
this.elements.upBtn.disabled = true;
|
||||
this.elements.pathInput.value = initialPath || '';
|
||||
|
||||
this._bindEvents();
|
||||
document.body.classList.add('modal-open');
|
||||
this.elements.modal.style.display = 'block';
|
||||
this.isOpen = true;
|
||||
|
||||
// An empty path lets the server pick its default (user home).
|
||||
this.loadDirectory(initialPath || '');
|
||||
}
|
||||
|
||||
close() {
|
||||
if (!this.isOpen) return;
|
||||
this.isOpen = false;
|
||||
this._unbindEvents();
|
||||
if (this.elements.modal) {
|
||||
this.elements.modal.style.display = 'none';
|
||||
}
|
||||
this.onSelect = null;
|
||||
// Keep body.modal-open: the settings modal underneath may still be open.
|
||||
}
|
||||
|
||||
async loadDirectory(path) {
|
||||
try {
|
||||
const response = await fetch('/api/lm/browse-directory', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path })
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this._clearError();
|
||||
this._renderDirectory(data);
|
||||
} else {
|
||||
this._showError(data.error || translate('settings.directoryPicker.loadError', {}, 'Failed to load directory'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading directory:', error);
|
||||
this._showError(translate('settings.directoryPicker.loadError', {}, 'Failed to load directory'));
|
||||
}
|
||||
}
|
||||
|
||||
_cacheElements() {
|
||||
const modal = document.getElementById('directoryPickerModal');
|
||||
this.elements = {
|
||||
modal,
|
||||
closeBtn: document.getElementById('directoryPickerCloseBtn'),
|
||||
pathInput: document.getElementById('directoryPickerPathInput'),
|
||||
goBtn: document.getElementById('directoryPickerGoBtn'),
|
||||
upBtn: document.getElementById('directoryPickerUpBtn'),
|
||||
currentPathEl: document.getElementById('directoryPickerCurrentPath'),
|
||||
folderList: document.getElementById('directoryPickerFolderList'),
|
||||
errorEl: document.getElementById('directoryPickerError'),
|
||||
selectBtn: document.getElementById('directoryPickerSelectBtn')
|
||||
};
|
||||
}
|
||||
|
||||
_bind(target, type, handler, options) {
|
||||
target.addEventListener(type, handler, options);
|
||||
this._bindings.push([target, type, handler, options]);
|
||||
}
|
||||
|
||||
_bindEvents() {
|
||||
const { modal, closeBtn, pathInput, goBtn, upBtn, selectBtn } = this.elements;
|
||||
|
||||
this._bind(closeBtn, 'click', () => this.close());
|
||||
this._bind(goBtn, 'click', () => this.loadDirectory(pathInput.value.trim()));
|
||||
this._bind(pathInput, 'keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
this.loadDirectory(pathInput.value.trim());
|
||||
}
|
||||
});
|
||||
this._bind(upBtn, 'click', () => {
|
||||
// Server-provided parent_path: Windows paths cannot be derived client-side.
|
||||
if (this.parentPath) {
|
||||
this.loadDirectory(this.parentPath);
|
||||
}
|
||||
});
|
||||
this._bind(selectBtn, 'click', () => this._selectCurrent());
|
||||
|
||||
// Capture phase + stopPropagation so an ESC here never reaches the
|
||||
// settings modal's own ESC handler underneath.
|
||||
this._bind(document, 'keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation();
|
||||
this.close();
|
||||
}
|
||||
}, true);
|
||||
|
||||
// Backdrop click (the .modal element itself, not its content).
|
||||
this._bind(modal, 'click', (event) => {
|
||||
if (event.target === modal) {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_unbindEvents() {
|
||||
for (const [target, type, handler, options] of this._bindings) {
|
||||
target.removeEventListener(type, handler, options);
|
||||
}
|
||||
this._bindings = [];
|
||||
}
|
||||
|
||||
_renderDirectory(data) {
|
||||
this.currentPath = data.current_path || '';
|
||||
this.parentPath = data.parent_path || null;
|
||||
|
||||
this.elements.currentPathEl.textContent = this.currentPath;
|
||||
this.elements.pathInput.value = this.currentPath;
|
||||
this.elements.upBtn.disabled = !this.parentPath;
|
||||
|
||||
const folderList = this.elements.folderList;
|
||||
folderList.innerHTML = '';
|
||||
|
||||
const directories = data.directories || [];
|
||||
if (directories.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'directory-picker-empty';
|
||||
empty.textContent = translate('settings.directoryPicker.emptyFolder', {}, 'This folder is empty');
|
||||
folderList.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
directories.forEach((entry) => {
|
||||
folderList.appendChild(this._createFolderItem(entry));
|
||||
});
|
||||
}
|
||||
|
||||
// Each entry is { name, path, is_parent }; the server supplies the full
|
||||
// child path, so navigation never joins path segments client-side.
|
||||
_createFolderItem(entry) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'folder-item';
|
||||
item.innerHTML = `
|
||||
<i class="fas fa-folder"></i>
|
||||
<span class="item-name">${this._escapeHtml(entry.name)}</span>
|
||||
`;
|
||||
item.addEventListener('click', () => {
|
||||
this.loadDirectory(entry.path);
|
||||
});
|
||||
return item;
|
||||
}
|
||||
|
||||
_selectCurrent() {
|
||||
if (!this.currentPath) return;
|
||||
if (this.onSelect) {
|
||||
this.onSelect(this.currentPath);
|
||||
}
|
||||
this.close();
|
||||
}
|
||||
|
||||
_showError(message) {
|
||||
this.elements.errorEl.textContent = message;
|
||||
this.elements.errorEl.style.display = 'block';
|
||||
}
|
||||
|
||||
_clearError() {
|
||||
this.elements.errorEl.textContent = '';
|
||||
this.elements.errorEl.style.display = 'none';
|
||||
}
|
||||
|
||||
_escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
export const directoryPickerModal = new DirectoryPickerModal();
|
||||
export { DirectoryPickerModal };
|
||||
@@ -7,6 +7,7 @@ import { state } from '../state/index.js';
|
||||
import { setSessionItem, removeSessionItem, getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||
import { fetchRecipeDetails, updateRecipeMetadata, sendRecipeWorkflow, extractRecipeId } from '../api/recipeApi.js';
|
||||
import { downloadManager } from '../managers/DownloadManager.js';
|
||||
import { withBasePath } from '../utils/basePath.js';
|
||||
import { MODEL_TYPES } from '../api/apiConfig.js';
|
||||
import { openMediaViewer } from './shared/MediaViewer.js';
|
||||
import { showRecipeDeleteConfirmation } from './RecipeCard.js';
|
||||
@@ -3231,7 +3232,7 @@ class RecipeModal {
|
||||
setSessionItem('filterCheckpointRecipeName', this.currentRecipe.title);
|
||||
}
|
||||
|
||||
window.location.href = '/checkpoints';
|
||||
window.location.href = withBasePath('/checkpoints');
|
||||
}
|
||||
|
||||
_getCheckpointHash(checkpoint) {
|
||||
@@ -3281,7 +3282,7 @@ class RecipeModal {
|
||||
}
|
||||
|
||||
// Navigate to the LoRAs page
|
||||
window.location.href = '/loras';
|
||||
window.location.href = withBasePath('/loras');
|
||||
}
|
||||
|
||||
// Only in-library LoRA items are row-navigable: the row opens the local
|
||||
|
||||
@@ -522,7 +522,7 @@ export function createModelCard(model, modelType) {
|
||||
card.dataset.modelId = modelId;
|
||||
} else {
|
||||
// For externally-sourced models, derive a group key from the source
|
||||
// URL for version grouping (hf:user/repo, ms:user/repo, ta:<id>).
|
||||
// identity for version grouping (ms:<model_id>, ta:<id>).
|
||||
const sourceGroupKey = getModelSourceGroupKey(model);
|
||||
if (sourceGroupKey) {
|
||||
card.dataset.modelId = sourceGroupKey;
|
||||
|
||||
@@ -994,9 +994,9 @@ export function initVersionsTab({
|
||||
renderErrorState(container, translate('modals.model.versions.missingModelId', {}, 'This model is missing a Civitai model id.'));
|
||||
return;
|
||||
}
|
||||
// External source group keys (e.g. "hf:user/repo", "ms:user/repo",
|
||||
// "ta:8278...") are not real CivitAI model IDs — skip the remote API
|
||||
// call and show a helpful message instead.
|
||||
// External source group keys (e.g. "ms:12345", "ta:8278...") are
|
||||
// not real CivitAI model IDs — skip the remote API call and show a
|
||||
// helpful message instead.
|
||||
const sourceGroup = parseModelSourceGroupKey(modelId);
|
||||
if (sourceGroup) {
|
||||
controller.isLoading = false;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
|
||||
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
|
||||
import { withBasePath } from '../../utils/basePath.js';
|
||||
|
||||
/**
|
||||
* Loads recipes that use the specified model and renders them in the tab.
|
||||
@@ -356,7 +357,7 @@ function navigateToRecipesPage({ modelKind, displayName, modelHash }) {
|
||||
}
|
||||
|
||||
// Directly navigate to recipes page
|
||||
window.location.href = '/loras/recipes';
|
||||
window.location.href = withBasePath('/loras/recipes');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -378,7 +379,7 @@ function navigateToRecipeDetails(recipeId) {
|
||||
setSessionItem('viewRecipeId', recipeId);
|
||||
|
||||
// Directly navigate to recipes page
|
||||
window.location.href = '/loras/recipes';
|
||||
window.location.href = withBasePath('/loras/recipes');
|
||||
}
|
||||
|
||||
function getRecipesEndpoint(modelKind) {
|
||||
|
||||
@@ -31,6 +31,9 @@ class BannerService {
|
||||
this.banners = new Map();
|
||||
this.container = null;
|
||||
this.initialized = false;
|
||||
// Only one banner is rendered at a time; this index selects which of
|
||||
// the active (non-dismissed) banners is currently displayed.
|
||||
this.currentBannerIndex = 0;
|
||||
this.recentHistory = this.loadBannerHistory();
|
||||
this.bannerHistoryViewedAt = this.loadBannerHistoryViewedAt();
|
||||
|
||||
@@ -121,12 +124,22 @@ class BannerService {
|
||||
*/
|
||||
registerBanner(id, bannerConfig) {
|
||||
this.banners.set(id, bannerConfig);
|
||||
|
||||
// If already initialized, render the banner immediately
|
||||
if (this.initialized && !this.isBannerDismissed(id) && this.container) {
|
||||
this.renderBanner(bannerConfig);
|
||||
this.updateContainerVisibility();
|
||||
|
||||
if (!this.initialized || !this.container || this.isBannerDismissed(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Preempt the currently displayed banner only when the new one has a
|
||||
// strictly higher priority (i.e. sorts earlier).
|
||||
const activeBanners = this.getSortedActiveBanners();
|
||||
const displayedId = this.container.querySelector('.banner-item')
|
||||
?.getAttribute('data-banner-id');
|
||||
const newIndex = activeBanners.findIndex(banner => banner.id === id);
|
||||
const displayedIndex = activeBanners.findIndex(banner => banner.id === displayedId);
|
||||
if (displayedIndex === -1 || (newIndex !== -1 && newIndex < displayedIndex)) {
|
||||
this.currentBannerIndex = Math.max(newIndex, 0);
|
||||
}
|
||||
this.renderCurrentBanner();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -164,11 +177,10 @@ class BannerService {
|
||||
if (banner && typeof banner.onRemove === 'function') {
|
||||
banner.onRemove(bannerElement);
|
||||
}
|
||||
|
||||
|
||||
bannerElement.style.animation = 'banner-slide-up 0.3s ease-in-out forwards';
|
||||
setTimeout(() => {
|
||||
bannerElement.remove();
|
||||
this.updateContainerVisibility();
|
||||
this.renderCurrentBanner();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
@@ -193,28 +205,87 @@ class BannerService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active (non-dismissed) banners sorted by priority, highest first
|
||||
* @returns {Object[]}
|
||||
*/
|
||||
getSortedActiveBanners() {
|
||||
return Array.from(this.banners.values())
|
||||
.filter(banner => !this.isBannerDismissed(banner.id))
|
||||
.sort((a, b) => (b.priority || 0) - (a.priority || 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show all active (non-dismissed) banners
|
||||
*/
|
||||
async showActiveBanners() {
|
||||
if (!this.container) return;
|
||||
|
||||
const activeBanners = Array.from(this.banners.values())
|
||||
.filter(banner => !this.isBannerDismissed(banner.id))
|
||||
.sort((a, b) => (b.priority || 0) - (a.priority || 0));
|
||||
|
||||
activeBanners.forEach(banner => {
|
||||
this.renderBanner(banner);
|
||||
});
|
||||
|
||||
this.updateContainerVisibility();
|
||||
this.currentBannerIndex = 0;
|
||||
this.renderCurrentBanner();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a banner to the DOM
|
||||
* @param {Object} banner - Banner configuration
|
||||
* Render the currently selected banner into the container. Only one
|
||||
* banner is visible at a time; a pager lets the user cycle through the
|
||||
* remaining active banners.
|
||||
*/
|
||||
renderBanner(banner) {
|
||||
renderCurrentBanner() {
|
||||
if (!this.container) return;
|
||||
|
||||
const activeBanners = this.getSortedActiveBanners();
|
||||
|
||||
this.container.innerHTML = '';
|
||||
|
||||
if (activeBanners.length === 0) {
|
||||
this.currentBannerIndex = 0;
|
||||
this.updateContainerVisibility();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.currentBannerIndex >= activeBanners.length) {
|
||||
this.currentBannerIndex = activeBanners.length - 1;
|
||||
}
|
||||
if (this.currentBannerIndex < 0) {
|
||||
this.currentBannerIndex = 0;
|
||||
}
|
||||
|
||||
// Record every active banner once so dismissed/cycled-away banners
|
||||
// remain reachable through the notification center history.
|
||||
activeBanners.forEach(banner => this.recordBannerAppearance(banner));
|
||||
|
||||
const banner = activeBanners[this.currentBannerIndex];
|
||||
const bannerElement = this.buildBannerElement(banner, activeBanners.length);
|
||||
this.container.appendChild(bannerElement);
|
||||
|
||||
this.updateContainerVisibility();
|
||||
|
||||
// Call onRegister callback if provided
|
||||
if (typeof banner.onRegister === 'function') {
|
||||
banner.onRegister(bannerElement);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the displayed banner by offset, wrapping around
|
||||
* @param {number} offset - +1 for next, -1 for previous
|
||||
*/
|
||||
showAdjacentBanner(offset) {
|
||||
const activeBanners = this.getSortedActiveBanners();
|
||||
if (activeBanners.length < 2) return;
|
||||
|
||||
this.currentBannerIndex =
|
||||
(this.currentBannerIndex + offset + activeBanners.length) % activeBanners.length;
|
||||
this.renderCurrentBanner();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a banner DOM element
|
||||
* @param {Object} banner - Banner configuration
|
||||
* @param {number} totalCount - Total number of active banners
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
buildBannerElement(banner, totalCount) {
|
||||
const bannerElement = document.createElement('div');
|
||||
bannerElement.className = 'banner-item';
|
||||
bannerElement.setAttribute('data-banner-id', banner.id);
|
||||
@@ -230,11 +301,34 @@ class BannerService {
|
||||
</a>`;
|
||||
}).join('') : '';
|
||||
|
||||
const dismissButtonHtml = banner.dismissible ?
|
||||
const dismissButtonHtml = banner.dismissible ?
|
||||
`<button class="banner-dismiss" onclick="bannerService.dismissBanner('${banner.id}').catch(console.error)" title="Dismiss">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>` : '';
|
||||
|
||||
let pagerHtml = '';
|
||||
if (totalCount > 1) {
|
||||
const previousLabel = translate('banners.pager.previous', {}, 'Previous message');
|
||||
const nextLabel = translate('banners.pager.next', {}, 'Next message');
|
||||
const positionLabel = translate('banners.pager.position', {
|
||||
current: this.currentBannerIndex + 1,
|
||||
total: totalCount
|
||||
}, `Message ${this.currentBannerIndex + 1} of ${totalCount}`);
|
||||
|
||||
pagerHtml = `
|
||||
<div class="banner-pager">
|
||||
<button type="button" class="banner-pager-btn" data-pager="prev"
|
||||
aria-label="${previousLabel}" title="${previousLabel}">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
<span class="banner-pager-indicator" aria-label="${positionLabel}">${this.currentBannerIndex + 1} / ${totalCount}</span>
|
||||
<button type="button" class="banner-pager-btn" data-pager="next"
|
||||
aria-label="${nextLabel}" title="${nextLabel}">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
bannerElement.innerHTML = `
|
||||
<div class="banner-content">
|
||||
<div class="banner-text">
|
||||
@@ -244,18 +338,19 @@ class BannerService {
|
||||
<div class="banner-actions">
|
||||
${actionsHtml}
|
||||
</div>
|
||||
${pagerHtml}
|
||||
</div>
|
||||
${dismissButtonHtml}
|
||||
`;
|
||||
|
||||
this.container.appendChild(bannerElement);
|
||||
bannerElement.querySelectorAll('.banner-pager-btn').forEach(button => {
|
||||
button.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
this.showAdjacentBanner(button.getAttribute('data-pager') === 'next' ? 1 : -1);
|
||||
});
|
||||
});
|
||||
|
||||
this.recordBannerAppearance(banner);
|
||||
|
||||
// Call onRegister callback if provided
|
||||
if (typeof banner.onRegister === 'function') {
|
||||
banner.onRegister(bannerElement);
|
||||
}
|
||||
return bannerElement;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -458,17 +553,18 @@ class BannerService {
|
||||
* @param {string} bannerId - Banner ID to remove
|
||||
*/
|
||||
removeBannerElement(bannerId) {
|
||||
// Also remove from banners map
|
||||
this.banners.delete(bannerId);
|
||||
|
||||
const bannerElement = document.querySelector(`[data-banner-id="${bannerId}"]`);
|
||||
if (bannerElement) {
|
||||
bannerElement.style.animation = 'banner-slide-up 0.3s ease-in-out forwards';
|
||||
setTimeout(() => {
|
||||
bannerElement.remove();
|
||||
this.updateContainerVisibility();
|
||||
this.renderCurrentBanner();
|
||||
}, 300);
|
||||
} else {
|
||||
this.renderCurrentBanner();
|
||||
}
|
||||
|
||||
// Also remove from banners map
|
||||
this.banners.delete(bannerId);
|
||||
}
|
||||
|
||||
prepareCommunitySupportBanner() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,16 @@
|
||||
import { appCore } from './core.js';
|
||||
import { showToast } from './utils/uiHelpers.js';
|
||||
import { enableOtherModels, openOtherModelsSettings } from './utils/otherModels.js';
|
||||
import { enableOtherModels, openOtherModelsSettings, openModelPathsSettings } from './utils/otherModels.js';
|
||||
|
||||
/**
|
||||
* Other Models is an opt-in feature. While it is disabled this page renders an
|
||||
* empty state whose button turns the feature on; the backend then rebuilds the
|
||||
* other-model roots and starts scanning, so a reload lands on the real page.
|
||||
*
|
||||
* The same module backs the "enabled but no folders found" state, where the
|
||||
* only useful action is jumping to Settings instead of enabling anything.
|
||||
* The same module backs the "enabled but no folders found" state: ComfyUI
|
||||
* mode points to the Settings page's Library section, while standalone mode
|
||||
* points to the standalone-only Model Paths section (which edits the primary
|
||||
* folder_paths) and still offers the settings.json location as a fallback.
|
||||
*/
|
||||
async function handleEnableClick() {
|
||||
const button = document.getElementById('enableOtherModelsBtn');
|
||||
@@ -32,6 +34,49 @@ function handleOpenSettingsClick(event) {
|
||||
openOtherModelsSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open Settings on the Model Paths section for the standalone "no folders
|
||||
* found" state, so the missing folders can be added directly.
|
||||
*/
|
||||
function handleOpenModelPathsSettingsClick(event) {
|
||||
event.preventDefault();
|
||||
openModelPathsSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the settings.json location from the standalone no-folders state,
|
||||
* offered as a fallback next to the Model Paths settings button.
|
||||
*/
|
||||
async function handleOpenSettingsFolderClick() {
|
||||
const button = document.getElementById('openSettingsFolderBtn');
|
||||
if (!button || button.disabled) return;
|
||||
|
||||
button.disabled = true;
|
||||
try {
|
||||
const response = await fetch('/api/lm/settings/open-location', { method: 'POST' });
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok || data.success === false) {
|
||||
throw new Error(data.error || `HTTP ${response.status}`);
|
||||
}
|
||||
if (data.mode === 'clipboard' && data.path) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(data.path);
|
||||
showToast('settings.openSettingsFileLocation.copied', { path: data.path }, 'success');
|
||||
} catch (clipboardError) {
|
||||
console.warn('Clipboard API not available:', clipboardError);
|
||||
showToast('settings.openSettingsFileLocation.clipboardFallback', { path: data.path }, 'info');
|
||||
}
|
||||
} else {
|
||||
showToast('settings.openSettingsFileLocation.success', {}, 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to open settings location:', error);
|
||||
showToast('settings.openSettingsFileLocation.failed', {}, 'error');
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeOtherDisabledPage() {
|
||||
// appCore.initialize() wires the shared header (theme, settings modal,
|
||||
// language) so this page is not a dead end.
|
||||
@@ -46,8 +91,18 @@ async function initializeOtherDisabledPage() {
|
||||
if (settingsButton) {
|
||||
settingsButton.addEventListener('click', handleOpenSettingsClick);
|
||||
}
|
||||
|
||||
const modelPathsButton = document.getElementById('openModelPathsSettingsBtn');
|
||||
if (modelPathsButton) {
|
||||
modelPathsButton.addEventListener('click', handleOpenModelPathsSettingsClick);
|
||||
}
|
||||
|
||||
const settingsFolderButton = document.getElementById('openSettingsFolderBtn');
|
||||
if (settingsFolderButton) {
|
||||
settingsFolderButton.addEventListener('click', handleOpenSettingsFolderClick);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initializeOtherDisabledPage);
|
||||
|
||||
export { handleEnableClick as enableOtherModels, initializeOtherDisabledPage };
|
||||
export { handleEnableClick as enableOtherModels, handleOpenSettingsFolderClick, initializeOtherDisabledPage };
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// Create the new hierarchical state structure
|
||||
import { getStorageItem, getMapFromStorage } from '../utils/storageHelpers.js';
|
||||
import { MODEL_TYPES } from '../api/apiConfig.js';
|
||||
import { DEFAULT_PATH_TEMPLATES, DEFAULT_PRIORITY_TAG_CONFIG } from '../utils/constants.js';
|
||||
import { DEFAULT_PATH_TEMPLATES, DEFAULT_FILENAME_TEMPLATES, DEFAULT_PRIORITY_TAG_CONFIG } from '../utils/constants.js';
|
||||
|
||||
const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||
civitai_api_key: '',
|
||||
civitai_api_key_set: false,
|
||||
huggingface_api_key: '',
|
||||
huggingface_api_key_set: false,
|
||||
civitai_host: 'civitai.com',
|
||||
download_backend: 'python',
|
||||
aria2c_path: '',
|
||||
@@ -30,6 +32,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||
recipes_path: '',
|
||||
base_model_path_mappings: {},
|
||||
download_path_templates: {},
|
||||
download_filename_templates: {},
|
||||
example_images_path: '',
|
||||
example_images_open_mode: 'system',
|
||||
example_images_local_root: '',
|
||||
@@ -74,9 +77,16 @@ export function createDefaultSettings() {
|
||||
...DEFAULT_SETTINGS_BASE,
|
||||
base_model_path_mappings: {},
|
||||
download_path_templates: { ...DEFAULT_PATH_TEMPLATES },
|
||||
download_filename_templates: { ...DEFAULT_FILENAME_TEMPLATES },
|
||||
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
|
||||
default_other_roots: {},
|
||||
enabled_other_sub_types: ['vae', 'upscaler', 'text_encoder'],
|
||||
// Standalone-only fields populated by GET /api/lm/settings; in plugin
|
||||
// mode the backend omits folder_paths/folder_path_schema and these
|
||||
// defaults apply.
|
||||
standalone_mode: false,
|
||||
folder_paths: {},
|
||||
folder_path_schema: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Base-path helpers for the manager pages.
|
||||
*
|
||||
* `window.LM_BASE_PATH` is set by the inline bootstrap in
|
||||
* templates/components/base_path_bootstrap.html: it is the reverse-proxy
|
||||
* subpath the manager is served under (e.g. "/comfyui"), or "" for normal
|
||||
* and standalone deployments.
|
||||
*/
|
||||
|
||||
export function getBasePath() {
|
||||
return window.LM_BASE_PATH || '';
|
||||
}
|
||||
|
||||
export function withBasePath(path) {
|
||||
return `${getBasePath()}${path}`;
|
||||
}
|
||||
@@ -360,6 +360,25 @@ export const DEFAULT_PATH_TEMPLATES = {
|
||||
other: ''
|
||||
};
|
||||
|
||||
// Valid placeholders for download filename templates (opt-in rename of
|
||||
// downloaded safetensors; the result is a filename stem, no path separators)
|
||||
export const FILENAME_TEMPLATE_PLACEHOLDERS = [
|
||||
'{model_name}',
|
||||
'{version_name}',
|
||||
'{base_model}',
|
||||
'{author}',
|
||||
'{first_tag}',
|
||||
'{hash_short}',
|
||||
'{original_name}'
|
||||
];
|
||||
|
||||
// Default filename templates per model type; empty string keeps the original filename
|
||||
export const DEFAULT_FILENAME_TEMPLATES = {
|
||||
lora: '',
|
||||
checkpoint: '',
|
||||
embedding: ''
|
||||
};
|
||||
|
||||
// Model type labels for UI
|
||||
export const MODEL_TYPE_LABELS = {
|
||||
lora: 'LoRA Models',
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
* support AI metadata enrichment.
|
||||
*
|
||||
* Models loaded from an older cache may only carry the legacy `hf_url`
|
||||
* field; every helper here falls back to it, and to the legacy
|
||||
* `hf:user/repo` group key shape.
|
||||
* field; every helper here falls back to it.
|
||||
*/
|
||||
|
||||
import { translate } from './i18nHelpers.js';
|
||||
@@ -17,6 +16,9 @@ export const MODEL_SOURCES = [
|
||||
platform: 'huggingface',
|
||||
label: 'Hugging Face',
|
||||
groupPrefix: 'hf',
|
||||
// A repository hosts many unrelated models and the site exposes no
|
||||
// model-level identity, so HF models never auto-group.
|
||||
groupKey: 'none',
|
||||
supportsEnrichment: true,
|
||||
supportsDownload: true,
|
||||
defaultRevision: 'main',
|
||||
@@ -36,6 +38,9 @@ export const MODEL_SOURCES = [
|
||||
platform: 'modelscope',
|
||||
label: 'ModelScope',
|
||||
groupPrefix: 'ms',
|
||||
// Group by the site-native published-model id (`source_model_id`),
|
||||
// recorded by enrichment — the repo id is not a model identity.
|
||||
groupKey: 'modelId',
|
||||
supportsEnrichment: true,
|
||||
supportsDownload: true,
|
||||
defaultRevision: 'master',
|
||||
@@ -56,6 +61,7 @@ export const MODEL_SOURCES = [
|
||||
platform: 'modelscope-ai',
|
||||
label: 'ModelScope (International)',
|
||||
groupPrefix: 'msai',
|
||||
groupKey: 'modelId',
|
||||
supportsEnrichment: true,
|
||||
supportsDownload: true,
|
||||
defaultRevision: 'master',
|
||||
@@ -73,6 +79,8 @@ export const MODEL_SOURCES = [
|
||||
platform: 'tensorart',
|
||||
label: 'TensorArt',
|
||||
groupPrefix: 'ta',
|
||||
// The numeric id in a TensorArt URL already identifies a single model.
|
||||
groupKey: 'repo',
|
||||
supportsEnrichment: false,
|
||||
supportsDownload: false,
|
||||
defaultRevision: '',
|
||||
@@ -152,11 +160,21 @@ export function getModelSourceInfo(model) {
|
||||
|
||||
/**
|
||||
* Version-group key for a model, matching the backend's `_extract_group_key`.
|
||||
* Returns `''` when the model has no external source.
|
||||
* Returns `''` when the model has no external source, or when its source has
|
||||
* no reliable model identity (Hugging Face, or a ModelScope model that has
|
||||
* not been enriched with the site-native `source_model_id` yet).
|
||||
*/
|
||||
export function getModelSourceGroupKey(model) {
|
||||
const info = getModelSourceInfo(model);
|
||||
if (!info || !info.sourceId) return '';
|
||||
if (!info) return '';
|
||||
const strategy = info.groupKey || 'repo';
|
||||
if (strategy === 'none') return '';
|
||||
if (strategy === 'modelId') {
|
||||
const modelId =
|
||||
model && typeof model.source_model_id === 'string' ? model.source_model_id.trim() : '';
|
||||
return modelId ? `${info.groupPrefix}:${modelId}` : '';
|
||||
}
|
||||
if (!info.sourceId) return '';
|
||||
return `${info.groupPrefix}:${info.sourceId}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -50,3 +50,19 @@ export function openOtherModelsSettings() {
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the settings modal on the standalone-only Model Paths section, where
|
||||
* primary folder_paths are edited. The section only exists in standalone mode,
|
||||
* so the nav item lookup simply no-ops elsewhere.
|
||||
*/
|
||||
export function openModelPathsSettings() {
|
||||
const modalManager = window.modalManager;
|
||||
if (modalManager && typeof modalManager.showModal === 'function') {
|
||||
modalManager.showModal('settingsModal');
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
document.querySelector('.settings-nav-item[data-section="modelPaths"]')?.click();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
+10
-9
@@ -2,19 +2,20 @@
|
||||
<html>
|
||||
|
||||
<head>
|
||||
{% include 'components/base_path_bootstrap.html' %}
|
||||
<title>{% block title %}{{ t('header.appTitle') }}{% endblock %}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="/loras_static/css/style.css?v={{ version }}">
|
||||
<link rel="stylesheet" href="/loras_static/css/onboarding.css?v={{ version }}">
|
||||
<link rel="stylesheet" href="/loras_static/vendor/flag-icons/flag-icons.min.css">
|
||||
<link rel="stylesheet" href="{{ rel_prefix }}loras_static/css/style.css?v={{ version }}">
|
||||
<link rel="stylesheet" href="{{ rel_prefix }}loras_static/css/onboarding.css?v={{ version }}">
|
||||
<link rel="stylesheet" href="{{ rel_prefix }}loras_static/vendor/flag-icons/flag-icons.min.css">
|
||||
{% block page_css %}{% endblock %}
|
||||
<link rel="stylesheet" href="/loras_static/vendor/font-awesome/css/all.min.css"
|
||||
<link rel="stylesheet" href="{{ rel_prefix }}loras_static/vendor/font-awesome/css/all.min.css"
|
||||
crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/loras_static/images/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/loras_static/images/favicon-16x16.png">
|
||||
<link rel="manifest" href="/loras_static/images/site.webmanifest">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ rel_prefix }}loras_static/images/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="{{ rel_prefix }}loras_static/images/favicon-16x16.png">
|
||||
<link rel="manifest" href="{{ rel_prefix }}loras_static/images/site.webmanifest">
|
||||
|
||||
<link rel="preload" as="font" type="font/woff2" href="/loras_static/vendor/font-awesome/webfonts/fa-solid-900.woff2" crossorigin>
|
||||
<link rel="preload" as="font" type="font/woff2" href="{{ rel_prefix }}loras_static/vendor/font-awesome/webfonts/fa-solid-900.woff2" crossorigin>
|
||||
|
||||
<!-- 添加性能监控 -->
|
||||
<script>
|
||||
@@ -102,7 +103,7 @@
|
||||
|
||||
{% if is_initializing %}
|
||||
<!-- Load initialization JavaScript -->
|
||||
<script type="module" src="/loras_static/js/components/initialization.js?v={{ version }}"></script>
|
||||
<script type="module" src="{{ rel_prefix }}loras_static/js/components/initialization.js?v={{ version }}"></script>
|
||||
{% else %}
|
||||
{% block main_script %}{% endblock %}
|
||||
{% endif %}
|
||||
|
||||
@@ -75,5 +75,5 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block main_script %}
|
||||
<script type="module" src="/loras_static/js/checkpoints.js?v={{ version }}"></script>
|
||||
<script type="module" src="{{ rel_prefix }}loras_static/js/checkpoints.js?v={{ version }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
{#
|
||||
Base-path bootstrap. Must be the FIRST element in <head>: it detects the
|
||||
reverse-proxy subpath (e.g. "/comfyui" when served via llama-swap, or
|
||||
"/ComfyBackendDirect" via SwarmUI) from the current page URL and, only when
|
||||
a prefix exists, patches fetch/XHR/WebSocket/innerHTML/DOM URL setters so
|
||||
that root-absolute URLs ("/api/lm/...", "/loras_static/...") generated
|
||||
anywhere in the frontend or in backend JSON payloads get the prefix
|
||||
prepended. When no prefix is detected (normal or standalone deployment)
|
||||
nothing is patched and behavior is byte-identical to before.
|
||||
#}
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Manager page routes as the backend sees them (proxies strip the
|
||||
// prefix, so the page path always ends with one of these suffixes).
|
||||
// Longest first so "/loras/recipes" wins over "/loras".
|
||||
var KNOWN_PAGES = ['/loras/recipes', '/loras', '/checkpoints', '/embeddings', '/other', '/statistics'];
|
||||
|
||||
var path = window.location.pathname.replace(/\/+$/, '') || '/';
|
||||
var prefix = '';
|
||||
for (var i = 0; i < KNOWN_PAGES.length; i++) {
|
||||
var page = KNOWN_PAGES[i];
|
||||
if (path === page || (path.length > page.length && path.endsWith(page))) {
|
||||
prefix = path.slice(0, path.length - page.length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
window.LM_BASE_PATH = prefix;
|
||||
if (!prefix) {
|
||||
return;
|
||||
}
|
||||
|
||||
var prefixBody = prefix.slice(1);
|
||||
|
||||
function prefixPath(p) {
|
||||
if (p.charAt(0) !== '/' || p.charAt(1) === '/') {
|
||||
return p;
|
||||
}
|
||||
// Already prefixed (e.g. markup re-serialized from the DOM).
|
||||
if (p === prefix || p.indexOf(prefix + '/') === 0) {
|
||||
return p;
|
||||
}
|
||||
return prefix + p;
|
||||
}
|
||||
|
||||
function prefixUrl(url) {
|
||||
if (typeof url !== 'string') {
|
||||
return url;
|
||||
}
|
||||
if (url.charAt(0) === '/') {
|
||||
return prefixPath(url);
|
||||
}
|
||||
// Absolute same-origin URL (e.g. from a Request object).
|
||||
if (url.indexOf('http') === 0) {
|
||||
try {
|
||||
var u = new URL(url);
|
||||
if (u.origin === window.location.origin) {
|
||||
var prefixed = prefixPath(u.pathname);
|
||||
if (prefixed !== u.pathname) {
|
||||
u.pathname = prefixed;
|
||||
return u.toString();
|
||||
}
|
||||
}
|
||||
} catch (e) { /* not a parseable URL: leave untouched */ }
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
window.lmWithBasePath = prefixUrl;
|
||||
|
||||
// fetch()
|
||||
if (window.fetch) {
|
||||
var nativeFetch = window.fetch;
|
||||
window.fetch = function (input, init) {
|
||||
if (typeof input === 'string') {
|
||||
input = prefixUrl(input);
|
||||
} else if (typeof URL !== 'undefined' && input instanceof URL) {
|
||||
// fetch(new URL('/api/...', location.origin)) bypasses the
|
||||
// string check — coerce so same-origin URLs get prefixed.
|
||||
input = prefixUrl(input.href);
|
||||
} else if (typeof Request !== 'undefined' && input instanceof Request) {
|
||||
var requestUrl = prefixUrl(input.url);
|
||||
if (requestUrl !== input.url) {
|
||||
input = new Request(requestUrl, input);
|
||||
}
|
||||
}
|
||||
return nativeFetch.call(this, input, init);
|
||||
};
|
||||
}
|
||||
|
||||
// XMLHttpRequest
|
||||
if (window.XMLHttpRequest) {
|
||||
var nativeOpen = window.XMLHttpRequest.prototype.open;
|
||||
window.XMLHttpRequest.prototype.open = function (method, url) {
|
||||
arguments[1] = prefixUrl(typeof url === 'string' ? url : String(url));
|
||||
return nativeOpen.apply(this, arguments);
|
||||
};
|
||||
}
|
||||
|
||||
// WebSocket
|
||||
if (window.WebSocket) {
|
||||
var NativeWebSocket = window.WebSocket;
|
||||
var PatchedWebSocket = function (url, protocols) {
|
||||
if (url && typeof url !== 'string' && url.href) {
|
||||
url = url.href;
|
||||
}
|
||||
return protocols === undefined
|
||||
? new NativeWebSocket(prefixUrl(url))
|
||||
: new NativeWebSocket(prefixUrl(url), protocols);
|
||||
};
|
||||
PatchedWebSocket.prototype = NativeWebSocket.prototype;
|
||||
PatchedWebSocket.CONNECTING = NativeWebSocket.CONNECTING;
|
||||
PatchedWebSocket.OPEN = NativeWebSocket.OPEN;
|
||||
PatchedWebSocket.CLOSING = NativeWebSocket.CLOSING;
|
||||
PatchedWebSocket.CLOSED = NativeWebSocket.CLOSED;
|
||||
window.WebSocket = PatchedWebSocket;
|
||||
}
|
||||
|
||||
// Markup injected via innerHTML/outerHTML/insertAdjacentHTML carries
|
||||
// root-absolute URLs from backend JSON (preview URLs, placeholders) and
|
||||
// inline handlers (onerror="this.src='/...'"): rewrite the attributes.
|
||||
var ATTR_URL_RE = /((?:src|href|poster)\s*=\s*["'])(\/)(?!\/)/g;
|
||||
function rewriteMarkup(html) {
|
||||
if (typeof html !== 'string' || html.indexOf('/') === -1) {
|
||||
return html;
|
||||
}
|
||||
return html.replace(ATTR_URL_RE, function (match, head, slash, offset, whole) {
|
||||
var rest = whole.slice(offset + match.length);
|
||||
if (rest === prefixBody || rest.indexOf(prefixBody + '/') === 0) {
|
||||
return match;
|
||||
}
|
||||
return head + prefix + '/';
|
||||
});
|
||||
}
|
||||
|
||||
function patchMarkupProp(proto, prop) {
|
||||
var desc = Object.getOwnPropertyDescriptor(proto, prop);
|
||||
if (!desc || !desc.set) {
|
||||
return;
|
||||
}
|
||||
Object.defineProperty(proto, prop, {
|
||||
configurable: true,
|
||||
enumerable: desc.enumerable,
|
||||
get: desc.get,
|
||||
set: function (value) { desc.set.call(this, rewriteMarkup(value)); },
|
||||
});
|
||||
}
|
||||
|
||||
if (window.Element) {
|
||||
patchMarkupProp(window.Element.prototype, 'innerHTML');
|
||||
patchMarkupProp(window.Element.prototype, 'outerHTML');
|
||||
var nativeInsertAdjacentHTML = window.Element.prototype.insertAdjacentHTML;
|
||||
if (nativeInsertAdjacentHTML) {
|
||||
window.Element.prototype.insertAdjacentHTML = function (position, html) {
|
||||
return nativeInsertAdjacentHTML.call(this, position, rewriteMarkup(html));
|
||||
};
|
||||
}
|
||||
|
||||
// Direct DOM assignments: img.src = model.preview_url, anchor.href, ...
|
||||
var nativeSetAttribute = window.Element.prototype.setAttribute;
|
||||
window.Element.prototype.setAttribute = function (name, value) {
|
||||
if (typeof value === 'string' && /^(src|href|poster)$/i.test(name)) {
|
||||
value = prefixUrl(value);
|
||||
}
|
||||
return nativeSetAttribute.call(this, name, value);
|
||||
};
|
||||
}
|
||||
|
||||
function patchUrlProp(proto, prop) {
|
||||
if (!proto) {
|
||||
return;
|
||||
}
|
||||
var desc = Object.getOwnPropertyDescriptor(proto, prop);
|
||||
if (!desc || !desc.set) {
|
||||
return;
|
||||
}
|
||||
Object.defineProperty(proto, prop, {
|
||||
configurable: true,
|
||||
enumerable: desc.enumerable,
|
||||
get: desc.get,
|
||||
set: function (value) { desc.set.call(this, prefixUrl(value)); },
|
||||
});
|
||||
}
|
||||
|
||||
patchUrlProp(window.HTMLImageElement && window.HTMLImageElement.prototype, 'src');
|
||||
patchUrlProp(window.HTMLMediaElement && window.HTMLMediaElement.prototype, 'src');
|
||||
patchUrlProp(window.HTMLSourceElement && window.HTMLSourceElement.prototype, 'src');
|
||||
patchUrlProp(window.HTMLVideoElement && window.HTMLVideoElement.prototype, 'poster');
|
||||
patchUrlProp(window.HTMLAnchorElement && window.HTMLAnchorElement.prototype, 'href');
|
||||
patchUrlProp(window.HTMLScriptElement && window.HTMLScriptElement.prototype, 'src');
|
||||
patchUrlProp(window.HTMLLinkElement && window.HTMLLinkElement.prototype, 'href');
|
||||
})();
|
||||
</script>
|
||||
@@ -3,8 +3,8 @@
|
||||
<!-- Left section: Logo + Navigation -->
|
||||
<div class="header-left">
|
||||
<div class="header-branding">
|
||||
<a href="/loras" class="logo-link">
|
||||
<img src="/loras_static/images/favicon-32x32.png" alt="LoRA Manager" class="app-logo">
|
||||
<a href="{{ rel_prefix }}loras" class="logo-link">
|
||||
<img src="{{ rel_prefix }}loras_static/images/favicon-32x32.png" alt="LoRA Manager" class="app-logo">
|
||||
<span class="app-title">{{ t('header.appTitle') }}</span>
|
||||
</a>
|
||||
</div>
|
||||
@@ -23,26 +23,26 @@
|
||||
{% set current_page = 'loras' %}
|
||||
{% endif %}
|
||||
<nav class="main-nav">
|
||||
<a href="/loras" class="nav-item{% if current_path == '/loras' %} active{% endif %}" id="lorasNavItem">
|
||||
<a href="{{ rel_prefix }}loras" class="nav-item{% if current_path == '/loras' %} active{% endif %}" id="lorasNavItem">
|
||||
<i class="fas fa-layer-group"></i> <span>{{ t('header.navigation.loras') }}</span>
|
||||
</a>
|
||||
<a href="/loras/recipes" class="nav-item{% if current_path.startswith('/loras/recipes') %} active{% endif %}"
|
||||
<a href="{{ rel_prefix }}loras/recipes" class="nav-item{% if current_path.startswith('/loras/recipes') %} active{% endif %}"
|
||||
id="recipesNavItem">
|
||||
<i class="fas fa-book-open"></i> <span>{{ t('header.navigation.recipes') }}</span>
|
||||
</a>
|
||||
<a href="/checkpoints" class="nav-item{% if current_path.startswith('/checkpoints') %} active{% endif %}"
|
||||
<a href="{{ rel_prefix }}checkpoints" class="nav-item{% if current_path.startswith('/checkpoints') %} active{% endif %}"
|
||||
id="checkpointsNavItem">
|
||||
<i class="fas fa-check-circle"></i> <span>{{ t('header.navigation.checkpoints') }}</span>
|
||||
</a>
|
||||
<a href="/embeddings" class="nav-item{% if current_path.startswith('/embeddings') %} active{% endif %}"
|
||||
<a href="{{ rel_prefix }}embeddings" class="nav-item{% if current_path.startswith('/embeddings') %} active{% endif %}"
|
||||
id="embeddingsNavItem">
|
||||
<i class="fas fa-code"></i> <span>{{ t('header.navigation.embeddings') }}</span>
|
||||
</a>
|
||||
<a href="/other" class="nav-item{% if current_path.startswith('/other') %} active{% endif %}{% if not settings.get('enable_other_models') %} nav-item--hidden{% endif %}"
|
||||
<a href="{{ rel_prefix }}other" class="nav-item{% if current_path.startswith('/other') %} active{% endif %}{% if not settings.get('enable_other_models') %} nav-item--hidden{% endif %}"
|
||||
id="otherNavItem">
|
||||
<i class="fas fa-shapes"></i> <span>{{ t('header.navigation.other') }}</span>
|
||||
</a>
|
||||
<a href="/statistics" class="nav-item{% if current_path.startswith('/statistics') %} active{% endif %}"
|
||||
<a href="{{ rel_prefix }}statistics" class="nav-item{% if current_path.startswith('/statistics') %} active{% endif %}"
|
||||
id="statisticsNavItem">
|
||||
<i class="fas fa-chart-bar"></i> <span>{{ t('header.navigation.statistics') }}</span>
|
||||
</a>
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<div class="tip-carousel" id="tipCarousel">
|
||||
<div class="tip-item active">
|
||||
<div class="tip-image">
|
||||
<img src="/loras_static/images/tips/civitai-api.png" alt="{{ t('initialization.tips.civitai.alt') }}"
|
||||
<img src="{{ rel_prefix }}loras_static/images/tips/civitai-api.png" alt="{{ t('initialization.tips.civitai.alt') }}"
|
||||
onerror="this.src='/loras_static/images/no-preview.png'">
|
||||
</div>
|
||||
<div class="tip-text">
|
||||
@@ -39,7 +39,7 @@
|
||||
</div>
|
||||
<div class="tip-item">
|
||||
<div class="tip-image">
|
||||
<img src="/loras_static/images/tips/civitai-download.png" alt="{{ t('initialization.tips.download.alt') }}"
|
||||
<img src="{{ rel_prefix }}loras_static/images/tips/civitai-download.png" alt="{{ t('initialization.tips.download.alt') }}"
|
||||
onerror="this.src='/loras_static/images/no-preview.png'">
|
||||
</div>
|
||||
<div class="tip-text">
|
||||
@@ -49,7 +49,7 @@
|
||||
</div>
|
||||
<div class="tip-item">
|
||||
<div class="tip-image">
|
||||
<img src="/loras_static/images/tips/recipes.png" alt="{{ t('initialization.tips.recipes.alt') }}"
|
||||
<img src="{{ rel_prefix }}loras_static/images/tips/recipes.png" alt="{{ t('initialization.tips.recipes.alt') }}"
|
||||
onerror="this.src='/loras_static/images/no-preview.png'">
|
||||
</div>
|
||||
<div class="tip-text">
|
||||
@@ -59,7 +59,7 @@
|
||||
</div>
|
||||
<div class="tip-item">
|
||||
<div class="tip-image">
|
||||
<img src="/loras_static/images/tips/filter.png" alt="{{ t('initialization.tips.filter.alt') }}"
|
||||
<img src="{{ rel_prefix }}loras_static/images/tips/filter.png" alt="{{ t('initialization.tips.filter.alt') }}"
|
||||
onerror="this.src='/loras_static/images/no-preview.png'">
|
||||
</div>
|
||||
<div class="tip-text">
|
||||
@@ -69,7 +69,7 @@
|
||||
</div>
|
||||
<div class="tip-item">
|
||||
<div class="tip-image">
|
||||
<img src="/loras_static/images/tips/search.webp" alt="{{ t('initialization.tips.search.alt') }}"
|
||||
<img src="{{ rel_prefix }}loras_static/images/tips/search.webp" alt="{{ t('initialization.tips.search.alt') }}"
|
||||
onerror="this.src='/loras_static/images/no-preview.png'">
|
||||
</div>
|
||||
<div class="tip-text">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user