mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-26 13:34:08 -03:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7aee964448 | ||
|
|
62c144d80a | ||
|
|
d21f209bad | ||
|
|
a6fca8612f | ||
|
|
5e4462822d | ||
|
|
6e45ef566c | ||
|
|
16430aef21 | ||
|
|
f5e983eaaa | ||
|
|
297d8787bd | ||
|
|
20d8c22390 | ||
|
|
3555ddb588 | ||
|
|
ede15032ce | ||
|
|
c48feeddb6 | ||
|
|
8a80f82d93 | ||
|
|
c4676183b5 | ||
|
|
8b7ba59263 | ||
|
|
067e605e75 | ||
|
|
dae18b3d1d | ||
|
|
2f9bd3ee7d | ||
|
|
755e1a5bca | ||
|
|
c202654d49 | ||
|
|
74736f7560 | ||
|
|
0ada32d0c7 | ||
|
|
e9aff35957 |
@@ -10,6 +10,9 @@ civitai/
|
||||
stats/
|
||||
wildcards/
|
||||
backups/
|
||||
# Portable-mode centralized sidecar storage (<repo>/sidecars): user data that
|
||||
# must survive pulls and stay out of git status
|
||||
/sidecars/
|
||||
logs/
|
||||
node_modules/
|
||||
coverage/
|
||||
|
||||
@@ -261,6 +261,18 @@ 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.
|
||||
- **Sidecar/preview path derivation must go through `py/utils/sidecar_paths.py`**
|
||||
helpers (never inline `splitext + ".metadata.json"`): the centralized storage
|
||||
mode (`sidecar_storage_mode` / `sidecar_storage_path` settings) relocates
|
||||
`.metadata.json` files and preview images under a mirror tree, so any
|
||||
hand-built path is wrong in that mode. `.civitai.info` stays co-located with
|
||||
the model file in both modes. The new settings keys live only in
|
||||
`DEFAULT_SETTINGS` — `settings.json.example` stays minimal (see below).
|
||||
- **`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
|
||||
}
|
||||
@@ -4,7 +4,7 @@ This document is the canonical set of conventions for translating LoRA Manager U
|
||||
It applies to **human translators and AI agents** alike. Read it before editing anything in
|
||||
`locales/`.
|
||||
|
||||
Source of truth: `locales/en.json` (10 locales, 2025 leaf keys; all locales share the exact
|
||||
Source of truth: `locales/en.json` (10 locales, 2128 leaf keys; all locales share the exact
|
||||
same key structure).
|
||||
|
||||
Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
|
||||
@@ -78,6 +78,23 @@ Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
|
||||
> the `toast.loras.filenameTemplate*` / `toast.settings.filenameTemplates*` toasts. All 9
|
||||
> locales are translated (terminology in §2, "Filename Templates feature").
|
||||
|
||||
> **Status (2026-09, folder delete verification):** the folder delete modal no longer trusts the
|
||||
> sidebar's "empty folder" prediction — it dry-runs the delete against the backend and renders
|
||||
> the answer, so a folder whose models are all *excluded* (invisible to the model lists, still
|
||||
> real weight files on disk) is refused with an explanation instead of contradicting itself.
|
||||
> That added 5 keys (`sidebar.deleteFolderModal.notEmptyMessageCount`,
|
||||
> `.notEmptyMessageExcluded`, `.busyTitle`, `.checking`, `sidebar.deleteFolderResult.notEmptyWithCount`);
|
||||
> all 9 locales are translated (terminology in §2, "Folder sidebar feature"), so the
|
||||
> "no remaining placeholders" claim holds again.
|
||||
|
||||
> **Status (2026-09, sidecar storage):** optional centralized storage for `.metadata.json`
|
||||
> sidecars and preview images added 23 keys — `settings.sections.sidecarStorage`,
|
||||
> the 18 `settings.sidecarStorage.*` labels/help/status/confirm strings, and the 4
|
||||
> `modals.sidecarMigrationConfirm.*` titles/button. The pull request merged them as
|
||||
> `[TODO: Translate]` copies; all 9 locales are now translated (terminology in §2,
|
||||
> "Sidecar storage feature"), so no placeholder remains and the "no remaining placeholders"
|
||||
> claim holds again.
|
||||
|
||||
---
|
||||
|
||||
## 1. Hard rules (do not violate)
|
||||
@@ -365,6 +382,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
|
||||
@@ -377,11 +408,32 @@ The model-root sidebar manages on-disk folders. "Folder" reuses the noun already
|
||||
| tree view / list view | zh-CN 树形视图 / 列表视图 · zh-TW 樹狀檢視 / 清單檢視 · ja ツリー表示 / リスト表示 · ko 트리 보기 / 목록 보기 · fr Vue arborescente / Vue liste · de Baumansicht / Listenansicht · es Vista de árbol / Vista de lista · ru Дерево / Список · he תצוגת עץ / תצוגת רשימה |
|
||||
| sidebar | reuse each locale's `sidebar.hideOnThisPage` noun: zh-CN 侧边栏 · zh-TW 側邊欄 · ja サイドバー · ko 사이드바 · fr barre latérale · de Seitenleiste · es barra lateral · ru боковая панель · he סרגל צד |
|
||||
|
||||
Deleting a folder **never cascades over model files** — the backend refuses it and
|
||||
`sidebar.deleteFolderModal.notEmptyMessage` states the rule in every locale, so keep that
|
||||
clause (and its `—`) when the copy is edited. The `{name}` / `{count}` / `{message}` tokens in
|
||||
`sidebar.createFolderResult.*`, `sidebar.deleteFolderResult.*` and `sidebar.renameFolderResult.*`
|
||||
are verbatim §1-R2 placeholders; `successWithFiles` is the only key carrying `{count}`.
|
||||
Deleting a folder **never cascades over model files** — the backend refuses it and the
|
||||
`sidebar.deleteFolderModal.notEmptyMessage*` keys state the rule in every locale, so keep that
|
||||
clause (and its `—`) when the copy is edited. The three variants split by what the modal knows:
|
||||
`notEmptyMessage` (no counts), `notEmptyMessageCount` (`{count}`, the blocking models are all
|
||||
listed) and `notEmptyMessageExcluded` (`{count}` + `{excluded}`, at least one is hidden by the
|
||||
`exclude` flag — the case where the folder legitimately looks empty). `checking` ("Checking the
|
||||
folder contents...", ASCII ellipsis) shows while the backend dry run is pending, `busyTitle`
|
||||
titles the already-pending-staged-delete state, and `notEmptyWithCount` mirrors
|
||||
`deleteFolderResult.notEmpty` with the count for the stale-tree toast.
|
||||
|
||||
| Term | Rendering |
|
||||
|---|---|
|
||||
| excluded from the library | zh-CN 已从模型库中排除 · zh-TW 已從模型庫中排除 · ja ライブラリから除外 · ko 라이브러리에서 제외 · fr exclu de la bibliothèque · de von der Bibliothek ausgeschlossen · es excluido de la biblioteca · ru исключены из библиотеки · he מוחרגים מהספרייה |
|
||||
| un-exclude (verb) | zh-CN 取消排除 · zh-TW 取消排除 · ja 除外を解除 · ko 제외를 해제 · fr annuler l'exclusion · de den Ausschluss aufheben · es anular la exclusión · ru снять исключение · he לבטל את ההחרגה |
|
||||
| "Manage Excluded Models" quoted in prose | zh-CN “管理已排除的模型” · zh-TW 「管理已排除的模型」 · ja 「除外モデルを管理」 · ko '제외된 모델 관리' · fr « Gérer les modèles exclus » · de „Ausgeschlossene Modelle verwalten“ · es «Gestionar modelos excluidos» · ru «Управление исключёнными моделями» · he «ניהול מודלים מוחרגים» |
|
||||
|
||||
A UI label quoted inside prose follows each locale's existing help-text style (zh-CN “ ”,
|
||||
zh-TW/ja 「 」, ko ASCII `' '`, fr/ru/es/he « », de „ “) — see `settings.hideEarlyAccessUpdates.help`
|
||||
/ `settings.civitaiHost.help` as the precedent. `קובצי מודלים` is the Hebrew model-file noun
|
||||
(`notEmptyMessage`); keep it identical in all four Hebrew keys.
|
||||
|
||||
The `{name}` / `{count}` / `{excluded}` / `{message}` tokens in `sidebar.createFolderResult.*`,
|
||||
`sidebar.deleteFolderResult.*` and `sidebar.renameFolderResult.*` are verbatim §1-R2
|
||||
placeholders. The keys carrying `{count}` are `successWithFiles`, `notEmptyMessageCount`,
|
||||
`notEmptyMessageExcluded` and `notEmptyWithCount`; `notEmptyMessageExcluded` is the only key
|
||||
carrying `{excluded}`.
|
||||
|
||||
### Settings Organization tab
|
||||
|
||||
@@ -404,6 +456,45 @@ the **noun for arranging files**, matching each locale's existing
|
||||
|
||||
zh-CN/zh-TW use 整理 ("tidying/arranging"), not 组织/組織 (an organization as a group).
|
||||
|
||||
### Sidecar storage feature (centralized `.metadata.json` / preview storage)
|
||||
|
||||
The Library settings tab hosts an optional mode that stores `.metadata.json` sidecars and
|
||||
preview images either **alongside** each model file or in a single **centralized** mirror tree,
|
||||
plus the manual migration that moves existing files between the two. Everything lives in
|
||||
`settings.sections.sidecarStorage` (the section header inside the Library tab),
|
||||
`settings.sidecarStorage.*` and `modals.sidecarMigrationConfirm.*`.
|
||||
|
||||
- **`sidecar` is a technical noun, not a brand**, so each locale either borrows it or uses its
|
||||
own companion-file word — one rendering per file:
|
||||
|
||||
| Term | Rendering |
|
||||
|---|---|
|
||||
| sidecar (noun) | zh-CN 附属文件 · zh-TW 附屬檔案 · ja サイドカーファイル · ko 사이드카 파일 · fr fichier sidecar · de Sidecar-Datei · es archivo sidecar · ru sidecar-файл · he קובץ לוואי |
|
||||
| centralized storage | zh-CN 集中存储 · zh-TW 集中儲存 · ja 集中保存 · ko 중앙 집중식 저장 · fr stockage centralisé · de zentrale Speicherung · es almacenamiento centralizado · ru централизованное хранилище · he אחסון מרכזי |
|
||||
| alongside model files | zh-CN 与模型文件放在一起 · zh-TW 與模型檔案放在一起 · ja モデルファイルの隣 · ko 모델 파일 옆 · fr à côté des fichiers de modèle · de neben den Modelldateien · es junto a los archivos de modelo · ru рядом с файлами моделей · he לצד קובצי המודלים |
|
||||
| migrate (verb/noun) | zh-CN 迁移 · zh-TW 遷移 · ja 移動 · ko 이동 · fr migrer / migration · de verschieben / Migration · es migrar / migración · ru перенести / перенос · he להעביר / העברה |
|
||||
| mirror (verb) | zh-CN 镜像 · zh-TW 對應 · ja ミラーリング · ko 미러링 · fr refléter · de spiegeln · es reflejar · ru повторять структуру · he לשקף |
|
||||
| preview images | zh-CN 预览图片 · zh-TW 預覽圖片 · ja プレビュー画像 · ko 미리보기 이미지 · fr images d’aperçu · de Vorschaubilder · es imágenes de vista previa · ru изображения превью · he תמונות תצוגה מקדימה |
|
||||
|
||||
- `ja`/`ko` follow the file's existing storage-relocation verb (ja 移動, ko 이동, from
|
||||
`settings.folderSettings.recipesPathMigrating`) rather than a transliteration of "migration";
|
||||
`ru` uses перенос for the same reason, and `de` keeps the loan noun `Migration` while the verbs
|
||||
use `verschieben`.
|
||||
- **`.metadata.json`**, **`.civitai.info`** and the default-path literal
|
||||
`(<settings dir>/sidecars)` stay byte-identical in every locale — they are file names and a
|
||||
path, not prose (§6 exception). Hebrew drops the wrapping parentheses to avoid bidi mirroring
|
||||
and writes the literal bare.
|
||||
- `migrationDeferred` names a navigation path ("Settings → Library → Sidecar Storage"), so each
|
||||
locale renders it with its **own** settings label and Library tab label
|
||||
(`common.actions.settings` + `settings.nav.library` + the new section label), using the same
|
||||
arrow and quoting style its other nav-path strings already use — zh-CN “设置 → 库 → …”,
|
||||
zh-TW/ja 「設定 > … > …」, ko `설정 → …` bare, fr/de/es bare
|
||||
(`Paramètres` / `Einstellungen` / `Configuración` → …), ru «Настройки → …»,
|
||||
he `הגדרות > …` bare (cf. `other.noPaths.descriptionStandalone`).
|
||||
- The migrate-button label is quoted inside `confirmToCentralized` / `confirmToAlongside` with
|
||||
each locale's UI-label quoting style (zh-CN “ ”, zh-TW/ja 「 」, ko `' '`, fr/ru/es/he « »,
|
||||
de „ “), matching `settings.sidecarStorage.migrateButton` verbatim so the two never drift.
|
||||
|
||||
### Filename Templates feature
|
||||
|
||||
Per-model-type templates that name downloaded model files; "Apply to Library Now"
|
||||
|
||||
@@ -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.
|
||||
@@ -11,6 +11,30 @@ This document defines the complete schema for `.metadata.json` files used by Lor
|
||||
|
||||
---
|
||||
|
||||
## Storage Location (Alongside vs Centralized)
|
||||
|
||||
By default, `.metadata.json` sidecars and preview images live **alongside** their model files. An optional centralized mode stores them under a single root directory instead. Two settings control this (Settings → Library → Sidecar Storage):
|
||||
|
||||
| Setting | Values | Default |
|
||||
|---------|--------|---------|
|
||||
| `sidecar_storage_mode` | `"alongside"` \| `"centralized"` | `"alongside"` |
|
||||
| `sidecar_storage_path` | Absolute path string; empty = `<settings dir>/sidecars` | `""` |
|
||||
|
||||
In centralized mode, sidecars and previews mirror the library-relative directory structure:
|
||||
|
||||
```
|
||||
<sidecar_root>/<library>/<root_basename-roothash>/<rel_dir>/<name>.metadata.json
|
||||
```
|
||||
|
||||
- `<library>` is the active library name and `<rel_dir>` the model's directory relative to the model root containing the file. `<root_basename-roothash>` combines the root's basename with a short hash of its full path so two roots sharing a basename (e.g. `/mnt/a/loras` and `/mnt/b/loras`) never collide. Each component is sanitized to filesystem-safe characters.
|
||||
- `.civitai.info` files always stay next to the model file, in both modes.
|
||||
- Changing the mode does **not** move existing files automatically — run the migration (`POST /api/lm/sidecars/migrate` with `{"direction": "to_centralized" | "to_alongside"}`, or the "Migrate Sidecars Now" button in settings). The migration covers excluded (hidden) models too, so un-excluding one later never strands its sidecar in the old layout. The result payload includes a `sidecar_root` field with the resolved centralized root, and the settings UI shows the outcome counters plus an "Open Folder" shortcut.
|
||||
- Changing `sidecar_storage_path` while centralized likewise needs a root relocation: `{"direction": "relocate_root", "old_root": "<previous path>"}` moves the whole mirror tree to the new root (the settings UI offers this automatically).
|
||||
- The settings UI always shows the resolved effective storage root (via the `sidecar_storage_root*` fields in `GET /api/lm/settings`), with `POST /api/lm/sidecars/open-location` opening it in the file manager. When the resolved root lies inside the plugin installation folder (portable settings mode), the UI warns: reinstalling or clean-updating the plugin would delete the sidecars, so an explicit path outside the installation folder is recommended. The repo `.gitignore` excludes the portable-mode default (`/sidecars/`).
|
||||
- All sidecar/preview path derivation goes through the helpers in `py/utils/sidecar_paths.py`; never construct paths inline.
|
||||
|
||||
---
|
||||
|
||||
## Base Fields (All Model Types)
|
||||
|
||||
These fields are present in all model metadata files.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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.",
|
||||
@@ -377,6 +383,7 @@
|
||||
"exampleImages": "Beispielbilder",
|
||||
"autoOrganize": "Auto-Organisierung",
|
||||
"metadata": "Metadaten",
|
||||
"sidecarStorage": "Sidecar-Speicherung",
|
||||
"proxySettings": "Proxy-Einstellungen"
|
||||
},
|
||||
"nav": {
|
||||
@@ -783,6 +790,35 @@
|
||||
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||
},
|
||||
"sidecarStorage": {
|
||||
"mode": "Sidecar-Speichermodus",
|
||||
"modeHelp": "Wählen Sie, wo .metadata.json-Sidecar-Dateien und Vorschaubilder gespeichert werden: neben jeder Modelldatei oder in einem einzelnen zentralen Verzeichnis, das Ihre Bibliotheksstruktur spiegelt. .civitai.info-Dateien bleiben immer neben der Modelldatei.",
|
||||
"modeOptions": {
|
||||
"alongside": "Neben den Modelldateien (Standard)",
|
||||
"centralized": "Zentrale Speicherung"
|
||||
},
|
||||
"path": "Pfad der zentralen Speicherung",
|
||||
"pathHelp": "Stammverzeichnis für die zentrale Sidecar-Speicherung. Leer lassen, um den Standardspeicherort (<settings dir>/sidecars) zu verwenden.",
|
||||
"pathPlaceholder": "Leer = <settings dir>/sidecars",
|
||||
"management": "Sidecar-Migration",
|
||||
"managementHelp": "Verschieben Sie vorhandene .metadata.json-Sidecar-Dateien und Vorschaubilder zwischen der Ablage neben den Modelldateien und der zentralen Speicherung, passend zum aktuell gewählten Modus. Ein Moduswechsel verschiebt vorhandene Dateien nicht automatisch.",
|
||||
"migrateButton": "Sidecar-Dateien jetzt verschieben",
|
||||
"migratingButton": "Wird verschoben...",
|
||||
"migrating": "Sidecar-Dateien werden verschoben...",
|
||||
"migrateSuccess": "Sidecar-Migration erfolgreich abgeschlossen",
|
||||
"migrateFailed": "Sidecar-Migration fehlgeschlagen: {message}",
|
||||
"migrationDeferred": "Vorhandene Sidecar-Dateien wurden nicht verschoben. Sie können sie später unter Einstellungen → Bibliothek → Sidecar-Speicherung verschieben.",
|
||||
"confirmToCentralized": "Der Speichermodus wurde geändert, aber vorhandene .metadata.json-Sidecar-Dateien und Vorschaubilder werden nicht automatisch verschoben. Jetzt in das zentrale Speicherverzeichnis verschieben? Sie können das auch später über die Schaltfläche „Sidecar-Dateien jetzt verschieben“ tun.",
|
||||
"confirmToAlongside": "Der Speichermodus wurde geändert, aber vorhandene .metadata.json-Sidecar-Dateien und Vorschaubilder werden nicht automatisch verschoben. Jetzt wieder neben ihre Modelldateien verschieben? Sie können das auch später über die Schaltfläche „Sidecar-Dateien jetzt verschieben“ tun.",
|
||||
"confirmRelocateRoot": "Das zentrale Speicherverzeichnis wurde geändert, aber vorhandene Sidecar-Dateien und Vorschaubilder liegen noch im vorherigen Verzeichnis. Jetzt in das neue Verzeichnis verschieben?",
|
||||
"effectivePathLabel": "[TODO: Translate] Effective storage location:",
|
||||
"openFolderButton": "[TODO: Translate] Open Folder",
|
||||
"repoWarning": "[TODO: Translate] The effective storage location is inside the LoRA Manager installation folder. Reinstalling the plugin or a clean update can delete it — set an explicit storage path outside the installation folder.",
|
||||
"openLocationSuccess": "[TODO: Translate] Opened sidecar storage folder",
|
||||
"openLocationCopied": "[TODO: Translate] Sidecar storage path copied to clipboard: {path}",
|
||||
"openLocationClipboardFallback": "[TODO: Translate] Copy the sidecar storage path manually: {path}",
|
||||
"openLocationFailed": "[TODO: Translate] Failed to open the sidecar storage folder"
|
||||
},
|
||||
"proxySettings": {
|
||||
"enableProxy": "App-Proxy aktivieren",
|
||||
"enableProxyHelp": "Aktivieren Sie benutzerdefinierte Proxy-Einstellungen für diese Anwendung. Überschreibt die System-Proxy-Einstellungen.",
|
||||
@@ -1344,6 +1380,10 @@
|
||||
"emptyNote": "Dieser Ordner enthält keine Modelle. Alle anderen darin enthaltenen Dateien werden ebenfalls gelöscht.",
|
||||
"notEmptyTitle": "Ordner ist nicht leer",
|
||||
"notEmptyMessage": "Dieser Ordner enthält noch Modelle. Löschen oder verschieben Sie diese zuerst — beim Löschen eines Ordners werden Modelldateien niemals mitgelöscht.",
|
||||
"notEmptyMessageCount": "Dieser Ordner enthält noch {count} Modelldatei(en). Löschen oder verschieben Sie diese zuerst — beim Löschen eines Ordners werden Modelldateien niemals mitgelöscht.",
|
||||
"notEmptyMessageExcluded": "Dieser Ordner enthält noch {count} Modelldatei(en), davon {excluded} von der Bibliothek ausgeschlossen. Heben Sie den Ausschluss unter „Ausgeschlossene Modelle verwalten“ auf und löschen Sie sie zuerst — beim Löschen eines Ordners werden Modelldateien niemals mitgelöscht.",
|
||||
"busyTitle": "Eine Löschung steht noch aus",
|
||||
"checking": "Ordnerinhalt wird geprüft...",
|
||||
"confirm": "Ordner löschen"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
@@ -1352,6 +1392,7 @@
|
||||
"restored": "Ordner wiederhergestellt",
|
||||
"failed": "Ordner konnte nicht gelöscht werden: {message}",
|
||||
"notEmpty": "Dieser Ordner enthält noch Modelle. Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut.",
|
||||
"notEmptyWithCount": "Dieser Ordner enthält noch {count} Modelldatei(en). Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut.",
|
||||
"busy": "In diesem Ordner steht noch eine Löschung aus. Warten Sie, bis das Zeitfenster für das Rückgängigmachen abgelaufen ist.",
|
||||
"unsupported": "Das Löschen von Ordnern wird auf dieser Seite nicht unterstützt",
|
||||
"noRoot": "Es ist kein Modell-Stammverzeichnis konfiguriert"
|
||||
@@ -1631,6 +1672,19 @@
|
||||
"titleRevert": "Ursprüngliche Dateinamen wiederherstellen?",
|
||||
"revertButton": "Ursprüngliche Dateinamen wiederherstellen"
|
||||
},
|
||||
"sidecarMigrationConfirm": {
|
||||
"titleToCentralized": "Sidecar-Dateien in die zentrale Speicherung verschieben?",
|
||||
"titleToAlongside": "Sidecar-Dateien zurück neben die Modelldateien verschieben?",
|
||||
"confirmButton": "Jetzt verschieben",
|
||||
"titleRelocateRoot": "Sidecar-Dateien in das neue Speicherverzeichnis verschieben?",
|
||||
"destination": "[TODO: Translate] Destination: {path}"
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "[TODO: Translate] Sidecar migration completed",
|
||||
"titleWithErrors": "[TODO: Translate] Sidecar migration completed with {count} error(s)",
|
||||
"summary": "[TODO: Translate] Moved {moved} files for {models} models. Skipped: {skipped}, conflicts resolved: {conflicts}.",
|
||||
"location": "[TODO: Translate] Storage location: {path}"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Tags zu mehreren Modellen hinzufügen",
|
||||
"description": "Tags hinzufügen zu",
|
||||
|
||||
@@ -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.",
|
||||
@@ -377,6 +383,7 @@
|
||||
"exampleImages": "Example Images",
|
||||
"autoOrganize": "Auto-organize",
|
||||
"metadata": "Metadata",
|
||||
"sidecarStorage": "Sidecar Storage",
|
||||
"proxySettings": "Proxy Settings"
|
||||
},
|
||||
"nav": {
|
||||
@@ -783,6 +790,35 @@
|
||||
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||
},
|
||||
"sidecarStorage": {
|
||||
"mode": "Sidecar Storage Mode",
|
||||
"modeHelp": "Choose where .metadata.json sidecars and preview images are stored: next to each model file, or in a single centralized directory that mirrors your library structure. .civitai.info files always stay next to the model file.",
|
||||
"modeOptions": {
|
||||
"alongside": "Alongside model files (default)",
|
||||
"centralized": "Centralized storage"
|
||||
},
|
||||
"path": "Centralized Storage Path",
|
||||
"pathHelp": "Root directory for centralized sidecar storage. Leave empty to use the default location (<settings dir>/sidecars).",
|
||||
"pathPlaceholder": "Empty = <settings dir>/sidecars",
|
||||
"management": "Sidecar Migration",
|
||||
"managementHelp": "Move existing .metadata.json sidecars and preview images between alongside and centralized storage, matching the currently selected mode. Changing the mode does not move existing files automatically.",
|
||||
"migrateButton": "Migrate Sidecars Now",
|
||||
"migratingButton": "Migrating...",
|
||||
"migrating": "Migrating sidecars...",
|
||||
"migrateSuccess": "Sidecar migration completed successfully",
|
||||
"migrateFailed": "Sidecar migration failed: {message}",
|
||||
"migrationDeferred": "Existing sidecars were not moved. You can migrate them later from Settings → Library → Sidecar Storage.",
|
||||
"confirmToCentralized": "The storage mode changed, but existing .metadata.json sidecars and preview images are not moved automatically. Move them into the centralized storage directory now? You can also do this later with the \"Migrate Sidecars Now\" button.",
|
||||
"confirmToAlongside": "The storage mode changed, but existing .metadata.json sidecars and preview images are not moved automatically. Move them back next to their model files now? You can also do this later with the \"Migrate Sidecars Now\" button.",
|
||||
"confirmRelocateRoot": "The centralized storage directory changed, but existing sidecars and preview images are still in the previous directory. Move them to the new directory now?",
|
||||
"effectivePathLabel": "Effective storage location:",
|
||||
"openFolderButton": "Open Folder",
|
||||
"repoWarning": "The effective storage location is inside the LoRA Manager installation folder. Reinstalling the plugin or a clean update can delete it — set an explicit storage path outside the installation folder.",
|
||||
"openLocationSuccess": "Opened sidecar storage folder",
|
||||
"openLocationCopied": "Sidecar storage path copied to clipboard: {path}",
|
||||
"openLocationClipboardFallback": "Copy the sidecar storage path manually: {path}",
|
||||
"openLocationFailed": "Failed to open the sidecar storage folder"
|
||||
},
|
||||
"proxySettings": {
|
||||
"enableProxy": "Enable App-level Proxy",
|
||||
"enableProxyHelp": "Enable custom proxy settings for this application, overriding system proxy settings",
|
||||
@@ -1344,6 +1380,10 @@
|
||||
"emptyNote": "This folder contains no models. Any other files it holds will be deleted too.",
|
||||
"notEmptyTitle": "Folder is not empty",
|
||||
"notEmptyMessage": "This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.",
|
||||
"notEmptyMessageCount": "This folder still contains {count} model file(s). Delete or move them first — deleting a folder never cascades over model files.",
|
||||
"notEmptyMessageExcluded": "This folder still contains {count} model file(s), {excluded} of them excluded from the library. Un-exclude them in Manage Excluded Models and delete them first — deleting a folder never cascades over model files.",
|
||||
"busyTitle": "A deletion is still pending",
|
||||
"checking": "Checking the folder contents...",
|
||||
"confirm": "Delete folder"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
@@ -1352,6 +1392,7 @@
|
||||
"restored": "Folder restored",
|
||||
"failed": "Failed to delete folder: {message}",
|
||||
"notEmpty": "This folder still contains models. Refresh the sidebar and try again.",
|
||||
"notEmptyWithCount": "This folder still contains {count} model file(s). Refresh the sidebar and try again.",
|
||||
"busy": "A deletion is still pending inside this folder. Wait for the undo window to expire.",
|
||||
"unsupported": "Folder deletion is not supported on this page",
|
||||
"noRoot": "No model root is configured"
|
||||
@@ -1631,6 +1672,19 @@
|
||||
"titleRevert": "Restore original filenames?",
|
||||
"revertButton": "Restore Original Filenames"
|
||||
},
|
||||
"sidecarMigrationConfirm": {
|
||||
"titleToCentralized": "Move sidecars to centralized storage?",
|
||||
"titleToAlongside": "Move sidecars back next to model files?",
|
||||
"confirmButton": "Migrate Now",
|
||||
"titleRelocateRoot": "Move sidecars to the new storage directory?",
|
||||
"destination": "Destination: {path}"
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "Sidecar migration completed",
|
||||
"titleWithErrors": "Sidecar migration completed with {count} error(s)",
|
||||
"summary": "Moved {moved} files for {models} models. Skipped: {skipped}, conflicts resolved: {conflicts}.",
|
||||
"location": "Storage location: {path}"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Add Tags to Multiple Models",
|
||||
"description": "Add tags to",
|
||||
|
||||
@@ -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\".",
|
||||
@@ -377,6 +383,7 @@
|
||||
"exampleImages": "Imágenes de ejemplo",
|
||||
"autoOrganize": "Organización automática",
|
||||
"metadata": "Metadatos",
|
||||
"sidecarStorage": "Almacenamiento de archivos sidecar",
|
||||
"proxySettings": "Configuración de proxy"
|
||||
},
|
||||
"nav": {
|
||||
@@ -783,6 +790,35 @@
|
||||
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||
},
|
||||
"sidecarStorage": {
|
||||
"mode": "Modo de almacenamiento de archivos sidecar",
|
||||
"modeHelp": "Elige dónde se guardan los archivos sidecar .metadata.json y las imágenes de vista previa: junto a cada archivo de modelo, o en un único directorio centralizado que refleja la estructura de tu biblioteca. Los archivos .civitai.info siempre permanecen junto al archivo de modelo.",
|
||||
"modeOptions": {
|
||||
"alongside": "Junto a los archivos de modelo (predeterminado)",
|
||||
"centralized": "Almacenamiento centralizado"
|
||||
},
|
||||
"path": "Ruta del almacenamiento centralizado",
|
||||
"pathHelp": "Carpeta raíz del almacenamiento centralizado de archivos sidecar. Déjalo vacío para usar la ubicación predeterminada (<settings dir>/sidecars).",
|
||||
"pathPlaceholder": "Vacío = <settings dir>/sidecars",
|
||||
"management": "Migración de archivos sidecar",
|
||||
"managementHelp": "Mueve los archivos sidecar .metadata.json y las imágenes de vista previa existentes entre el almacenamiento junto a los modelos y el almacenamiento centralizado, según el modo seleccionado. Cambiar el modo no mueve los archivos existentes automáticamente.",
|
||||
"migrateButton": "Migrar archivos sidecar ahora",
|
||||
"migratingButton": "Migrando...",
|
||||
"migrating": "Migrando archivos sidecar...",
|
||||
"migrateSuccess": "Migración de archivos sidecar completada",
|
||||
"migrateFailed": "Error al migrar los archivos sidecar: {message}",
|
||||
"migrationDeferred": "Los archivos sidecar existentes no se movieron. Puedes migrarlos más tarde desde Configuración → Biblioteca → Almacenamiento de archivos sidecar.",
|
||||
"confirmToCentralized": "El modo de almacenamiento ha cambiado, pero los archivos sidecar .metadata.json y las imágenes de vista previa existentes no se mueven automáticamente. ¿Moverlos ahora al directorio de almacenamiento centralizado? También puedes hacerlo más tarde con el botón «Migrar archivos sidecar ahora».",
|
||||
"confirmToAlongside": "El modo de almacenamiento ha cambiado, pero los archivos sidecar .metadata.json y las imágenes de vista previa existentes no se mueven automáticamente. ¿Devolverlos ahora junto a sus archivos de modelo? También puedes hacerlo más tarde con el botón «Migrar archivos sidecar ahora».",
|
||||
"confirmRelocateRoot": "El directorio de almacenamiento centralizado ha cambiado, pero los archivos sidecar y las imágenes de vista previa existentes siguen en el directorio anterior. ¿Moverlos ahora al nuevo directorio?",
|
||||
"effectivePathLabel": "[TODO: Translate] Effective storage location:",
|
||||
"openFolderButton": "[TODO: Translate] Open Folder",
|
||||
"repoWarning": "[TODO: Translate] The effective storage location is inside the LoRA Manager installation folder. Reinstalling the plugin or a clean update can delete it — set an explicit storage path outside the installation folder.",
|
||||
"openLocationSuccess": "[TODO: Translate] Opened sidecar storage folder",
|
||||
"openLocationCopied": "[TODO: Translate] Sidecar storage path copied to clipboard: {path}",
|
||||
"openLocationClipboardFallback": "[TODO: Translate] Copy the sidecar storage path manually: {path}",
|
||||
"openLocationFailed": "[TODO: Translate] Failed to open the sidecar storage folder"
|
||||
},
|
||||
"proxySettings": {
|
||||
"enableProxy": "Habilitar proxy a nivel de aplicación",
|
||||
"enableProxyHelp": "Habilita la configuración de proxy personalizada para esta aplicación, sobrescribiendo la configuración de proxy del sistema",
|
||||
@@ -1344,6 +1380,10 @@
|
||||
"emptyNote": "Esta carpeta no contiene modelos. Los demás archivos que contenga también se eliminarán.",
|
||||
"notEmptyTitle": "La carpeta no está vacía",
|
||||
"notEmptyMessage": "Esta carpeta aún contiene modelos. Elimínalos o muévelos primero — eliminar una carpeta nunca elimina los archivos de modelo en cascada.",
|
||||
"notEmptyMessageCount": "Esta carpeta aún contiene {count} archivo(s) de modelo. Elimínalos o muévelos primero — eliminar una carpeta nunca elimina los archivos de modelo en cascada.",
|
||||
"notEmptyMessageExcluded": "Esta carpeta aún contiene {count} archivo(s) de modelo, de los cuales {excluded} están excluidos de la biblioteca. Anula su exclusión en «Gestionar modelos excluidos» y elimínalos primero — eliminar una carpeta nunca elimina los archivos de modelo en cascada.",
|
||||
"busyTitle": "Todavía hay una eliminación pendiente",
|
||||
"checking": "Comprobando el contenido de la carpeta...",
|
||||
"confirm": "Eliminar carpeta"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
@@ -1352,6 +1392,7 @@
|
||||
"restored": "Carpeta restaurada",
|
||||
"failed": "Error al eliminar la carpeta: {message}",
|
||||
"notEmpty": "Esta carpeta aún contiene modelos. Actualiza la barra lateral e inténtalo de nuevo.",
|
||||
"notEmptyWithCount": "Esta carpeta aún contiene {count} archivo(s) de modelo. Actualiza la barra lateral e inténtalo de nuevo.",
|
||||
"busy": "Todavía hay una eliminación pendiente dentro de esta carpeta. Espera a que caduque la ventana de deshacer.",
|
||||
"unsupported": "La eliminación de carpetas no es compatible con esta página",
|
||||
"noRoot": "No hay ninguna raíz de modelo configurada"
|
||||
@@ -1631,6 +1672,19 @@
|
||||
"titleRevert": "¿Restaurar los nombres de archivo originales?",
|
||||
"revertButton": "Restaurar nombres de archivo originales"
|
||||
},
|
||||
"sidecarMigrationConfirm": {
|
||||
"titleToCentralized": "¿Mover los archivos sidecar al almacenamiento centralizado?",
|
||||
"titleToAlongside": "¿Devolver los archivos sidecar junto a los archivos de modelo?",
|
||||
"confirmButton": "Migrar ahora",
|
||||
"titleRelocateRoot": "¿Mover los archivos sidecar al nuevo directorio de almacenamiento?",
|
||||
"destination": "[TODO: Translate] Destination: {path}"
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "[TODO: Translate] Sidecar migration completed",
|
||||
"titleWithErrors": "[TODO: Translate] Sidecar migration completed with {count} error(s)",
|
||||
"summary": "[TODO: Translate] Moved {moved} files for {models} models. Skipped: {skipped}, conflicts resolved: {conflicts}.",
|
||||
"location": "[TODO: Translate] Storage location: {path}"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Añadir etiquetas a múltiples modelos",
|
||||
"description": "Añadir etiquetas a",
|
||||
|
||||
@@ -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 ».",
|
||||
@@ -377,6 +383,7 @@
|
||||
"exampleImages": "Images d'exemple",
|
||||
"autoOrganize": "Organisation automatique",
|
||||
"metadata": "Métadonnées",
|
||||
"sidecarStorage": "Stockage des fichiers sidecar",
|
||||
"proxySettings": "Paramètres du proxy"
|
||||
},
|
||||
"nav": {
|
||||
@@ -783,6 +790,35 @@
|
||||
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||
},
|
||||
"sidecarStorage": {
|
||||
"mode": "Mode de stockage des fichiers sidecar",
|
||||
"modeHelp": "Choisissez où sont stockés les fichiers sidecar .metadata.json et les images d’aperçu : à côté de chaque fichier de modèle, ou dans un seul dossier centralisé qui reflète la structure de votre bibliothèque. Les fichiers .civitai.info restent toujours à côté du fichier de modèle.",
|
||||
"modeOptions": {
|
||||
"alongside": "À côté des fichiers de modèle (par défaut)",
|
||||
"centralized": "Stockage centralisé"
|
||||
},
|
||||
"path": "Chemin du stockage centralisé",
|
||||
"pathHelp": "Dossier racine du stockage centralisé des fichiers sidecar. Laissez vide pour utiliser l’emplacement par défaut (<settings dir>/sidecars).",
|
||||
"pathPlaceholder": "Vide = <settings dir>/sidecars",
|
||||
"management": "Migration des fichiers sidecar",
|
||||
"managementHelp": "Déplacez les fichiers sidecar .metadata.json et les images d’aperçu existants entre le stockage à côté des modèles et le stockage centralisé, selon le mode sélectionné. Changer de mode ne déplace pas les fichiers existants automatiquement.",
|
||||
"migrateButton": "Migrer les fichiers sidecar maintenant",
|
||||
"migratingButton": "Migration en cours...",
|
||||
"migrating": "Migration des fichiers sidecar en cours...",
|
||||
"migrateSuccess": "Migration des fichiers sidecar terminée",
|
||||
"migrateFailed": "Échec de la migration des fichiers sidecar : {message}",
|
||||
"migrationDeferred": "Les fichiers sidecar existants n’ont pas été déplacés. Vous pouvez les migrer plus tard depuis Paramètres → Bibliothèque → Stockage des fichiers sidecar.",
|
||||
"confirmToCentralized": "Le mode de stockage a changé, mais les fichiers sidecar .metadata.json et les images d’aperçu existants ne sont pas déplacés automatiquement. Les déplacer maintenant dans le dossier de stockage centralisé ? Vous pouvez aussi le faire plus tard avec le bouton « Migrer les fichiers sidecar maintenant ».",
|
||||
"confirmToAlongside": "Le mode de stockage a changé, mais les fichiers sidecar .metadata.json et les images d’aperçu existants ne sont pas déplacés automatiquement. Les remettre maintenant à côté de leurs fichiers de modèle ? Vous pouvez aussi le faire plus tard avec le bouton « Migrer les fichiers sidecar maintenant ».",
|
||||
"confirmRelocateRoot": "Le dossier de stockage centralisé a changé, mais les fichiers sidecar et les images d’aperçu existants se trouvent encore dans le dossier précédent. Les déplacer maintenant dans le nouveau dossier ?",
|
||||
"effectivePathLabel": "[TODO: Translate] Effective storage location:",
|
||||
"openFolderButton": "[TODO: Translate] Open Folder",
|
||||
"repoWarning": "[TODO: Translate] The effective storage location is inside the LoRA Manager installation folder. Reinstalling the plugin or a clean update can delete it — set an explicit storage path outside the installation folder.",
|
||||
"openLocationSuccess": "[TODO: Translate] Opened sidecar storage folder",
|
||||
"openLocationCopied": "[TODO: Translate] Sidecar storage path copied to clipboard: {path}",
|
||||
"openLocationClipboardFallback": "[TODO: Translate] Copy the sidecar storage path manually: {path}",
|
||||
"openLocationFailed": "[TODO: Translate] Failed to open the sidecar storage folder"
|
||||
},
|
||||
"proxySettings": {
|
||||
"enableProxy": "Activer le proxy au niveau de l'application",
|
||||
"enableProxyHelp": "Activer les paramètres de proxy personnalisés pour cette application, remplaçant les paramètres de proxy système",
|
||||
@@ -1344,6 +1380,10 @@
|
||||
"emptyNote": "Ce dossier ne contient aucun modèle. Les autres fichiers qu’il contient seront également supprimés.",
|
||||
"notEmptyTitle": "Le dossier n’est pas vide",
|
||||
"notEmptyMessage": "Ce dossier contient encore des modèles. Supprimez-les ou déplacez-les d’abord — la suppression d’un dossier n’entraîne jamais celle des fichiers de modèles.",
|
||||
"notEmptyMessageCount": "Ce dossier contient encore {count} fichier(s) de modèle. Supprimez-les ou déplacez-les d’abord — la suppression d’un dossier n’entraîne jamais celle des fichiers de modèles.",
|
||||
"notEmptyMessageExcluded": "Ce dossier contient encore {count} fichier(s) de modèle, dont {excluded} sont exclus de la bibliothèque. Annulez leur exclusion dans « Gérer les modèles exclus » et supprimez-les d’abord — la suppression d’un dossier n’entraîne jamais celle des fichiers de modèles.",
|
||||
"busyTitle": "Une suppression est encore en attente",
|
||||
"checking": "Vérification du contenu du dossier...",
|
||||
"confirm": "Supprimer le dossier"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
@@ -1352,6 +1392,7 @@
|
||||
"restored": "Dossier restauré",
|
||||
"failed": "Échec de la suppression du dossier : {message}",
|
||||
"notEmpty": "Ce dossier contient encore des modèles. Actualisez la barre latérale et réessayez.",
|
||||
"notEmptyWithCount": "Ce dossier contient encore {count} fichier(s) de modèle. Actualisez la barre latérale et réessayez.",
|
||||
"busy": "Une suppression est encore en attente dans ce dossier. Attendez la fin de la fenêtre d’annulation.",
|
||||
"unsupported": "La suppression de dossiers n’est pas prise en charge sur cette page",
|
||||
"noRoot": "Aucune racine de modèle n’est configurée"
|
||||
@@ -1631,6 +1672,19 @@
|
||||
"titleRevert": "Restaurer les noms de fichier d'origine ?",
|
||||
"revertButton": "Restaurer les noms de fichier d'origine"
|
||||
},
|
||||
"sidecarMigrationConfirm": {
|
||||
"titleToCentralized": "Déplacer les fichiers sidecar vers le stockage centralisé ?",
|
||||
"titleToAlongside": "Remettre les fichiers sidecar à côté des fichiers de modèle ?",
|
||||
"confirmButton": "Migrer maintenant",
|
||||
"titleRelocateRoot": "Déplacer les fichiers sidecar vers le nouveau dossier de stockage ?",
|
||||
"destination": "[TODO: Translate] Destination: {path}"
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "[TODO: Translate] Sidecar migration completed",
|
||||
"titleWithErrors": "[TODO: Translate] Sidecar migration completed with {count} error(s)",
|
||||
"summary": "[TODO: Translate] Moved {moved} files for {models} models. Skipped: {skipped}, conflicts resolved: {conflicts}.",
|
||||
"location": "[TODO: Translate] Storage location: {path}"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Ajouter des tags à plusieurs modèles",
|
||||
"description": "Ajouter des tags à",
|
||||
|
||||
@@ -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\".",
|
||||
@@ -377,6 +383,7 @@
|
||||
"exampleImages": "תמונות דוגמה",
|
||||
"autoOrganize": "ארגון אוטומטי",
|
||||
"metadata": "מטא-נתונים",
|
||||
"sidecarStorage": "אחסון קובצי לוואי",
|
||||
"proxySettings": "הגדרות פרוקסי"
|
||||
},
|
||||
"nav": {
|
||||
@@ -783,6 +790,35 @@
|
||||
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||
},
|
||||
"sidecarStorage": {
|
||||
"mode": "מצב אחסון קובצי לוואי",
|
||||
"modeHelp": "בחר היכן יאוחסנו קובצי הלוואי .metadata.json ותמונות התצוגה המקדימה: לצד כל קובץ מודל, או בתיקייה מרכזית אחת שמשקפת את מבנה הספרייה שלך. קובצי .civitai.info נשארים תמיד לצד קובץ המודל.",
|
||||
"modeOptions": {
|
||||
"alongside": "לצד קובצי המודלים (ברירת מחדל)",
|
||||
"centralized": "אחסון מרכזי"
|
||||
},
|
||||
"path": "נתיב האחסון המרכזי",
|
||||
"pathHelp": "תיקיית השורש של אחסון קובצי הלוואי המרכזי. השאר ריק כדי להשתמש במיקום ברירת המחדל <settings dir>/sidecars.",
|
||||
"pathPlaceholder": "ריק = <settings dir>/sidecars",
|
||||
"management": "העברת קובצי לוואי",
|
||||
"managementHelp": "העבר קובצי לוואי .metadata.json ותמונות תצוגה מקדימה קיימים בין אחסון לצד המודלים לאחסון המרכזי, בהתאם למצב שנבחר. שינוי המצב אינו מעביר קבצים קיימים באופן אוטומטי.",
|
||||
"migrateButton": "העבר קובצי לוואי כעת",
|
||||
"migratingButton": "מעביר...",
|
||||
"migrating": "מעביר קובצי לוואי...",
|
||||
"migrateSuccess": "העברת קובצי הלוואי הושלמה בהצלחה",
|
||||
"migrateFailed": "העברת קובצי הלוואי נכשלה: {message}",
|
||||
"migrationDeferred": "קובצי הלוואי הקיימים לא הועברו. ניתן להעביר אותם מאוחר יותר דרך הגדרות > ספרייה > אחסון קובצי לוואי.",
|
||||
"confirmToCentralized": "מצב האחסון השתנה, אך קובצי הלוואי .metadata.json ותמונות התצוגה המקדימה הקיימים אינם מועברים אוטומטית. להעביר אותם כעת לתיקיית האחסון המרכזי? ניתן לעשות זאת גם מאוחר יותר באמצעות הכפתור «העבר קובצי לוואי כעת».",
|
||||
"confirmToAlongside": "מצב האחסון השתנה, אך קובצי הלוואי .metadata.json ותמונות התצוגה המקדימה הקיימים אינם מועברים אוטומטית. להחזיר אותם כעת לצד קובצי המודל שלהם? ניתן לעשות זאת גם מאוחר יותר באמצעות הכפתור «העבר קובצי לוואי כעת».",
|
||||
"confirmRelocateRoot": "תיקיית האחסון המרכזי השתנתה, אך קובצי הלוואי ותמונות התצוגה המקדימה הקיימים עדיין נמצאים בתיקייה הקודמת. להעביר אותם כעת לתיקייה החדשה?",
|
||||
"effectivePathLabel": "[TODO: Translate] Effective storage location:",
|
||||
"openFolderButton": "[TODO: Translate] Open Folder",
|
||||
"repoWarning": "[TODO: Translate] The effective storage location is inside the LoRA Manager installation folder. Reinstalling the plugin or a clean update can delete it — set an explicit storage path outside the installation folder.",
|
||||
"openLocationSuccess": "[TODO: Translate] Opened sidecar storage folder",
|
||||
"openLocationCopied": "[TODO: Translate] Sidecar storage path copied to clipboard: {path}",
|
||||
"openLocationClipboardFallback": "[TODO: Translate] Copy the sidecar storage path manually: {path}",
|
||||
"openLocationFailed": "[TODO: Translate] Failed to open the sidecar storage folder"
|
||||
},
|
||||
"proxySettings": {
|
||||
"enableProxy": "הפעל פרוקסי ברמת האפליקציה",
|
||||
"enableProxyHelp": "אפשר הגדרות פרוקסי מותאמות אישית עבור יישום זה, במקום הגדרות הפרוקסי של המערכת",
|
||||
@@ -1344,6 +1380,10 @@
|
||||
"emptyNote": "אין מודלים בתיקייה זו. קבצים אחרים שבה יימחקו גם הם.",
|
||||
"notEmptyTitle": "התיקייה אינה ריקה",
|
||||
"notEmptyMessage": "בתיקייה זו עדיין יש מודלים. מחק או העבר אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.",
|
||||
"notEmptyMessageCount": "בתיקייה זו עדיין יש {count} קובצי מודלים. מחק או העבר אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.",
|
||||
"notEmptyMessageExcluded": "בתיקייה זו עדיין יש {count} קובצי מודלים, שמהם {excluded} מוחרגים מהספרייה. בטל את ההחרגה ב«ניהול מודלים מוחרגים» ומחק אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.",
|
||||
"busyTitle": "מחיקה עדיין ממתינה",
|
||||
"checking": "בודק את תוכן התיקייה...",
|
||||
"confirm": "מחק תיקייה"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
@@ -1352,6 +1392,7 @@
|
||||
"restored": "התיקייה שוחזרה",
|
||||
"failed": "מחיקת התיקייה נכשלה: {message}",
|
||||
"notEmpty": "בתיקייה זו עדיין יש מודלים. רענן את סרגל הצד ונסה שוב.",
|
||||
"notEmptyWithCount": "בתיקייה זו עדיין יש {count} קובצי מודלים. רענן את סרגל הצד ונסה שוב.",
|
||||
"busy": "מחיקה עדיין ממתינה בתיקייה זו. המתן לסיום חלון הביטול.",
|
||||
"unsupported": "מחיקת תיקיות אינה נתמכת בדף זה",
|
||||
"noRoot": "לא הוגדר שורש מודלים"
|
||||
@@ -1631,6 +1672,19 @@
|
||||
"titleRevert": "לשחזר שמות קבצים מקוריים?",
|
||||
"revertButton": "שחזר שמות קבצים מקוריים"
|
||||
},
|
||||
"sidecarMigrationConfirm": {
|
||||
"titleToCentralized": "להעביר את קובצי הלוואי לאחסון המרכזי?",
|
||||
"titleToAlongside": "להחזיר את קובצי הלוואי לצד קובצי המודל?",
|
||||
"confirmButton": "העבר כעת",
|
||||
"titleRelocateRoot": "להעביר את קובצי הלוואי לתיקיית האחסון החדשה?",
|
||||
"destination": "[TODO: Translate] Destination: {path}"
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "[TODO: Translate] Sidecar migration completed",
|
||||
"titleWithErrors": "[TODO: Translate] Sidecar migration completed with {count} error(s)",
|
||||
"summary": "[TODO: Translate] Moved {moved} files for {models} models. Skipped: {skipped}, conflicts resolved: {conflicts}.",
|
||||
"location": "[TODO: Translate] Storage location: {path}"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "הוסף תגיות למספר מודלים",
|
||||
"description": "הוסף תגיות ל-",
|
||||
|
||||
@@ -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 サイトを選択します。",
|
||||
@@ -377,6 +383,7 @@
|
||||
"exampleImages": "例画像",
|
||||
"autoOrganize": "自動整理",
|
||||
"metadata": "メタデータ",
|
||||
"sidecarStorage": "サイドカーファイルの保存",
|
||||
"proxySettings": "プロキシ設定"
|
||||
},
|
||||
"nav": {
|
||||
@@ -783,6 +790,35 @@
|
||||
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||
},
|
||||
"sidecarStorage": {
|
||||
"mode": "サイドカーファイルの保存モード",
|
||||
"modeHelp": ".metadata.json サイドカーファイルとプレビュー画像の保存先を選択します。各モデルファイルの隣に保存するか、ライブラリ構造をミラーリングした単一の集中ディレクトリに保存します。.civitai.info ファイルは常にモデルファイルの隣に置かれます。",
|
||||
"modeOptions": {
|
||||
"alongside": "モデルファイルの隣(デフォルト)",
|
||||
"centralized": "集中保存"
|
||||
},
|
||||
"path": "集中保存先パス",
|
||||
"pathHelp": "サイドカーファイルを集中保存するルートディレクトリ。空欄の場合は既定の場所(<settings dir>/sidecars)を使用します。",
|
||||
"pathPlaceholder": "空欄 = <settings dir>/sidecars",
|
||||
"management": "サイドカーファイルの移動",
|
||||
"managementHelp": "既存の .metadata.json サイドカーファイルとプレビュー画像を、現在選択されているモードに合わせて「モデルファイルの隣」と「集中保存」の間で移動します。モードを変更しても既存ファイルは自動では移動しません。",
|
||||
"migrateButton": "今すぐサイドカーファイルを移動",
|
||||
"migratingButton": "移動しています...",
|
||||
"migrating": "サイドカーファイルを移動しています...",
|
||||
"migrateSuccess": "サイドカーファイルの移動が完了しました",
|
||||
"migrateFailed": "サイドカーファイルの移動に失敗しました:{message}",
|
||||
"migrationDeferred": "既存のサイドカーファイルは移動されませんでした。後で「設定 > ライブラリ > サイドカーファイルの保存」から移動できます。",
|
||||
"confirmToCentralized": "保存モードが変更されましたが、既存の .metadata.json サイドカーファイルとプレビュー画像は自動では移動しません。今すぐ集中保存ディレクトリに移動しますか?「今すぐサイドカーファイルを移動」ボタンで後から実行することもできます。",
|
||||
"confirmToAlongside": "保存モードが変更されましたが、既存の .metadata.json サイドカーファイルとプレビュー画像は自動では移動しません。今すぐ各モデルファイルの隣に戻しますか?「今すぐサイドカーファイルを移動」ボタンで後から実行することもできます。",
|
||||
"confirmRelocateRoot": "集中保存ディレクトリが変更されましたが、既存のサイドカーファイルとプレビュー画像はまだ以前のディレクトリにあります。今すぐ新しいディレクトリに移動しますか?",
|
||||
"effectivePathLabel": "[TODO: Translate] Effective storage location:",
|
||||
"openFolderButton": "[TODO: Translate] Open Folder",
|
||||
"repoWarning": "[TODO: Translate] The effective storage location is inside the LoRA Manager installation folder. Reinstalling the plugin or a clean update can delete it — set an explicit storage path outside the installation folder.",
|
||||
"openLocationSuccess": "[TODO: Translate] Opened sidecar storage folder",
|
||||
"openLocationCopied": "[TODO: Translate] Sidecar storage path copied to clipboard: {path}",
|
||||
"openLocationClipboardFallback": "[TODO: Translate] Copy the sidecar storage path manually: {path}",
|
||||
"openLocationFailed": "[TODO: Translate] Failed to open the sidecar storage folder"
|
||||
},
|
||||
"proxySettings": {
|
||||
"enableProxy": "アプリレベルのプロキシを有効化",
|
||||
"enableProxyHelp": "このアプリケーション専用のカスタムプロキシ設定を有効にします(システムのプロキシ設定を上書きします)",
|
||||
@@ -1344,6 +1380,10 @@
|
||||
"emptyNote": "このフォルダにはモデルがありません。他のファイルもすべて削除されます。",
|
||||
"notEmptyTitle": "フォルダが空ではありません",
|
||||
"notEmptyMessage": "このフォルダにはまだモデルがあります。先に削除するか移動してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。",
|
||||
"notEmptyMessageCount": "このフォルダにはまだ {count} 個のモデルファイルがあります。先に削除するか移動してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。",
|
||||
"notEmptyMessageExcluded": "このフォルダにはまだ {count} 個のモデルファイルがあり、そのうち {excluded} 個はライブラリから除外されています。「除外モデルを管理」で除外を解除してから削除してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。",
|
||||
"busyTitle": "保留中の削除があります",
|
||||
"checking": "フォルダの内容を確認しています...",
|
||||
"confirm": "フォルダを削除"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
@@ -1352,6 +1392,7 @@
|
||||
"restored": "フォルダを復元しました",
|
||||
"failed": "フォルダの削除に失敗しました: {message}",
|
||||
"notEmpty": "このフォルダにはまだモデルがあります。サイドバーを再読み込みしてからもう一度お試しください。",
|
||||
"notEmptyWithCount": "このフォルダにはまだ {count} 個のモデルファイルがあります。サイドバーを再読み込みしてからもう一度お試しください。",
|
||||
"busy": "このフォルダ内に保留中の削除があります。取り消し可能な時間が過ぎるまでお待ちください。",
|
||||
"unsupported": "このページではフォルダを削除できません",
|
||||
"noRoot": "モデルルートが設定されていません"
|
||||
@@ -1631,6 +1672,19 @@
|
||||
"titleRevert": "元のファイル名を復元しますか?",
|
||||
"revertButton": "元のファイル名を復元"
|
||||
},
|
||||
"sidecarMigrationConfirm": {
|
||||
"titleToCentralized": "サイドカーファイルを集中保存に移動しますか?",
|
||||
"titleToAlongside": "サイドカーファイルをモデルファイルの隣に戻しますか?",
|
||||
"confirmButton": "今すぐ移動",
|
||||
"titleRelocateRoot": "サイドカーファイルを新しい保存ディレクトリに移動しますか?",
|
||||
"destination": "[TODO: Translate] Destination: {path}"
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "[TODO: Translate] Sidecar migration completed",
|
||||
"titleWithErrors": "[TODO: Translate] Sidecar migration completed with {count} error(s)",
|
||||
"summary": "[TODO: Translate] Moved {moved} files for {models} models. Skipped: {skipped}, conflicts resolved: {conflicts}.",
|
||||
"location": "[TODO: Translate] Storage location: {path}"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "複数モデルにタグを追加",
|
||||
"description": "タグを追加するモデル:",
|
||||
|
||||
@@ -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 사이트를 열지 선택합니다.",
|
||||
@@ -377,6 +383,7 @@
|
||||
"exampleImages": "예시 이미지",
|
||||
"autoOrganize": "자동 정리",
|
||||
"metadata": "메타데이터",
|
||||
"sidecarStorage": "사이드카 파일 저장",
|
||||
"proxySettings": "프록시 설정"
|
||||
},
|
||||
"nav": {
|
||||
@@ -783,6 +790,35 @@
|
||||
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||
},
|
||||
"sidecarStorage": {
|
||||
"mode": "사이드카 파일 저장 모드",
|
||||
"modeHelp": ".metadata.json 사이드카 파일과 미리보기 이미지를 저장할 위치를 선택하세요. 각 모델 파일 옆에 저장하거나, 라이브러리 구조를 미러링하는 단일 중앙 집중식 디렉터리에 저장할 수 있습니다. .civitai.info 파일은 항상 모델 파일 옆에 남습니다.",
|
||||
"modeOptions": {
|
||||
"alongside": "모델 파일 옆 (기본값)",
|
||||
"centralized": "중앙 집중식 저장"
|
||||
},
|
||||
"path": "중앙 집중식 저장 경로",
|
||||
"pathHelp": "사이드카 파일을 중앙 집중식으로 저장할 루트 디렉터리입니다. 비워 두면 기본 위치(<settings dir>/sidecars)를 사용합니다.",
|
||||
"pathPlaceholder": "비움 = <settings dir>/sidecars",
|
||||
"management": "사이드카 파일 이동",
|
||||
"managementHelp": "기존 .metadata.json 사이드카 파일과 미리보기 이미지를 현재 선택한 모드에 맞게 '모델 파일 옆'과 '중앙 집중식 저장' 사이에서 이동합니다. 모드를 변경해도 기존 파일은 자동으로 이동되지 않습니다.",
|
||||
"migrateButton": "지금 사이드카 파일 이동",
|
||||
"migratingButton": "이동 중...",
|
||||
"migrating": "사이드카 파일을 이동하는 중...",
|
||||
"migrateSuccess": "사이드카 파일 이동이 완료되었습니다",
|
||||
"migrateFailed": "사이드카 파일 이동 실패: {message}",
|
||||
"migrationDeferred": "기존 사이드카 파일은 이동되지 않았습니다. 나중에 설정 → 라이브러리 → 사이드카 파일 저장에서 이동할 수 있습니다.",
|
||||
"confirmToCentralized": "저장 모드가 변경되었지만 기존 .metadata.json 사이드카 파일과 미리보기 이미지는 자동으로 이동되지 않습니다. 지금 중앙 집중식 저장 디렉터리로 이동할까요? '지금 사이드카 파일 이동' 버튼으로 나중에 실행할 수도 있습니다.",
|
||||
"confirmToAlongside": "저장 모드가 변경되었지만 기존 .metadata.json 사이드카 파일과 미리보기 이미지는 자동으로 이동되지 않습니다. 지금 각 모델 파일 옆으로 되돌릴까요? '지금 사이드카 파일 이동' 버튼으로 나중에 실행할 수도 있습니다.",
|
||||
"confirmRelocateRoot": "중앙 집중식 저장 디렉터리가 변경되었지만 기존 사이드카 파일과 미리보기 이미지는 아직 이전 디렉터리에 있습니다. 지금 새 디렉터리로 이동할까요?",
|
||||
"effectivePathLabel": "[TODO: Translate] Effective storage location:",
|
||||
"openFolderButton": "[TODO: Translate] Open Folder",
|
||||
"repoWarning": "[TODO: Translate] The effective storage location is inside the LoRA Manager installation folder. Reinstalling the plugin or a clean update can delete it — set an explicit storage path outside the installation folder.",
|
||||
"openLocationSuccess": "[TODO: Translate] Opened sidecar storage folder",
|
||||
"openLocationCopied": "[TODO: Translate] Sidecar storage path copied to clipboard: {path}",
|
||||
"openLocationClipboardFallback": "[TODO: Translate] Copy the sidecar storage path manually: {path}",
|
||||
"openLocationFailed": "[TODO: Translate] Failed to open the sidecar storage folder"
|
||||
},
|
||||
"proxySettings": {
|
||||
"enableProxy": "앱 수준 프록시 활성화",
|
||||
"enableProxyHelp": "이 애플리케이션에 대한 사용자 지정 프록시 설정을 활성화하여 시스템 프록시 설정을 무시합니다",
|
||||
@@ -1344,6 +1380,10 @@
|
||||
"emptyNote": "이 폴더에는 모델이 없습니다. 폴더 안의 다른 파일도 함께 삭제됩니다.",
|
||||
"notEmptyTitle": "폴더가 비어 있지 않습니다",
|
||||
"notEmptyMessage": "이 폴더에는 아직 모델이 있습니다. 먼저 해당 모델을 삭제하거나 이동하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.",
|
||||
"notEmptyMessageCount": "이 폴더에는 아직 모델 파일이 {count}개 있습니다. 먼저 해당 모델을 삭제하거나 이동하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.",
|
||||
"notEmptyMessageExcluded": "이 폴더에는 아직 모델 파일이 {count}개 있으며, 그중 {excluded}개는 라이브러리에서 제외되어 있습니다. '제외된 모델 관리'에서 제외를 해제한 뒤 먼저 삭제하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.",
|
||||
"busyTitle": "대기 중인 삭제 작업이 있습니다",
|
||||
"checking": "폴더 내용을 확인하는 중...",
|
||||
"confirm": "폴더 삭제"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
@@ -1352,6 +1392,7 @@
|
||||
"restored": "폴더를 복원했습니다",
|
||||
"failed": "폴더 삭제 실패: {message}",
|
||||
"notEmpty": "이 폴더에는 아직 모델이 있습니다. 사이드바를 새로 고친 후 다시 시도하세요.",
|
||||
"notEmptyWithCount": "이 폴더에는 아직 모델 파일이 {count}개 있습니다. 사이드바를 새로 고친 후 다시 시도하세요.",
|
||||
"busy": "이 폴더에 아직 대기 중인 삭제 작업이 있습니다. 되돌리기 시간이 끝날 때까지 기다리세요.",
|
||||
"unsupported": "이 페이지에서는 폴더를 삭제할 수 없습니다",
|
||||
"noRoot": "모델 루트가 설정되지 않았습니다"
|
||||
@@ -1631,6 +1672,19 @@
|
||||
"titleRevert": "원본 파일명을 복원하시겠습니까?",
|
||||
"revertButton": "원본 파일명 복원"
|
||||
},
|
||||
"sidecarMigrationConfirm": {
|
||||
"titleToCentralized": "사이드카 파일을 중앙 집중식 저장으로 이동할까요?",
|
||||
"titleToAlongside": "사이드카 파일을 모델 파일 옆으로 되돌릴까요?",
|
||||
"confirmButton": "지금 이동",
|
||||
"titleRelocateRoot": "사이드카 파일을 새 저장 디렉터리로 이동할까요?",
|
||||
"destination": "[TODO: Translate] Destination: {path}"
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "[TODO: Translate] Sidecar migration completed",
|
||||
"titleWithErrors": "[TODO: Translate] Sidecar migration completed with {count} error(s)",
|
||||
"summary": "[TODO: Translate] Moved {moved} files for {models} models. Skipped: {skipped}, conflicts resolved: {conflicts}.",
|
||||
"location": "[TODO: Translate] Storage location: {path}"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "여러 모델에 태그 추가",
|
||||
"description": "다음에 태그를 추가합니다:",
|
||||
|
||||
@@ -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».",
|
||||
@@ -377,6 +383,7 @@
|
||||
"exampleImages": "Примеры изображений",
|
||||
"autoOrganize": "Автоорганизация",
|
||||
"metadata": "Метаданные",
|
||||
"sidecarStorage": "Хранилище sidecar-файлов",
|
||||
"proxySettings": "Настройки прокси"
|
||||
},
|
||||
"nav": {
|
||||
@@ -783,6 +790,35 @@
|
||||
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||
},
|
||||
"sidecarStorage": {
|
||||
"mode": "Режим хранения sidecar-файлов",
|
||||
"modeHelp": "Выберите, где хранить sidecar-файлы .metadata.json и изображения превью: рядом с каждым файлом модели или в одном централизованном каталоге, повторяющем структуру вашей библиотеки. Файлы .civitai.info всегда остаются рядом с файлом модели.",
|
||||
"modeOptions": {
|
||||
"alongside": "Рядом с файлами моделей (по умолчанию)",
|
||||
"centralized": "Централизованное хранилище"
|
||||
},
|
||||
"path": "Путь централизованного хранилища",
|
||||
"pathHelp": "Корневой каталог централизованного хранилища sidecar-файлов. Оставьте пустым, чтобы использовать расположение по умолчанию (<settings dir>/sidecars).",
|
||||
"pathPlaceholder": "Пусто = <settings dir>/sidecars",
|
||||
"management": "Перенос sidecar-файлов",
|
||||
"managementHelp": "Переносите существующие sidecar-файлы .metadata.json и изображения превью между хранением рядом с моделями и централизованным хранилищем в соответствии с выбранным режимом. Смена режима не переносит существующие файлы автоматически.",
|
||||
"migrateButton": "Перенести sidecar-файлы сейчас",
|
||||
"migratingButton": "Перенос...",
|
||||
"migrating": "Перенос sidecar-файлов...",
|
||||
"migrateSuccess": "Перенос sidecar-файлов успешно завершён",
|
||||
"migrateFailed": "Не удалось перенести sidecar-файлы: {message}",
|
||||
"migrationDeferred": "Существующие sidecar-файлы не были перенесены. Вы можете перенести их позже в разделе «Настройки → Библиотека → Хранилище sidecar-файлов».",
|
||||
"confirmToCentralized": "Режим хранения изменён, но существующие sidecar-файлы .metadata.json и изображения превью не переносятся автоматически. Перенести их сейчас в централизованное хранилище? Это также можно сделать позже кнопкой «Перенести sidecar-файлы сейчас».",
|
||||
"confirmToAlongside": "Режим хранения изменён, но существующие sidecar-файлы .metadata.json и изображения превью не переносятся автоматически. Вернуть их сейчас рядом с их файлами моделей? Это также можно сделать позже кнопкой «Перенести sidecar-файлы сейчас».",
|
||||
"confirmRelocateRoot": "Каталог централизованного хранилища изменён, но существующие sidecar-файлы и изображения превью всё ещё находятся в прежнем каталоге. Перенести их сейчас в новый каталог?",
|
||||
"effectivePathLabel": "[TODO: Translate] Effective storage location:",
|
||||
"openFolderButton": "[TODO: Translate] Open Folder",
|
||||
"repoWarning": "[TODO: Translate] The effective storage location is inside the LoRA Manager installation folder. Reinstalling the plugin or a clean update can delete it — set an explicit storage path outside the installation folder.",
|
||||
"openLocationSuccess": "[TODO: Translate] Opened sidecar storage folder",
|
||||
"openLocationCopied": "[TODO: Translate] Sidecar storage path copied to clipboard: {path}",
|
||||
"openLocationClipboardFallback": "[TODO: Translate] Copy the sidecar storage path manually: {path}",
|
||||
"openLocationFailed": "[TODO: Translate] Failed to open the sidecar storage folder"
|
||||
},
|
||||
"proxySettings": {
|
||||
"enableProxy": "Включить прокси на уровне приложения",
|
||||
"enableProxyHelp": "Включить пользовательские настройки прокси для этого приложения, переопределяя системные настройки прокси",
|
||||
@@ -1344,6 +1380,10 @@
|
||||
"emptyNote": "В этой папке нет моделей. Остальные файлы в ней тоже будут удалены.",
|
||||
"notEmptyTitle": "Папка не пуста",
|
||||
"notEmptyMessage": "В этой папке ещё есть модели. Сначала удалите или переместите их — удаление папки никогда не затрагивает файлы моделей.",
|
||||
"notEmptyMessageCount": "В этой папке ещё есть {count} файл(ов) модели. Сначала удалите или переместите их — удаление папки никогда не затрагивает файлы моделей.",
|
||||
"notEmptyMessageExcluded": "В этой папке ещё есть {count} файл(ов) модели, из них {excluded} исключены из библиотеки. Снимите исключение в разделе «Управление исключёнными моделями» и удалите их — удаление папки никогда не затрагивает файлы моделей.",
|
||||
"busyTitle": "Удаление всё ещё отложено",
|
||||
"checking": "Проверка содержимого папки...",
|
||||
"confirm": "Удалить папку"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
@@ -1352,6 +1392,7 @@
|
||||
"restored": "Папка восстановлена",
|
||||
"failed": "Не удалось удалить папку: {message}",
|
||||
"notEmpty": "В этой папке ещё есть модели. Обновите боковую панель и повторите попытку.",
|
||||
"notEmptyWithCount": "В этой папке ещё есть {count} файл(ов) модели. Обновите боковую панель и повторите попытку.",
|
||||
"busy": "В этой папке всё ещё есть отложенное удаление. Дождитесь окончания окна отмены.",
|
||||
"unsupported": "Удаление папок не поддерживается на этой странице",
|
||||
"noRoot": "Корневая папка моделей не настроена"
|
||||
@@ -1631,6 +1672,19 @@
|
||||
"titleRevert": "Восстановить исходные имена файлов?",
|
||||
"revertButton": "Восстановить исходные имена файлов"
|
||||
},
|
||||
"sidecarMigrationConfirm": {
|
||||
"titleToCentralized": "Перенести sidecar-файлы в централизованное хранилище?",
|
||||
"titleToAlongside": "Вернуть sidecar-файлы рядом с файлами моделей?",
|
||||
"confirmButton": "Перенести сейчас",
|
||||
"titleRelocateRoot": "Перенести sidecar-файлы в новый каталог хранилища?",
|
||||
"destination": "[TODO: Translate] Destination: {path}"
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "[TODO: Translate] Sidecar migration completed",
|
||||
"titleWithErrors": "[TODO: Translate] Sidecar migration completed with {count} error(s)",
|
||||
"summary": "[TODO: Translate] Moved {moved} files for {models} models. Skipped: {skipped}, conflicts resolved: {conflicts}.",
|
||||
"location": "[TODO: Translate] Storage location: {path}"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Добавить теги к нескольким моделям",
|
||||
"description": "Добавить теги к",
|
||||
|
||||
@@ -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 站点。",
|
||||
@@ -377,6 +383,7 @@
|
||||
"exampleImages": "示例图片",
|
||||
"autoOrganize": "自动整理",
|
||||
"metadata": "元数据",
|
||||
"sidecarStorage": "附属文件存储",
|
||||
"proxySettings": "代理设置"
|
||||
},
|
||||
"nav": {
|
||||
@@ -783,6 +790,35 @@
|
||||
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||
},
|
||||
"sidecarStorage": {
|
||||
"mode": "附属文件存储模式",
|
||||
"modeHelp": "选择 .metadata.json 附属文件和预览图片的存放位置:与每个模型文件放在一起,或集中存放在一个镜像模型库结构的目录中。.civitai.info 文件始终与模型文件放在一起。",
|
||||
"modeOptions": {
|
||||
"alongside": "与模型文件放在一起(默认)",
|
||||
"centralized": "集中存储"
|
||||
},
|
||||
"path": "集中存储路径",
|
||||
"pathHelp": "集中存储附属文件的根目录。留空则使用默认位置(<settings dir>/sidecars)。",
|
||||
"pathPlaceholder": "留空 = <settings dir>/sidecars",
|
||||
"management": "附属文件迁移",
|
||||
"managementHelp": "在“与模型文件放在一起”和“集中存储”之间迁移现有的 .metadata.json 附属文件和预览图片,以匹配当前选择的模式。更改模式不会自动迁移现有文件。",
|
||||
"migrateButton": "立即迁移附属文件",
|
||||
"migratingButton": "迁移中...",
|
||||
"migrating": "正在迁移附属文件...",
|
||||
"migrateSuccess": "附属文件迁移完成",
|
||||
"migrateFailed": "附属文件迁移失败:{message}",
|
||||
"migrationDeferred": "现有附属文件未迁移。你可以稍后在“设置 → 库 → 附属文件存储”中迁移它们。",
|
||||
"confirmToCentralized": "存储模式已更改,但现有的 .metadata.json 附属文件和预览图片不会自动迁移。要现在将它们移入集中存储目录吗?你也可以稍后使用“立即迁移附属文件”按钮完成。",
|
||||
"confirmToAlongside": "存储模式已更改,但现有的 .metadata.json 附属文件和预览图片不会自动迁移。要现在将它们移回各自的模型文件旁边吗?你也可以稍后使用“立即迁移附属文件”按钮完成。",
|
||||
"confirmRelocateRoot": "集中存储目录已更改,但现有的附属文件和预览图片仍在原目录中。要现在将它们移到新目录吗?",
|
||||
"effectivePathLabel": "[TODO: Translate] Effective storage location:",
|
||||
"openFolderButton": "[TODO: Translate] Open Folder",
|
||||
"repoWarning": "[TODO: Translate] The effective storage location is inside the LoRA Manager installation folder. Reinstalling the plugin or a clean update can delete it — set an explicit storage path outside the installation folder.",
|
||||
"openLocationSuccess": "[TODO: Translate] Opened sidecar storage folder",
|
||||
"openLocationCopied": "[TODO: Translate] Sidecar storage path copied to clipboard: {path}",
|
||||
"openLocationClipboardFallback": "[TODO: Translate] Copy the sidecar storage path manually: {path}",
|
||||
"openLocationFailed": "[TODO: Translate] Failed to open the sidecar storage folder"
|
||||
},
|
||||
"proxySettings": {
|
||||
"enableProxy": "启用应用级代理",
|
||||
"enableProxyHelp": "为此应用启用自定义代理设置,覆盖系统代理设置",
|
||||
@@ -1344,6 +1380,10 @@
|
||||
"emptyNote": "该文件夹中没有模型,其中的其他文件也会一并删除。",
|
||||
"notEmptyTitle": "文件夹不为空",
|
||||
"notEmptyMessage": "该文件夹中仍有模型,请先删除或移出这些模型 —— 删除文件夹不会级联删除模型文件。",
|
||||
"notEmptyMessageCount": "该文件夹中仍有 {count} 个模型文件,请先删除或移出这些模型 —— 删除文件夹不会级联删除模型文件。",
|
||||
"notEmptyMessageExcluded": "该文件夹中仍有 {count} 个模型文件,其中 {excluded} 个已从模型库中排除。请先在“管理已排除的模型”中取消排除并删除它们 —— 删除文件夹不会级联删除模型文件。",
|
||||
"busyTitle": "仍有删除操作待处理",
|
||||
"checking": "正在检查文件夹内容...",
|
||||
"confirm": "删除文件夹"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
@@ -1352,6 +1392,7 @@
|
||||
"restored": "文件夹已恢复",
|
||||
"failed": "删除文件夹失败: {message}",
|
||||
"notEmpty": "该文件夹中仍有模型。请刷新侧边栏后重试。",
|
||||
"notEmptyWithCount": "该文件夹中仍有 {count} 个模型文件。请刷新侧边栏后重试。",
|
||||
"busy": "该文件夹内仍有待处理的删除操作,请等待撤销窗口结束。",
|
||||
"unsupported": "此页面不支持删除文件夹",
|
||||
"noRoot": "未配置模型根目录"
|
||||
@@ -1631,6 +1672,19 @@
|
||||
"titleRevert": "恢复原始文件名?",
|
||||
"revertButton": "恢复原始文件名"
|
||||
},
|
||||
"sidecarMigrationConfirm": {
|
||||
"titleToCentralized": "要将附属文件移到集中存储吗?",
|
||||
"titleToAlongside": "要将附属文件移回模型文件旁边吗?",
|
||||
"confirmButton": "立即迁移",
|
||||
"titleRelocateRoot": "要将附属文件移到新的存储目录吗?",
|
||||
"destination": "[TODO: Translate] Destination: {path}"
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "[TODO: Translate] Sidecar migration completed",
|
||||
"titleWithErrors": "[TODO: Translate] Sidecar migration completed with {count} error(s)",
|
||||
"summary": "[TODO: Translate] Moved {moved} files for {models} models. Skipped: {skipped}, conflicts resolved: {conflicts}.",
|
||||
"location": "[TODO: Translate] Storage location: {path}"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "批量添加标签",
|
||||
"description": "为多个模型添加标签",
|
||||
|
||||
@@ -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 站點。",
|
||||
@@ -377,6 +383,7 @@
|
||||
"exampleImages": "範例圖片",
|
||||
"autoOrganize": "自動整理",
|
||||
"metadata": "中繼資料",
|
||||
"sidecarStorage": "附屬檔案儲存",
|
||||
"proxySettings": "代理設定"
|
||||
},
|
||||
"nav": {
|
||||
@@ -783,6 +790,35 @@
|
||||
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||
},
|
||||
"sidecarStorage": {
|
||||
"mode": "附屬檔案儲存模式",
|
||||
"modeHelp": "選擇 .metadata.json 附屬檔案與預覽圖片的存放位置:與每個模型檔案放在一起,或集中在一個對應模型庫結構的目錄中。.civitai.info 檔案一律與模型檔案放在一起。",
|
||||
"modeOptions": {
|
||||
"alongside": "與模型檔案放在一起(預設)",
|
||||
"centralized": "集中儲存"
|
||||
},
|
||||
"path": "集中儲存路徑",
|
||||
"pathHelp": "集中儲存附屬檔案的根目錄。留空則使用預設位置(<settings dir>/sidecars)。",
|
||||
"pathPlaceholder": "留空 = <settings dir>/sidecars",
|
||||
"management": "附屬檔案遷移",
|
||||
"managementHelp": "在「與模型檔案放在一起」與「集中儲存」之間遷移現有的 .metadata.json 附屬檔案與預覽圖片,以符合目前選擇的模式。變更模式不會自動遷移現有檔案。",
|
||||
"migrateButton": "立即遷移附屬檔案",
|
||||
"migratingButton": "遷移中...",
|
||||
"migrating": "正在遷移附屬檔案...",
|
||||
"migrateSuccess": "附屬檔案遷移完成",
|
||||
"migrateFailed": "附屬檔案遷移失敗:{message}",
|
||||
"migrationDeferred": "現有附屬檔案未遷移。您稍後可以在「設定 > 模型庫 > 附屬檔案儲存」中遷移它們。",
|
||||
"confirmToCentralized": "儲存模式已變更,但現有的 .metadata.json 附屬檔案與預覽圖片不會自動遷移。要現在將它們移入集中儲存目錄嗎?您也可以稍後使用「立即遷移附屬檔案」按鈕完成。",
|
||||
"confirmToAlongside": "儲存模式已變更,但現有的 .metadata.json 附屬檔案與預覽圖片不會自動遷移。要現在將它們移回各自的模型檔案旁邊嗎?您也可以稍後使用「立即遷移附屬檔案」按鈕完成。",
|
||||
"confirmRelocateRoot": "集中儲存目錄已變更,但現有的附屬檔案與預覽圖片仍在原目錄中。要現在將它們移到新目錄嗎?",
|
||||
"effectivePathLabel": "[TODO: Translate] Effective storage location:",
|
||||
"openFolderButton": "[TODO: Translate] Open Folder",
|
||||
"repoWarning": "[TODO: Translate] The effective storage location is inside the LoRA Manager installation folder. Reinstalling the plugin or a clean update can delete it — set an explicit storage path outside the installation folder.",
|
||||
"openLocationSuccess": "[TODO: Translate] Opened sidecar storage folder",
|
||||
"openLocationCopied": "[TODO: Translate] Sidecar storage path copied to clipboard: {path}",
|
||||
"openLocationClipboardFallback": "[TODO: Translate] Copy the sidecar storage path manually: {path}",
|
||||
"openLocationFailed": "[TODO: Translate] Failed to open the sidecar storage folder"
|
||||
},
|
||||
"proxySettings": {
|
||||
"enableProxy": "啟用應用程式代理",
|
||||
"enableProxyHelp": "啟用此應用程式的自訂代理設定,將覆蓋系統代理設定",
|
||||
@@ -1344,6 +1380,10 @@
|
||||
"emptyNote": "該資料夾中沒有模型,其中的其他檔案也會一併刪除。",
|
||||
"notEmptyTitle": "資料夾不是空的",
|
||||
"notEmptyMessage": "該資料夾中仍有模型,請先刪除或移出這些模型 —— 刪除資料夾不會串聯刪除模型檔案。",
|
||||
"notEmptyMessageCount": "該資料夾中仍有 {count} 個模型檔案,請先刪除或移出這些模型 —— 刪除資料夾不會串聯刪除模型檔案。",
|
||||
"notEmptyMessageExcluded": "該資料夾中仍有 {count} 個模型檔案,其中 {excluded} 個已從模型庫中排除。請先在「管理已排除的模型」中取消排除並刪除它們 —— 刪除資料夾不會串聯刪除模型檔案。",
|
||||
"busyTitle": "仍有刪除操作待處理",
|
||||
"checking": "正在檢查資料夾內容...",
|
||||
"confirm": "刪除資料夾"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
@@ -1352,6 +1392,7 @@
|
||||
"restored": "資料夾已還原",
|
||||
"failed": "刪除資料夾失敗: {message}",
|
||||
"notEmpty": "該資料夾中仍有模型。請重新整理側邊欄後再試。",
|
||||
"notEmptyWithCount": "該資料夾中仍有 {count} 個模型檔案。請重新整理側邊欄後再試。",
|
||||
"busy": "該資料夾內仍有待處理的刪除操作,請等待復原時間結束。",
|
||||
"unsupported": "此頁面不支援刪除資料夾",
|
||||
"noRoot": "未設定模型根目錄"
|
||||
@@ -1631,6 +1672,19 @@
|
||||
"titleRevert": "要還原原始檔案名稱嗎?",
|
||||
"revertButton": "還原原始檔案名稱"
|
||||
},
|
||||
"sidecarMigrationConfirm": {
|
||||
"titleToCentralized": "要將附屬檔案移到集中儲存嗎?",
|
||||
"titleToAlongside": "要將附屬檔案移回模型檔案旁邊嗎?",
|
||||
"confirmButton": "立即遷移",
|
||||
"titleRelocateRoot": "要將附屬檔案移到新的儲存目錄嗎?",
|
||||
"destination": "[TODO: Translate] Destination: {path}"
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "[TODO: Translate] Sidecar migration completed",
|
||||
"titleWithErrors": "[TODO: Translate] Sidecar migration completed with {count} error(s)",
|
||||
"summary": "[TODO: Translate] Moved {moved} files for {models} models. Skipped: {skipped}, conflicts resolved: {conflicts}.",
|
||||
"location": "[TODO: Translate] Storage location: {path}"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "新增標籤到多個模型",
|
||||
"description": "新增標籤到",
|
||||
|
||||
@@ -891,6 +891,17 @@ class Config:
|
||||
if self.recipes_path:
|
||||
preview_roots.update(self._expand_preview_root(self.recipes_path))
|
||||
|
||||
# Centralized sidecar storage holds preview assets outside the model
|
||||
# roots; allow serving them when the mode is active.
|
||||
try:
|
||||
from .utils.sidecar_paths import get_sidecar_root # Local import to avoid circular dependency
|
||||
|
||||
sidecar_root = get_sidecar_root()
|
||||
except Exception: # pragma: no cover - defensive fallback
|
||||
sidecar_root = ""
|
||||
if sidecar_root:
|
||||
preview_roots.update(self._expand_preview_root(sidecar_root))
|
||||
|
||||
for target, link in self._path_mappings.items():
|
||||
preview_roots.update(self._expand_preview_root(target))
|
||||
preview_roots.update(self._expand_preview_root(link))
|
||||
@@ -1494,6 +1505,15 @@ class Config:
|
||||
self.other_roots = self._init_other_paths()
|
||||
self._rebuild_preview_roots()
|
||||
|
||||
def refresh_preview_roots(self) -> None:
|
||||
"""Rebuild the preview allowlist after path-affecting settings change.
|
||||
|
||||
Called when ``sidecar_storage_mode`` / ``sidecar_storage_path`` are
|
||||
updated so centralized preview assets become servable (or stop being
|
||||
servable) without a restart.
|
||||
"""
|
||||
self._rebuild_preview_roots()
|
||||
|
||||
def get_other_models_availability(self) -> Dict[str, Any]:
|
||||
"""Report the other-model folders the host can actually expose.
|
||||
|
||||
|
||||
@@ -172,12 +172,16 @@ async def download_preview(
|
||||
"""
|
||||
from ..services.downloader import get_downloader
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
from ..utils.sidecar_paths import get_preview_dir
|
||||
|
||||
if not url or not url.strip():
|
||||
return None
|
||||
|
||||
base_name = os.path.splitext(os.path.basename(model_path))[0]
|
||||
preview_dir = os.path.dirname(model_path)
|
||||
preview_dir = get_preview_dir(model_path)
|
||||
# Centralized mirrors may not exist yet (unlike the model's own directory
|
||||
# in alongside mode).
|
||||
os.makedirs(preview_dir, exist_ok=True)
|
||||
output_path = os.path.join(preview_dir, base_name + ".webp")
|
||||
|
||||
downloader = await get_downloader()
|
||||
|
||||
@@ -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),)
|
||||
|
||||
@@ -45,6 +45,8 @@ from ...services.llm_service import (
|
||||
get_provider_model_ids,
|
||||
)
|
||||
from ...services.cache_health_monitor import CacheHealthMonitor, CacheHealthStatus
|
||||
from ...services.use_cases.sidecar_migration_use_case import SidecarMigrationUseCase
|
||||
from ...services.websocket_progress_callback import WebSocketBroadcastCallback
|
||||
from ...utils.models import BaseModelMetadata
|
||||
from ...utils.constants import (
|
||||
CIVITAI_USER_MODEL_TYPES,
|
||||
@@ -68,6 +70,12 @@ from ...utils.example_images_paths import (
|
||||
)
|
||||
from ...utils.lora_metadata import extract_trained_words
|
||||
from ...utils.session_logging import get_standalone_session_log_snapshot
|
||||
from ...utils.sidecar_paths import (
|
||||
describe_sidecar_root,
|
||||
get_configured_sidecar_root,
|
||||
get_metadata_path,
|
||||
get_preview_dir,
|
||||
)
|
||||
from ...utils.usage_stats import UsageStats
|
||||
from .base_model_handlers import BaseModelHandlerSet
|
||||
|
||||
@@ -943,15 +951,24 @@ class DoctorHandler:
|
||||
|
||||
os.rename(path, new_path)
|
||||
|
||||
for suffix in (".metadata.json", ".civitai.info"):
|
||||
old_sidecar = old_base_no_ext + suffix
|
||||
new_sidecar = new_base_no_ext + suffix
|
||||
if os.path.exists(old_sidecar):
|
||||
os.rename(old_sidecar, new_sidecar)
|
||||
old_metadata_path = get_metadata_path(path)
|
||||
new_metadata_path = get_metadata_path(new_path)
|
||||
if os.path.exists(old_metadata_path):
|
||||
os.rename(old_metadata_path, new_metadata_path)
|
||||
|
||||
old_sidecar = old_base_no_ext + ".civitai.info"
|
||||
new_sidecar = new_base_no_ext + ".civitai.info"
|
||||
if os.path.exists(old_sidecar):
|
||||
os.rename(old_sidecar, new_sidecar)
|
||||
|
||||
for preview_ext in PREVIEW_EXTENSIONS:
|
||||
old_preview = old_base_no_ext + preview_ext
|
||||
new_preview = new_base_no_ext + preview_ext
|
||||
old_preview = os.path.join(
|
||||
get_preview_dir(path), base_name + preview_ext
|
||||
)
|
||||
new_preview = os.path.join(
|
||||
get_preview_dir(new_path),
|
||||
candidate_base + preview_ext,
|
||||
)
|
||||
if os.path.exists(old_preview):
|
||||
os.rename(old_preview, new_preview)
|
||||
|
||||
@@ -963,7 +980,10 @@ class DoctorHandler:
|
||||
old_preview_url = entry["preview_url"].replace("\\", "/")
|
||||
preview_ext = os.path.splitext(old_preview_url)[1]
|
||||
if preview_ext:
|
||||
entry["preview_url"] = (new_base_no_ext + preview_ext).replace(os.sep, "/")
|
||||
entry["preview_url"] = os.path.join(
|
||||
get_preview_dir(new_path),
|
||||
candidate_base + preview_ext,
|
||||
).replace(os.sep, "/")
|
||||
await scanner.update_single_model_cache(
|
||||
path, new_path, entry
|
||||
)
|
||||
@@ -1506,6 +1526,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",
|
||||
}
|
||||
)
|
||||
@@ -1564,6 +1585,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
|
||||
@@ -1609,6 +1632,19 @@ class SettingsHandler:
|
||||
settings_file = getattr(self._settings, "settings_file", None)
|
||||
if settings_file:
|
||||
response_data["settings_file"] = settings_file
|
||||
# Resolved centralized sidecar root (mode-independent): lets the
|
||||
# settings UI show where sidecars actually live, including when the
|
||||
# path setting is empty and the default kicks in. inside_repo flags
|
||||
# the portable-mode hazard (root inside the plugin folder).
|
||||
try:
|
||||
sidecar_info = describe_sidecar_root()
|
||||
response_data["sidecar_storage_root"] = sidecar_info["root"]
|
||||
response_data["sidecar_storage_root_is_default"] = sidecar_info["is_default"]
|
||||
response_data["sidecar_storage_root_in_repo"] = sidecar_info["inside_repo"]
|
||||
except Exception as sidecar_error: # pragma: no cover - defensive
|
||||
logger.debug(
|
||||
"Could not resolve sidecar storage info: %s", sidecar_error
|
||||
)
|
||||
messages_getter: Any = getattr(self._settings, "get_startup_messages", None)
|
||||
messages = list(messages_getter()) if messages_getter else []
|
||||
return web.json_response(
|
||||
@@ -3498,6 +3534,24 @@ 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 open_sidecar_location(self, request: web.Request) -> web.Response:
|
||||
"""Open the centralized sidecar storage root in the file manager."""
|
||||
|
||||
try:
|
||||
root = get_configured_sidecar_root()
|
||||
if not root:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Sidecar storage root is not resolvable"},
|
||||
status=404,
|
||||
)
|
||||
# Create on demand so the button also works before the first
|
||||
# migration/download has materialized the directory.
|
||||
os.makedirs(root, exist_ok=True)
|
||||
return await self._open_path(root)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error("Failed to open sidecar 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:
|
||||
@@ -4120,6 +4174,64 @@ class NodeRegistryHandler:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
|
||||
class SidecarMigrationHandler:
|
||||
"""Migrate sidecar metadata and previews between storage layouts."""
|
||||
|
||||
_VALID_DIRECTIONS = ("to_centralized", "to_alongside", "relocate_root")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
use_case_factory: Callable[[], SidecarMigrationUseCase] = SidecarMigrationUseCase,
|
||||
progress_callback_factory: Callable[[], Any] = WebSocketBroadcastCallback,
|
||||
) -> None:
|
||||
self._use_case_factory = use_case_factory
|
||||
self._progress_callback_factory = progress_callback_factory
|
||||
|
||||
async def migrate_sidecars(self, request: web.Request) -> web.Response:
|
||||
"""Run a sidecar migration; accepts POST JSON or GET query params."""
|
||||
try:
|
||||
if request.method == "GET":
|
||||
params: Mapping[str, Any] = request.query
|
||||
else:
|
||||
try:
|
||||
params = await request.json()
|
||||
except Exception: # empty/invalid body: fall back to query
|
||||
params = request.query
|
||||
|
||||
direction = str(params.get("direction") or "").strip()
|
||||
if direction not in self._VALID_DIRECTIONS:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "direction must be 'to_centralized', 'to_alongside' or 'relocate_root'",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
force = params.get("force") in (True, 1, "true", "1")
|
||||
old_root = str(params.get("old_root") or "").strip()
|
||||
if direction == "relocate_root" and not old_root:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "old_root is required for relocate_root"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
use_case = self._use_case_factory()
|
||||
progress_cb = self._progress_callback_factory()
|
||||
result = await use_case.execute_with_error_handling(
|
||||
direction=direction,
|
||||
progress_cb=progress_cb,
|
||||
force=force,
|
||||
old_root=old_root,
|
||||
)
|
||||
status = 200 if result.get("success") else 400
|
||||
return web.json_response(result, status=status)
|
||||
except Exception as exc:
|
||||
logger.error("Sidecar migration failed: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
|
||||
class MiscHandlerSet:
|
||||
"""Aggregate handlers into a lookup compatible with the registrar."""
|
||||
|
||||
@@ -4146,6 +4258,7 @@ class MiscHandlerSet:
|
||||
model_source_handler: Any = None,
|
||||
agent_handler: Any = None,
|
||||
download_routing: Any = None,
|
||||
sidecar_migration: Any = None,
|
||||
) -> None:
|
||||
self.health = health
|
||||
self.settings = settings
|
||||
@@ -4167,6 +4280,7 @@ class MiscHandlerSet:
|
||||
self.model_source_handler = model_source_handler
|
||||
self.agent_handler = agent_handler
|
||||
self.download_routing = download_routing
|
||||
self.sidecar_migration = sidecar_migration
|
||||
|
||||
def to_route_mapping(
|
||||
self,
|
||||
@@ -4212,6 +4326,7 @@ 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,
|
||||
"open_sidecar_location": self.filesystem.open_sidecar_location,
|
||||
"browse_directory": self.filesystem.browse_directory,
|
||||
"validate_path": self.filesystem.validate_path,
|
||||
"search_custom_words": self.custom_words.search_custom_words,
|
||||
@@ -4233,6 +4348,8 @@ class MiscHandlerSet:
|
||||
"cancel_agent_skill": self.agent_handler.cancel_agent_skill,
|
||||
# Download routing handler
|
||||
"get_download_routing": self.download_routing.get_download_routing,
|
||||
# Sidecar migration handler
|
||||
"migrate_sidecars": self.sidecar_migration.migrate_sidecars,
|
||||
# Base model handlers
|
||||
"get_base_models": self.base_model.get_base_models,
|
||||
"refresh_base_models": self.base_model.refresh_base_models,
|
||||
|
||||
@@ -50,6 +50,8 @@ 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.sidecar_paths import get_metadata_path
|
||||
from ...utils.url_utils import relative_root_prefix
|
||||
|
||||
LICENSE_FIELDS = (
|
||||
"allowNoCredit",
|
||||
@@ -204,6 +206,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:
|
||||
@@ -674,7 +677,7 @@ class ModelManagementHandler:
|
||||
status=400,
|
||||
)
|
||||
|
||||
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
local_metadata = await self._metadata_sync.load_local_metadata(
|
||||
metadata_path
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -36,6 +36,7 @@ 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
|
||||
|
||||
@@ -113,6 +113,16 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/download/routing", "get_download_routing"
|
||||
),
|
||||
# Sidecar storage layout migration (GET supported for the extension)
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/sidecars/migrate", "migrate_sidecars"
|
||||
),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/sidecars/migrate", "migrate_sidecars"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/sidecars/open-location", "open_sidecar_location"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/download-model-source", "download_model_source"
|
||||
),
|
||||
|
||||
@@ -32,6 +32,7 @@ from .handlers.misc_handlers import (
|
||||
NodeRegistry,
|
||||
NodeRegistryHandler,
|
||||
SettingsHandler,
|
||||
SidecarMigrationHandler,
|
||||
SupportersHandler,
|
||||
TrainedWordsHandler,
|
||||
UsageStatsHandler,
|
||||
@@ -142,6 +143,7 @@ class MiscRoutes:
|
||||
model_source_handler = ModelSourceHandler()
|
||||
agent_handler = AgentHandler()
|
||||
download_routing = DownloadRoutingHandler()
|
||||
sidecar_migration = SidecarMigrationHandler()
|
||||
|
||||
return self._handler_set_factory(
|
||||
health=health,
|
||||
@@ -164,6 +166,7 @@ class MiscRoutes:
|
||||
model_source_handler=model_source_handler,
|
||||
agent_handler=agent_handler,
|
||||
download_routing=download_routing,
|
||||
sidecar_migration=sidecar_migration,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -27,6 +27,8 @@ import os
|
||||
import threading
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from ..utils.sidecar_paths import get_metadata_path
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - type-check only; runtime imports are local
|
||||
from .model_scanner import ModelScanner
|
||||
|
||||
@@ -41,7 +43,7 @@ def _resolve_autov3(file_path: str) -> str:
|
||||
safetensors header hash. Returns ``''`` when neither is available.
|
||||
"""
|
||||
try:
|
||||
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
if os.path.exists(metadata_path):
|
||||
with open(metadata_path, "r", encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any, Dict, List, Optional
|
||||
from ..utils.models import CheckpointMetadata
|
||||
from ..utils.file_utils import find_preview_file, normalize_path, calculate_autov3
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from ..utils.sidecar_paths import get_preview_dir, is_centralized
|
||||
from ..config import config
|
||||
from .model_scanner import ModelScanner, _is_excluded_dir
|
||||
from .model_hash_index import ModelHashIndex
|
||||
@@ -61,10 +62,9 @@ class CheckpointScanner(ModelScanner):
|
||||
return None
|
||||
|
||||
base_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
dir_path = os.path.dirname(file_path)
|
||||
|
||||
# Find preview image
|
||||
preview_url = find_preview_file(base_name, dir_path)
|
||||
preview_url = find_preview_file(base_name, get_preview_dir(file_path))
|
||||
|
||||
# AutoV3 reads only the safetensors header, so it is cheap even for
|
||||
# large checkpoints; record the checked state at creation time ("" =
|
||||
@@ -322,6 +322,11 @@ class CheckpointScanner(ModelScanner):
|
||||
|
||||
async def _find_pending_models_from_filesystem(self) -> List[Dict[str, Any]]:
|
||||
"""Scan filesystem for checkpoint metadata files with pending hash status."""
|
||||
# Centralized mode stores sidecars in the mirror tree, not next to the
|
||||
# models; walk the mirror instead of the model folders.
|
||||
if is_centralized():
|
||||
return self._find_pending_models_in_sidecar_mirror()
|
||||
|
||||
pending_models = []
|
||||
|
||||
for root_path in self.get_model_roots():
|
||||
|
||||
@@ -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,
|
||||
@@ -36,6 +38,7 @@ from ..utils.preview_selection import resolve_mature_threshold, select_preview_m
|
||||
from ..utils.utils import calculate_filename_for_model, sanitize_folder_name
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from ..utils.sidecar_paths import get_metadata_path, get_preview_dir
|
||||
from .service_registry import ServiceRegistry
|
||||
from .download_routing import is_diffusion_model_download, resolve_other_download_sub_type
|
||||
from .settings_manager import get_settings_manager
|
||||
@@ -829,7 +832,7 @@ class DownloadManager:
|
||||
)
|
||||
|
||||
for file_path in target_files:
|
||||
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
deleted = await self._delete_file_with_retries(metadata_path)
|
||||
if not deleted and os.path.exists(metadata_path):
|
||||
logger.error(f"Error deleting metadata file: {metadata_path}")
|
||||
@@ -2327,16 +2330,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":
|
||||
@@ -2435,7 +2448,7 @@ class DownloadManager:
|
||||
return {"success": False, "error": save_path}
|
||||
|
||||
part_path = save_path + ".part"
|
||||
metadata_path = os.path.splitext(save_path)[0] + ".metadata.json"
|
||||
metadata_path = get_metadata_path(save_path)
|
||||
|
||||
pause_control = self._pause_events.get(download_id) if download_id else None
|
||||
|
||||
@@ -2452,6 +2465,10 @@ class DownloadManager:
|
||||
# Download preview image if available
|
||||
images = version_info.get("images", [])
|
||||
if images:
|
||||
# Centralized preview mirrors may not exist yet (unlike the
|
||||
# model's own directory in alongside mode).
|
||||
os.makedirs(get_preview_dir(save_path), exist_ok=True)
|
||||
|
||||
if progress_callback:
|
||||
await progress_callback(
|
||||
1
|
||||
@@ -2491,7 +2508,10 @@ class DownloadManager:
|
||||
|
||||
if media_type == "video":
|
||||
preview_ext = _extension_from_url(preview_url, ".mp4")
|
||||
preview_path = os.path.splitext(save_path)[0] + preview_ext
|
||||
preview_path = os.path.join(
|
||||
get_preview_dir(save_path),
|
||||
os.path.splitext(os.path.basename(save_path))[0] + preview_ext,
|
||||
)
|
||||
rewritten_url, rewritten = rewrite_preview_url(
|
||||
preview_url, media_type="video"
|
||||
)
|
||||
@@ -2518,7 +2538,10 @@ class DownloadManager:
|
||||
)
|
||||
if rewritten and rewritten_url:
|
||||
preview_ext = _extension_from_url(preview_url, ".png")
|
||||
preview_path = os.path.splitext(save_path)[0] + preview_ext
|
||||
preview_path = os.path.join(
|
||||
get_preview_dir(save_path),
|
||||
os.path.splitext(os.path.basename(save_path))[0] + preview_ext,
|
||||
)
|
||||
success, _ = await downloader.download_file(
|
||||
rewritten_url, preview_path, use_auth=False
|
||||
)
|
||||
@@ -2545,8 +2568,9 @@ class DownloadManager:
|
||||
temp_file_handle.write(
|
||||
content if isinstance(content, bytes) else content.encode("utf-8")
|
||||
)
|
||||
preview_path = (
|
||||
os.path.splitext(save_path)[0] + ".webp"
|
||||
preview_path = os.path.join(
|
||||
get_preview_dir(save_path),
|
||||
os.path.splitext(os.path.basename(save_path))[0] + ".webp",
|
||||
)
|
||||
|
||||
optimized_data, _ = ExifUtils.optimize_image(
|
||||
@@ -2776,9 +2800,7 @@ class DownloadManager:
|
||||
entry = cast(Any, adjusted_entry)
|
||||
metadata_entries[index] = entry
|
||||
|
||||
metadata_file_path = (
|
||||
os.path.splitext(entry.file_path)[0] + ".metadata.json"
|
||||
)
|
||||
metadata_file_path = get_metadata_path(entry.file_path)
|
||||
metadata_files_for_cleanup.append(metadata_file_path)
|
||||
|
||||
await MetadataManager.save_metadata(entry.file_path, entry)
|
||||
@@ -3037,7 +3059,11 @@ class DownloadManager:
|
||||
extension = os.path.splitext(preview_path)[1] or ".webp"
|
||||
|
||||
targets = [
|
||||
os.path.splitext(entry.file_path)[0] + extension for entry in entries
|
||||
os.path.join(
|
||||
get_preview_dir(entry.file_path),
|
||||
os.path.splitext(os.path.basename(entry.file_path))[0] + extension,
|
||||
)
|
||||
for entry in entries
|
||||
]
|
||||
|
||||
if not targets:
|
||||
@@ -3045,10 +3071,12 @@ class DownloadManager:
|
||||
|
||||
first_target = targets[0]
|
||||
if preview_path != first_target:
|
||||
os.makedirs(os.path.dirname(first_target), exist_ok=True)
|
||||
os.replace(preview_path, first_target)
|
||||
source_path = first_target
|
||||
|
||||
for target in targets[1:]:
|
||||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||
shutil.copyfile(source_path, target)
|
||||
|
||||
return targets
|
||||
|
||||
@@ -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", ""),
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from ..services.settings_manager import SettingsManager
|
||||
from ..utils.civitai_utils import resolve_license_payload
|
||||
from ..utils.model_utils import determine_base_model
|
||||
from ..utils.models import autov3_from_civitai_files
|
||||
from ..utils.sidecar_paths import get_metadata_path
|
||||
from .connectivity_guard import OFFLINE_FRIENDLY_MESSAGE, is_expected_offline_error
|
||||
from .errors import RateLimitError
|
||||
from .model_sources import has_external_source
|
||||
@@ -216,7 +217,7 @@ class MetadataSyncService:
|
||||
logger.error(error)
|
||||
return False, error
|
||||
|
||||
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
enable_archive = self._settings.get("enable_metadata_archive_db", False)
|
||||
previous_source = model_data.get("metadata_source") or (model_data.get("civitai") or {}).get("source")
|
||||
|
||||
@@ -485,7 +486,7 @@ class MetadataSyncService:
|
||||
+ (f" with version: {model_version_id}" if model_version_id else "")
|
||||
)
|
||||
|
||||
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
await self.update_model_metadata(
|
||||
metadata_path,
|
||||
metadata,
|
||||
@@ -505,7 +506,7 @@ class MetadataSyncService:
|
||||
) -> Dict[str, Any]:
|
||||
"""Apply metadata updates and persist to disk and cache."""
|
||||
|
||||
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
metadata = await metadata_loader(metadata_path)
|
||||
|
||||
for key, value in updates.items():
|
||||
@@ -554,7 +555,7 @@ class MetadataSyncService:
|
||||
}
|
||||
|
||||
expected_hash: Optional[str] = None
|
||||
first_metadata_path = os.path.splitext(file_paths[0])[0] + ".metadata.json"
|
||||
first_metadata_path = get_metadata_path(file_paths[0])
|
||||
first_metadata = await metadata_loader(first_metadata_path)
|
||||
if first_metadata and "sha256" in first_metadata:
|
||||
expected_hash = first_metadata["sha256"].lower()
|
||||
@@ -565,7 +566,7 @@ class MetadataSyncService:
|
||||
|
||||
try:
|
||||
actual_hash = await hash_calculator(path)
|
||||
metadata_path = os.path.splitext(path)[0] + ".metadata.json"
|
||||
metadata_path = get_metadata_path(path)
|
||||
metadata = await metadata_loader(metadata_path)
|
||||
stored_hash = metadata.get("sha256", "").lower()
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from abc import ABC, abstractmethod
|
||||
|
||||
from ..utils.utils import calculate_relative_path_for_model, remove_empty_dirs
|
||||
from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE, MODEL_FILE_EXTENSIONS
|
||||
from ..utils.sidecar_paths import is_centralized, resolve_centralized_dir_for_dir
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
from ..services.model_lifecycle_service import _require_path_in_library_roots
|
||||
from ..services.pending_delete_service import PENDING_DELETE_DIR_NAME
|
||||
@@ -15,6 +16,16 @@ from ..services.pending_delete_service import PENDING_DELETE_DIR_NAME
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_match_path(path: Any) -> str:
|
||||
"""Normalize a path for set membership tests.
|
||||
|
||||
Business paths only — symlinks are never resolved here, matching how the
|
||||
scanner stores ``excluded_models``. The forward-slash form keeps Windows
|
||||
comparisons working with the scanner's normalized entries.
|
||||
"""
|
||||
return os.path.normpath(os.path.abspath(str(path))).replace(os.sep, "/")
|
||||
|
||||
|
||||
class ProgressCallback(ABC):
|
||||
"""Abstract callback interface for progress reporting"""
|
||||
|
||||
@@ -567,8 +578,9 @@ class ModelMoveService:
|
||||
|
||||
Returns:
|
||||
Dictionary with the success flag plus a removal manifest
|
||||
(``model_count``/``file_count``/``dir_count``/``symlink_count``/
|
||||
``total_bytes``/``restorable``) on success.
|
||||
(``model_count``/``excluded_model_count``/``file_count``/
|
||||
``dir_count``/``symlink_count``/``total_bytes``/``restorable``)
|
||||
on success.
|
||||
"""
|
||||
try:
|
||||
if not folder_path or not str(folder_path).strip():
|
||||
@@ -608,13 +620,34 @@ class ModelMoveService:
|
||||
}
|
||||
|
||||
if manifest["model_count"] > 0:
|
||||
model_count = manifest["model_count"]
|
||||
excluded_count = manifest["excluded_model_count"]
|
||||
# Excluded models are hidden from the library lists but are
|
||||
# still real weight files, so they block the cascade just like
|
||||
# any other model. Naming them is what makes the refusal
|
||||
# actionable: the folder looks empty in the sidebar precisely
|
||||
# because everything in it is excluded.
|
||||
if excluded_count == model_count:
|
||||
error = (
|
||||
f"Folder still contains {model_count} model file(s), "
|
||||
"all excluded from the library; un-exclude or delete "
|
||||
"them first"
|
||||
)
|
||||
elif excluded_count:
|
||||
error = (
|
||||
f"Folder still contains {model_count} model file(s), "
|
||||
f"{excluded_count} of them excluded from the library; "
|
||||
"delete or move them first"
|
||||
)
|
||||
else:
|
||||
error = (
|
||||
f"Folder still contains {model_count} model "
|
||||
"file(s); delete or move them first"
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"code": "not_empty",
|
||||
"error": (
|
||||
f"Folder still contains {manifest['model_count']} model "
|
||||
"file(s); delete or move them first"
|
||||
),
|
||||
"error": error,
|
||||
"manifest": manifest,
|
||||
}
|
||||
|
||||
@@ -631,6 +664,21 @@ class ModelMoveService:
|
||||
|
||||
shutil.rmtree(absolute_path)
|
||||
|
||||
# Centralized mode: prune the folder's mirror subtree when it no
|
||||
# longer holds any sidecar files (per-model deletes already
|
||||
# removed their sidecars, so only empty directories are expected;
|
||||
# a non-empty mirror keeps its orphan sidecars).
|
||||
if is_centralized():
|
||||
mirror_dir = resolve_centralized_dir_for_dir(absolute_path)
|
||||
if mirror_dir and os.path.isdir(mirror_dir):
|
||||
for root, _dirs, files in os.walk(mirror_dir, topdown=False):
|
||||
if files:
|
||||
continue
|
||||
try:
|
||||
os.rmdir(root)
|
||||
except OSError: # pragma: no cover - best-effort cleanup
|
||||
pass
|
||||
|
||||
await self._forget_folder(relative_folder)
|
||||
|
||||
return {
|
||||
@@ -667,13 +715,20 @@ class ModelMoveService:
|
||||
Symbolic links are never followed (``os.walk`` default) and are counted
|
||||
separately — ``shutil.rmtree`` unlinks them without touching their
|
||||
targets.
|
||||
|
||||
``excluded_model_count`` splits the subset of ``model_count`` that the
|
||||
library hides behind the ``exclude`` flag: those files still block the
|
||||
delete, yet they are invisible to the model lists (and therefore to the
|
||||
folder tree, which derives "empty" from them).
|
||||
"""
|
||||
model_count = 0
|
||||
excluded_model_count = 0
|
||||
file_count = 0
|
||||
dir_count = 0
|
||||
symlink_count = 0
|
||||
total_bytes = 0
|
||||
pending_delete_job = False
|
||||
excluded_paths = self._excluded_model_paths()
|
||||
|
||||
for dirpath, dirnames, filenames in os.walk(absolute_path):
|
||||
if PENDING_DELETE_DIR_NAME in dirnames:
|
||||
@@ -692,6 +747,8 @@ class ModelMoveService:
|
||||
continue
|
||||
if self._is_model_file(name):
|
||||
model_count += 1
|
||||
if _normalize_match_path(full_path) in excluded_paths:
|
||||
excluded_model_count += 1
|
||||
else:
|
||||
file_count += 1
|
||||
try:
|
||||
@@ -701,6 +758,7 @@ class ModelMoveService:
|
||||
|
||||
return {
|
||||
"model_count": model_count,
|
||||
"excluded_model_count": excluded_model_count,
|
||||
"file_count": file_count,
|
||||
"dir_count": dir_count,
|
||||
"symlink_count": symlink_count,
|
||||
@@ -716,6 +774,21 @@ class ModelMoveService:
|
||||
),
|
||||
}
|
||||
|
||||
def _excluded_model_paths(self) -> Set[str]:
|
||||
"""Absolute paths of the models the library hides behind ``exclude``.
|
||||
|
||||
Best-effort: scanner stand-ins that do not expose the accessor simply
|
||||
report no excluded models.
|
||||
"""
|
||||
get_excluded = getattr(self.scanner, "get_excluded_models", None)
|
||||
if not callable(get_excluded):
|
||||
return set()
|
||||
try:
|
||||
paths = get_excluded() or []
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return set()
|
||||
return {_normalize_match_path(path) for path in paths if path}
|
||||
|
||||
async def _forget_folder(self, relative_folder: str) -> None:
|
||||
"""Drop a removed directory from the scanner's folder/cache records."""
|
||||
if not relative_folder:
|
||||
|
||||
@@ -11,6 +11,7 @@ from ..services.service_registry import ServiceRegistry
|
||||
from ..services.pending_delete_service import get_pending_delete_service
|
||||
from ..utils.constants import PREVIEW_EXTENSIONS
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from ..utils.sidecar_paths import get_metadata_path, get_preview_dir, get_sidecar_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -41,16 +42,22 @@ async def load_local_metadata(metadata_path: str) -> Dict[str, Any]:
|
||||
async def delete_model_artifacts(
|
||||
target_dir: str, file_name: str, main_extension: str | None = None
|
||||
) -> List[str]:
|
||||
"""Delete the primary model artefacts within ``target_dir``."""
|
||||
"""Delete the primary model artefacts within ``target_dir``.
|
||||
|
||||
Sidecars and previews are taken from the model's sidecar directory — the
|
||||
model's own directory in alongside mode, the centralized mirror otherwise.
|
||||
"""
|
||||
|
||||
main_extension = ".safetensors" if main_extension is None else main_extension
|
||||
main_file = f"{file_name}{main_extension}" if main_extension else file_name
|
||||
patterns = [main_file, f"{file_name}.metadata.json"]
|
||||
model_path = os.path.join(target_dir, main_file)
|
||||
sidecar_dir = get_sidecar_dir(model_path)
|
||||
patterns = [os.path.basename(get_metadata_path(model_path))]
|
||||
for ext in PREVIEW_EXTENSIONS:
|
||||
patterns.append(f"{file_name}{ext}")
|
||||
|
||||
deleted: List[str] = []
|
||||
main_path = os.path.join(target_dir, main_file).replace(os.sep, "/")
|
||||
main_path = model_path.replace(os.sep, "/")
|
||||
|
||||
if os.path.exists(main_path):
|
||||
os.remove(main_path)
|
||||
@@ -58,8 +65,8 @@ async def delete_model_artifacts(
|
||||
else:
|
||||
logger.warning("Model file not found: %s", main_file)
|
||||
|
||||
for pattern in patterns[1:]:
|
||||
path = os.path.join(target_dir, pattern)
|
||||
for pattern in patterns:
|
||||
path = os.path.join(sidecar_dir, pattern)
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
os.remove(path)
|
||||
@@ -260,7 +267,7 @@ class ModelLifecycleService:
|
||||
|
||||
_require_path_in_library_roots(file_path, self._scanner, label="File path")
|
||||
|
||||
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
metadata = await self._metadata_loader(metadata_path)
|
||||
metadata["exclude"] = True
|
||||
|
||||
@@ -315,7 +322,7 @@ class ModelLifecycleService:
|
||||
if not os.path.exists(file_path):
|
||||
raise ValueError("Model file does not exist")
|
||||
|
||||
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
metadata_payload = await self._metadata_loader(metadata_path)
|
||||
metadata_payload["exclude"] = False
|
||||
|
||||
@@ -384,21 +391,26 @@ class ModelLifecycleService:
|
||||
if os.path.exists(new_file_path):
|
||||
raise ValueError("A file with this name already exists")
|
||||
|
||||
patterns = [
|
||||
f"{old_file_name}{old_extension}",
|
||||
f"{old_file_name}.metadata.json",
|
||||
f"{old_file_name}.metadata.json.bak",
|
||||
metadata_filename = os.path.basename(get_metadata_path(file_path))
|
||||
# Sidecars/previews live in the sidecar dir (the model's own dir in
|
||||
# alongside mode, the centralized mirror otherwise); the model file
|
||||
# itself always stays in target_dir.
|
||||
sidecar_dir = get_sidecar_dir(file_path)
|
||||
patterns: List[tuple[str, str]] = [
|
||||
(target_dir, f"{old_file_name}{old_extension}"),
|
||||
(sidecar_dir, metadata_filename),
|
||||
(sidecar_dir, f"{metadata_filename}.bak"),
|
||||
]
|
||||
for ext in PREVIEW_EXTENSIONS:
|
||||
patterns.append(f"{old_file_name}{ext}")
|
||||
patterns.append((sidecar_dir, f"{old_file_name}{ext}"))
|
||||
|
||||
existing_files: List[tuple[str, str]] = []
|
||||
for pattern in patterns:
|
||||
path = os.path.join(target_dir, pattern)
|
||||
for pattern_dir, pattern in patterns:
|
||||
path = os.path.join(pattern_dir, pattern)
|
||||
if os.path.exists(path):
|
||||
existing_files.append((path, pattern))
|
||||
|
||||
metadata_path = os.path.join(target_dir, f"{old_file_name}.metadata.json")
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
metadata: Optional[Dict[str, object]] = None
|
||||
hash_value: Optional[str] = None
|
||||
|
||||
@@ -413,9 +425,9 @@ class ModelLifecycleService:
|
||||
|
||||
for old_path, pattern in existing_files:
|
||||
ext = self._get_multipart_ext(pattern)
|
||||
new_path = os.path.join(target_dir, f"{new_file_name}{ext}").replace(
|
||||
os.sep, "/"
|
||||
)
|
||||
new_path = os.path.join(
|
||||
os.path.dirname(old_path), f"{new_file_name}{ext}"
|
||||
).replace(os.sep, "/")
|
||||
os.rename(old_path, new_path)
|
||||
renamed_files.append(new_path)
|
||||
|
||||
@@ -432,9 +444,9 @@ class ModelLifecycleService:
|
||||
if metadata.get("preview_url"):
|
||||
old_preview = str(metadata["preview_url"])
|
||||
ext = self._get_multipart_ext(old_preview)
|
||||
new_preview = os.path.join(target_dir, f"{new_file_name}{ext}").replace(
|
||||
os.sep, "/"
|
||||
)
|
||||
new_preview = os.path.join(
|
||||
get_preview_dir(new_file_path), f"{new_file_name}{ext}"
|
||||
).replace(os.sep, "/")
|
||||
metadata["preview_url"] = new_preview
|
||||
|
||||
await self._metadata_manager.save_metadata(new_file_path, metadata)
|
||||
|
||||
+177
-28
@@ -11,6 +11,13 @@ from ..utils.models import BaseModelMetadata, autov3_from_civitai_files
|
||||
from ..config import config
|
||||
from ..utils.file_utils import find_preview_file, get_preview_extension, calculate_sha256, calculate_autov3
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from ..utils.sidecar_paths import (
|
||||
get_metadata_path,
|
||||
get_preview_dir,
|
||||
get_sidecar_dir,
|
||||
is_centralized,
|
||||
resolve_centralized_dir_for_dir,
|
||||
)
|
||||
from ..utils.civitai_utils import resolve_license_info
|
||||
from .model_cache import ModelCache
|
||||
from .model_hash_index import ModelHashIndex
|
||||
@@ -399,10 +406,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)
|
||||
|
||||
@@ -1609,6 +1620,25 @@ class ModelScanner:
|
||||
old_abs_prefix = f"{str(previous_path).replace(chr(92), '/').rstrip('/')}/"
|
||||
new_abs_prefix = f"{str(new_path).replace(chr(92), '/').rstrip('/')}/"
|
||||
|
||||
# Centralized sidecar mode: sidecars/previews live in the mirror tree,
|
||||
# not under the renamed model directory, so the mirror subtree must
|
||||
# move too and mirror-prefixed preview URLs need their own rekey.
|
||||
old_mirror_dir: Optional[str] = None
|
||||
new_mirror_dir: Optional[str] = None
|
||||
if is_centralized():
|
||||
old_mirror_dir = resolve_centralized_dir_for_dir(str(previous_path))
|
||||
new_mirror_dir = resolve_centralized_dir_for_dir(str(new_path))
|
||||
old_mirror_prefix = (
|
||||
f"{old_mirror_dir.replace(chr(92), '/').rstrip('/')}/"
|
||||
if old_mirror_dir
|
||||
else ""
|
||||
)
|
||||
new_mirror_prefix = (
|
||||
f"{new_mirror_dir.replace(chr(92), '/').rstrip('/')}/"
|
||||
if new_mirror_dir
|
||||
else ""
|
||||
)
|
||||
|
||||
cache = self._cache
|
||||
if cache is None:
|
||||
return False
|
||||
@@ -1666,8 +1696,24 @@ class ModelScanner:
|
||||
item["preview_url"] = self._rekey_path(
|
||||
item["preview_url"], old_abs_prefix, new_abs_prefix
|
||||
)
|
||||
if old_mirror_prefix:
|
||||
item["preview_url"] = self._rekey_path(
|
||||
item["preview_url"], old_mirror_prefix, new_mirror_prefix
|
||||
)
|
||||
touched.append(item)
|
||||
|
||||
if old_mirror_dir and new_mirror_dir and os.path.isdir(old_mirror_dir):
|
||||
try:
|
||||
os.makedirs(os.path.dirname(new_mirror_dir), exist_ok=True)
|
||||
shutil.move(old_mirror_dir, new_mirror_dir)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.warning(
|
||||
"Failed to move centralized sidecar mirror %s -> %s: %s",
|
||||
old_mirror_dir,
|
||||
new_mirror_dir,
|
||||
exc,
|
||||
)
|
||||
|
||||
if touched:
|
||||
changed = True
|
||||
await self._rewrite_sidecar_paths(touched)
|
||||
@@ -1696,7 +1742,9 @@ class ModelScanner:
|
||||
async def _rewrite_sidecar_paths(self, entries: List[Dict[str, Any]]) -> None:
|
||||
"""Point each model's metadata sidecar at its new location.
|
||||
|
||||
Sidecars travel with the renamed directory, so only the recorded
|
||||
In alongside mode sidecars travel with the renamed directory; in
|
||||
centralized mode the mirror subtree has already been moved by the
|
||||
caller (:meth:`rename_known_folder`). Either way only the recorded
|
||||
``file_path``/``preview_url`` inside them need rewriting. Failures are
|
||||
logged and skipped — a stale sidecar is repaired by the next metadata
|
||||
refresh, and must not abort the rename.
|
||||
@@ -1705,7 +1753,7 @@ class ModelScanner:
|
||||
file_path = item.get("file_path")
|
||||
if not file_path:
|
||||
continue
|
||||
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
if not os.path.exists(metadata_path):
|
||||
continue
|
||||
try:
|
||||
@@ -1715,6 +1763,94 @@ class ModelScanner:
|
||||
"Failed to rewrite metadata sidecar %s: %s", metadata_path, exc
|
||||
)
|
||||
|
||||
def _find_pending_models_in_sidecar_mirror(self) -> List[Dict[str, Any]]:
|
||||
"""Mirror-tree counterpart of the alongside pending-hash filesystem scan.
|
||||
|
||||
Centralized mode stores ``.metadata.json`` sidecars in the mirror
|
||||
tree, so walking the model folders finds nothing. Each mirror base is
|
||||
resolved from a configured model root; a sidecar's recorded
|
||||
``file_path`` locates its model, with a stem-based probe under the
|
||||
mapped model root as fallback (mirror path components are sanitized,
|
||||
so reverse mapping is best-effort). Orphan sidecars whose model file
|
||||
no longer exists are skipped, matching the alongside scan.
|
||||
"""
|
||||
|
||||
pending_models: List[Dict[str, Any]] = []
|
||||
|
||||
for root_path in self.get_model_roots():
|
||||
mirror_base = resolve_centralized_dir_for_dir(root_path)
|
||||
if not mirror_base or not os.path.isdir(mirror_base):
|
||||
continue
|
||||
|
||||
for dirpath, dirnames, filenames in os.walk(mirror_base):
|
||||
dirnames[:] = [d for d in dirnames if not _is_excluded_dir(d)]
|
||||
for filename in filenames:
|
||||
if not filename.endswith(".metadata.json"):
|
||||
continue
|
||||
|
||||
metadata_path = os.path.join(dirpath, filename)
|
||||
try:
|
||||
with open(metadata_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Check if hash is pending
|
||||
hash_status = data.get("hash_status", "completed")
|
||||
sha256 = data.get("sha256", "")
|
||||
|
||||
if hash_status != "completed" or not sha256:
|
||||
# Find corresponding model file: prefer the
|
||||
# sidecar's recorded path, then probe by stem
|
||||
# under the mapped model root.
|
||||
model_path = None
|
||||
recorded_path = data.get("file_path")
|
||||
if (
|
||||
isinstance(recorded_path, str)
|
||||
and recorded_path
|
||||
and os.path.exists(recorded_path)
|
||||
):
|
||||
model_path = recorded_path
|
||||
else:
|
||||
model_name = filename.replace(".metadata.json", "")
|
||||
rel_dir = os.path.relpath(dirpath, mirror_base)
|
||||
candidate_dir = (
|
||||
root_path
|
||||
if rel_dir == os.curdir
|
||||
else os.path.join(root_path, rel_dir)
|
||||
)
|
||||
for ext in self.file_extensions:
|
||||
potential_path = os.path.join(
|
||||
candidate_dir, model_name + ext
|
||||
)
|
||||
if os.path.exists(potential_path):
|
||||
model_path = potential_path
|
||||
break
|
||||
|
||||
if model_path:
|
||||
pending_models.append(
|
||||
{
|
||||
"file_path": model_path.replace(os.sep, "/"),
|
||||
"hash_status": hash_status,
|
||||
"sha256": sha256,
|
||||
**{
|
||||
k: v
|
||||
for k, v in data.items()
|
||||
if k
|
||||
not in [
|
||||
"file_path",
|
||||
"hash_status",
|
||||
"sha256",
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
except (json.JSONDecodeError, Exception) as e:
|
||||
logger.debug(
|
||||
f"Error reading metadata file {metadata_path}: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
return pending_models
|
||||
|
||||
def _schedule_all_folders_backfill(self) -> None:
|
||||
"""Kick off a one-shot background folder walk if none is running."""
|
||||
if self._all_folders_backfill_running:
|
||||
@@ -1885,7 +2021,7 @@ class ModelScanner:
|
||||
file_info['name'] = os.path.basename(file_path)
|
||||
|
||||
metadata = cast(Any, self.model_class).from_civitai_info(version_info, file_info, file_path)
|
||||
metadata.preview_url = find_preview_file(local_stem, os.path.dirname(file_path))
|
||||
metadata.preview_url = find_preview_file(local_stem, get_preview_dir(file_path))
|
||||
await MetadataManager.save_metadata(file_path, metadata)
|
||||
logger.info(f"Created metadata from .civitai.info for {file_path} (Reason: .civitai.info was found but .metadata.json was missing)")
|
||||
except Exception as e:
|
||||
@@ -2322,38 +2458,51 @@ class ModelScanner:
|
||||
# Move all associated files with the same base name
|
||||
source_metadata = None
|
||||
moved_metadata_path = None
|
||||
|
||||
# Find all files with the same base name in the source directory
|
||||
|
||||
# Associated files (sidecar metadata, previews) sit next to the
|
||||
# model in alongside mode and in the mirror tree in centralized
|
||||
# mode; collect from every directory that holds them.
|
||||
source_sidecar_dir = get_sidecar_dir(source_path)
|
||||
target_sidecar_dir = get_sidecar_dir(target_file)
|
||||
associated_dirs = [(source_dir, target_path)]
|
||||
if os.path.normpath(source_sidecar_dir) != os.path.normpath(source_dir):
|
||||
associated_dirs.append((source_sidecar_dir, target_sidecar_dir))
|
||||
|
||||
# Find all files with the same base name in the source directories
|
||||
files_to_move = []
|
||||
try:
|
||||
for file in os.listdir(source_dir):
|
||||
if file.startswith(base_name + ".") and file != os.path.basename(source_path):
|
||||
source_file_path = os.path.join(source_dir, file)
|
||||
# Generate new filename with the same base name as the model file
|
||||
file_suffix = file[len(base_name):] # Get the part after base_name (e.g., ".metadata.json", ".preview.png")
|
||||
new_associated_filename = f"{final_base_name}{file_suffix}"
|
||||
target_associated_path = os.path.join(target_path, new_associated_filename)
|
||||
|
||||
# Store metadata file path for special handling
|
||||
if file == f"{base_name}.metadata.json":
|
||||
source_metadata = source_file_path
|
||||
moved_metadata_path = target_associated_path
|
||||
else:
|
||||
files_to_move.append((source_file_path, target_associated_path))
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing files in {source_dir}: {e}")
|
||||
|
||||
metadata_filename = os.path.basename(get_metadata_path(source_path))
|
||||
for assoc_source_dir, assoc_target_dir in associated_dirs:
|
||||
try:
|
||||
for file in os.listdir(assoc_source_dir):
|
||||
if file.startswith(base_name + ".") and file != os.path.basename(source_path):
|
||||
source_file_path = os.path.join(assoc_source_dir, file)
|
||||
# Generate new filename with the same base name as the model file
|
||||
file_suffix = file[len(base_name):] # Get the part after base_name (e.g., ".metadata.json", ".preview.png")
|
||||
new_associated_filename = f"{final_base_name}{file_suffix}"
|
||||
target_associated_path = os.path.join(assoc_target_dir, new_associated_filename)
|
||||
|
||||
# Store metadata file path for special handling
|
||||
if file == metadata_filename:
|
||||
source_metadata = source_file_path
|
||||
moved_metadata_path = target_associated_path
|
||||
else:
|
||||
files_to_move.append((source_file_path, target_associated_path))
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing files in {assoc_source_dir}: {e}")
|
||||
|
||||
# Move all associated files
|
||||
metadata = None
|
||||
for source_file, target_file_path in files_to_move:
|
||||
try:
|
||||
os.makedirs(os.path.dirname(target_file_path), exist_ok=True)
|
||||
shutil.move(source_file, target_file_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Error moving associated file {source_file}: {e}")
|
||||
|
||||
|
||||
# Handle metadata file specially to update paths
|
||||
if source_metadata and moved_metadata_path and os.path.exists(source_metadata):
|
||||
try:
|
||||
os.makedirs(os.path.dirname(moved_metadata_path), exist_ok=True)
|
||||
shutil.move(source_metadata, moved_metadata_path)
|
||||
metadata = await self._update_metadata_paths(moved_metadata_path, target_file)
|
||||
except Exception as e:
|
||||
@@ -2395,7 +2544,7 @@ class ModelScanner:
|
||||
metadata['file_name'] = os.path.splitext(os.path.basename(model_path))[0]
|
||||
|
||||
if 'preview_url' in metadata and metadata['preview_url']:
|
||||
preview_dir = os.path.dirname(model_path)
|
||||
preview_dir = get_preview_dir(model_path)
|
||||
# Update preview filename to match the new base name
|
||||
new_base_name = os.path.splitext(os.path.basename(model_path))[0]
|
||||
preview_ext = get_preview_extension(metadata['preview_url'])
|
||||
@@ -2755,7 +2904,7 @@ class ModelScanner:
|
||||
|
||||
# Sidecar write-back: JSON null encodes the checked-unavailable
|
||||
# state. Skip silently when the sidecar does not exist.
|
||||
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
if os.path.exists(metadata_path):
|
||||
with open(metadata_path, 'r', encoding='utf-8') as handle:
|
||||
payload = json.load(handle)
|
||||
@@ -2821,7 +2970,7 @@ class ModelScanner:
|
||||
if not file_path:
|
||||
return None
|
||||
|
||||
dir_path = os.path.dirname(file_path)
|
||||
dir_path = get_preview_dir(file_path)
|
||||
base_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
preview_path = find_preview_file(base_name, dir_path)
|
||||
if preview_path:
|
||||
|
||||
@@ -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", ""),
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any, Dict, List, Optional
|
||||
from ..utils.models import OtherModelMetadata
|
||||
from ..utils.file_utils import find_preview_file, normalize_path, calculate_autov3
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from ..utils.sidecar_paths import get_preview_dir, is_centralized
|
||||
from ..config import config
|
||||
from .model_scanner import ModelScanner, _is_excluded_dir
|
||||
from .model_hash_index import ModelHashIndex
|
||||
@@ -72,10 +73,9 @@ class OtherScanner(ModelScanner):
|
||||
return None
|
||||
|
||||
base_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
dir_path = os.path.dirname(file_path)
|
||||
|
||||
# Find preview image
|
||||
preview_url = find_preview_file(base_name, dir_path)
|
||||
preview_url = find_preview_file(base_name, get_preview_dir(file_path))
|
||||
|
||||
# AutoV3 reads only the safetensors header, so it is cheap even for
|
||||
# large files; record the checked state at creation time ("" =
|
||||
@@ -333,6 +333,11 @@ class OtherScanner(ModelScanner):
|
||||
|
||||
async def _find_pending_models_from_filesystem(self) -> List[Dict[str, Any]]:
|
||||
"""Scan filesystem for other-model metadata files with pending hash status."""
|
||||
# Centralized mode stores sidecars in the mirror tree, not next to the
|
||||
# models; walk the mirror instead of the model folders.
|
||||
if is_centralized():
|
||||
return self._find_pending_models_in_sidecar_mirror()
|
||||
|
||||
pending_models = []
|
||||
|
||||
for root_path in self.get_model_roots():
|
||||
|
||||
@@ -38,6 +38,7 @@ from typing import (
|
||||
)
|
||||
|
||||
from ..utils.constants import PREVIEW_EXTENSIONS
|
||||
from ..utils.sidecar_paths import get_metadata_path, get_sidecar_dir
|
||||
from ..utils import settings_paths
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -669,13 +670,22 @@ class PendingDeleteService:
|
||||
"""Enumerate existing artifacts exactly like delete_model_artifacts."""
|
||||
main_extension = ".safetensors" if main_extension is None else main_extension
|
||||
main_file = f"{file_name}{main_extension}" if main_extension else file_name
|
||||
patterns = [main_file, f"{file_name}.metadata.json"]
|
||||
model_path = os.path.join(target_dir, main_file)
|
||||
|
||||
artifacts: List[str] = []
|
||||
main_path = os.path.abspath(model_path)
|
||||
if os.path.exists(main_path):
|
||||
artifacts.append(main_path)
|
||||
|
||||
# Sidecars/previews live in the sidecar dir (the model's own dir in
|
||||
# alongside mode, the centralized mirror otherwise).
|
||||
sidecar_dir = get_sidecar_dir(model_path)
|
||||
patterns = [os.path.basename(get_metadata_path(model_path))]
|
||||
for ext in PREVIEW_EXTENSIONS:
|
||||
patterns.append(f"{file_name}{ext}")
|
||||
|
||||
artifacts: List[str] = []
|
||||
for pattern in patterns:
|
||||
path = os.path.abspath(os.path.join(target_dir, pattern))
|
||||
path = os.path.abspath(os.path.join(sidecar_dir, pattern))
|
||||
if os.path.exists(path):
|
||||
artifacts.append(path)
|
||||
return artifacts
|
||||
@@ -694,7 +704,9 @@ class PendingDeleteService:
|
||||
"""
|
||||
for original_path in artifacts:
|
||||
staged_path = os.path.join(batch_dir, os.path.basename(original_path))
|
||||
os.rename(original_path, staged_path)
|
||||
# EXDEV-tolerant: centralized sidecars may live on a different
|
||||
# filesystem than the staging batch dir under the model root.
|
||||
self._restore_file(original_path, staged_path)
|
||||
staged_pairs.append(
|
||||
{
|
||||
"staged": os.path.abspath(staged_path),
|
||||
@@ -736,13 +748,14 @@ class PendingDeleteService:
|
||||
return staged_pairs
|
||||
|
||||
def _restore_file(self, staged_path: str, original_path: str) -> None:
|
||||
"""Restore a staged file to its original path, tolerating EXDEV.
|
||||
"""Move a file between staging and library paths, tolerating EXDEV.
|
||||
|
||||
``os.rename`` is atomic and preferred (model staging and most recipe
|
||||
restores are same-volume). Recipe staging copies into the settings-dir
|
||||
staging parent, which may live on a DIFFERENT filesystem than the
|
||||
recipes dir; rename then raises EXDEV. Fall back to ``shutil.copy2`` +
|
||||
``os.remove`` so the bytes are restored and the staged copy removed.
|
||||
staging parent, and centralized sidecars live under the configured
|
||||
sidecar root; both may live on a DIFFERENT filesystem than the target
|
||||
dir, so rename can raise EXDEV. Fall back to ``shutil.copy2`` +
|
||||
``os.remove`` so the bytes are moved and the source copy removed.
|
||||
"""
|
||||
try:
|
||||
os.rename(staged_path, original_path)
|
||||
@@ -764,7 +777,10 @@ class PendingDeleteService:
|
||||
if not os.path.exists(staged_path):
|
||||
continue
|
||||
try:
|
||||
os.rename(staged_path, original_path)
|
||||
# EXDEV-tolerant: centralized sidecars may have been copied
|
||||
# across filesystems into staging, so plain os.rename would
|
||||
# fail here and strand the only copy.
|
||||
self._restore_file(staged_path, original_path)
|
||||
except OSError as exc: # pragma: no cover - best-effort rollback
|
||||
logger.warning(
|
||||
"Failed to roll back staged file %s -> %s: %s",
|
||||
|
||||
@@ -68,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"] = {}
|
||||
@@ -214,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.
|
||||
@@ -579,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)
|
||||
);
|
||||
|
||||
@@ -648,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",
|
||||
}
|
||||
|
||||
@@ -735,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:
|
||||
|
||||
@@ -10,6 +10,7 @@ from urllib.parse import urlparse
|
||||
from ..utils.constants import CARD_PREVIEW_WIDTH, PREVIEW_EXTENSIONS
|
||||
from ..utils.civitai_utils import rewrite_preview_url
|
||||
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
|
||||
from ..utils.sidecar_paths import get_metadata_path, get_preview_dir
|
||||
from .settings_manager import get_settings_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -63,6 +64,9 @@ class PreviewAssetService:
|
||||
|
||||
base_name = os.path.splitext(os.path.splitext(os.path.basename(metadata_path))[0])[0]
|
||||
preview_dir = os.path.dirname(metadata_path)
|
||||
# Centralized mirrors may not exist yet (unlike the model's own
|
||||
# directory in alongside mode).
|
||||
os.makedirs(preview_dir, exist_ok=True)
|
||||
is_video = first_preview.get("type") == "video"
|
||||
preview_url = first_preview.get("url")
|
||||
|
||||
@@ -159,7 +163,10 @@ class PreviewAssetService:
|
||||
"""Replace an existing preview asset for a model."""
|
||||
|
||||
base_name = os.path.splitext(os.path.basename(model_path))[0]
|
||||
folder = os.path.dirname(model_path)
|
||||
folder = get_preview_dir(model_path)
|
||||
# Centralized mirrors may not exist yet (unlike the model's own
|
||||
# directory in alongside mode).
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
|
||||
extension, optimized_data = await self._convert_preview(
|
||||
preview_data, content_type, original_filename
|
||||
@@ -179,7 +186,7 @@ class PreviewAssetService:
|
||||
with open(preview_path, "wb") as handle:
|
||||
handle.write(optimized_data)
|
||||
|
||||
metadata_path = os.path.splitext(model_path)[0] + ".metadata.json"
|
||||
metadata_path = get_metadata_path(model_path)
|
||||
metadata = await metadata_loader(metadata_path)
|
||||
metadata["preview_url"] = preview_path
|
||||
metadata["preview_nsfw_level"] = nsfw_level
|
||||
|
||||
@@ -47,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,
|
||||
)
|
||||
@@ -65,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 +99,8 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"enable_other_models": False,
|
||||
"enabled_other_sub_types": list(DEFAULT_ENABLED_OTHER_SUB_TYPES),
|
||||
"recipes_path": "",
|
||||
"sidecar_storage_mode": "alongside",
|
||||
"sidecar_storage_path": "",
|
||||
"base_model_path_mappings": {},
|
||||
"download_path_templates": {},
|
||||
"download_filename_templates": {},
|
||||
@@ -1122,6 +1127,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",
|
||||
@@ -1569,9 +1583,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]]:
|
||||
@@ -1598,9 +1618,30 @@ class SettingsManager:
|
||||
|
||||
return os.path.abspath(os.path.normpath(os.path.expanduser(stripped)))
|
||||
|
||||
@staticmethod
|
||||
def _normalize_sidecar_storage_mode(value: Any) -> str:
|
||||
"""Return a valid sidecar storage mode, falling back to ``alongside``."""
|
||||
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in ("alongside", "centralized"):
|
||||
return normalized
|
||||
return "alongside"
|
||||
|
||||
def _refresh_sidecar_storage_config(self) -> None:
|
||||
"""Rebuild dependent config state after sidecar storage settings change."""
|
||||
|
||||
try:
|
||||
from ..config import config # Local import to avoid circular dependency
|
||||
|
||||
config.refresh_preview_roots()
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.debug(
|
||||
"Failed to refresh config after sidecar storage change: %s", exc
|
||||
)
|
||||
|
||||
def _get_effective_recipes_dir(self, recipes_path: Optional[str] = None) -> str:
|
||||
"""Resolve the effective recipes directory for the active library."""
|
||||
|
||||
normalized_custom = self._normalize_recipes_path_value(
|
||||
self.settings.get("recipes_path", "")
|
||||
if recipes_path is None
|
||||
@@ -1797,6 +1838,10 @@ class SettingsManager:
|
||||
target_recipes_dir = self._get_effective_recipes_dir(value)
|
||||
self._validate_recipes_storage_path(target_recipes_dir)
|
||||
self._migrate_recipes_directory(current_recipes_dir, target_recipes_dir)
|
||||
elif key == "sidecar_storage_mode":
|
||||
value = self._normalize_sidecar_storage_mode(value)
|
||||
elif key == "sidecar_storage_path":
|
||||
value = self._normalize_recipes_path_value(value)
|
||||
self.settings[key] = value
|
||||
portable_switch_pending = False
|
||||
if key == "use_portable_settings" and isinstance(value, bool):
|
||||
@@ -1827,6 +1872,8 @@ class SettingsManager:
|
||||
self._save_settings()
|
||||
if key == "recipes_path":
|
||||
self._notify_library_change(self.get_active_library_name())
|
||||
if key in ("sidecar_storage_mode", "sidecar_storage_path"):
|
||||
self._refresh_sidecar_storage_config()
|
||||
if key in ("enable_other_models", "enabled_other_sub_types"):
|
||||
self._apply_other_model_settings_change()
|
||||
if portable_switch_pending:
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from typing import Awaitable, Callable, Dict, List, Sequence, Tuple
|
||||
|
||||
from ..utils.sidecar_paths import get_metadata_path
|
||||
from .auto_tag_service import extract_auto_tags
|
||||
|
||||
|
||||
@@ -24,8 +23,7 @@ class TagUpdateService:
|
||||
update_cache: Callable[[str, str, Dict[str, object]], Awaitable[bool]],
|
||||
) -> Tuple[List[str], List[str]]:
|
||||
"""Add tags to a metadata entry and return updated tags and auto_tags."""
|
||||
base, _ = os.path.splitext(file_path)
|
||||
metadata_path = f"{base}.metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
metadata = await metadata_loader(metadata_path)
|
||||
|
||||
raw_tags = metadata.get("tags", [])
|
||||
|
||||
@@ -21,6 +21,10 @@ from .example_images import (
|
||||
ImportExampleImagesValidationError,
|
||||
)
|
||||
from .filename_template_use_case import FilenameTemplateUseCase
|
||||
from .sidecar_migration_use_case import (
|
||||
SidecarMigrationProgressReporter,
|
||||
SidecarMigrationUseCase,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AutoOrganizeInProgressError",
|
||||
@@ -36,4 +40,6 @@ __all__ = [
|
||||
"ImportExampleImagesUseCase",
|
||||
"ImportExampleImagesValidationError",
|
||||
"FilenameTemplateUseCase",
|
||||
"SidecarMigrationProgressReporter",
|
||||
"SidecarMigrationUseCase",
|
||||
]
|
||||
|
||||
@@ -12,6 +12,7 @@ import os
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional, Sequence
|
||||
|
||||
from ...utils.constants import AUTO_ORGANIZE_BATCH_SIZE
|
||||
from ...utils.sidecar_paths import get_metadata_path
|
||||
from ...utils.utils import calculate_filename_for_model
|
||||
from ..model_file_service import AutoOrganizeResult, ProgressCallback
|
||||
from ..model_lifecycle_service import ModelLifecycleService, load_local_metadata
|
||||
@@ -200,7 +201,7 @@ class FilenameTemplateUseCase:
|
||||
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_path = get_metadata_path(file_path)
|
||||
metadata = await self._metadata_loader(metadata_path)
|
||||
original = metadata.get("original_file_name")
|
||||
if not isinstance(original, str):
|
||||
|
||||
@@ -0,0 +1,709 @@
|
||||
"""Use case migrating sidecar metadata and previews between storage layouts.
|
||||
|
||||
Two storage layouts exist (see :mod:`py.utils.sidecar_paths`):
|
||||
|
||||
- ``alongside``: ``<model_dir>/<name>.metadata.json`` and preview files live
|
||||
next to the model file.
|
||||
- ``centralized``: the same files live under the configured sidecar root,
|
||||
mirroring the library-relative directory structure.
|
||||
|
||||
This use case moves the ``.metadata.json`` sidecar and preview files for every
|
||||
known model from one layout to the other. Model files themselves NEVER move.
|
||||
Paths inside the moved sidecar (``file_path``, ``file_name``, ``preview_url``)
|
||||
are rewritten the same way :meth:`ModelScanner._update_metadata_paths` does.
|
||||
After the move, scanner caches are reconciled so the list API immediately
|
||||
serves the new preview locations instead of stale pre-migration URLs.
|
||||
|
||||
Intended flow (settings-first):
|
||||
|
||||
1. The user switches ``sidecar_storage_mode`` (and optionally
|
||||
``sidecar_storage_path``) in settings.
|
||||
2. The migration runs in the direction of the NEW mode with ``force=True``.
|
||||
After the switch, files in the OLD layout are the source of truth; the
|
||||
guard below would otherwise refuse to run because the active mode already
|
||||
matches the migration target.
|
||||
|
||||
Both orderings work because all path computations are mode-independent: the
|
||||
alongside location is derived from the model path directly, and the mirror
|
||||
location is resolved via ``get_configured_sidecar_root()``, which ignores the
|
||||
active mode.
|
||||
|
||||
Guards (pass ``force=True`` to bypass):
|
||||
|
||||
- ``migrate_to_centralized`` refuses when centralized storage is already the
|
||||
active, resolvable mode.
|
||||
- ``migrate_to_alongside`` refuses when the active mode is ``alongside``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional, Protocol, Sequence, Tuple
|
||||
|
||||
from ..service_registry import ServiceRegistry
|
||||
from ..settings_manager import get_settings_manager
|
||||
from ...utils.constants import PREVIEW_EXTENSIONS
|
||||
from ...utils.file_utils import find_preview_file, get_preview_extension
|
||||
from ...utils.metadata_manager import MetadataManager
|
||||
from ...utils.sidecar_paths import (
|
||||
METADATA_SUFFIX,
|
||||
STORAGE_MODE_CENTRALIZED,
|
||||
get_configured_sidecar_root,
|
||||
get_sidecar_root,
|
||||
get_storage_mode,
|
||||
resolve_centralized_dir_for_dir,
|
||||
)
|
||||
|
||||
|
||||
class SidecarMigrationProgressReporter(Protocol):
|
||||
"""Protocol for progress reporters used during sidecar migration."""
|
||||
|
||||
async def on_progress(self, payload: Dict[str, Any]) -> None:
|
||||
"""Handle a sidecar migration progress update."""
|
||||
|
||||
|
||||
ScannerFactory = Callable[[], Awaitable[Any]]
|
||||
|
||||
DIRECTION_TO_CENTRALIZED = "to_centralized"
|
||||
DIRECTION_TO_ALONGSIDE = "to_alongside"
|
||||
DIRECTION_RELOCATE_ROOT = "relocate_root"
|
||||
|
||||
# Same candidate set find_preview_file recognizes: every PREVIEW_EXTENSIONS
|
||||
# suffix plus the legacy ".example.0.jpeg" (issue #225).
|
||||
_PREVIEW_CANDIDATE_EXTENSIONS = tuple(PREVIEW_EXTENSIONS) + (".example.0.jpeg",)
|
||||
|
||||
|
||||
def _enumerate_preview_names(directory: str, stem: str) -> List[str]:
|
||||
"""Return preview filenames for ``stem`` present in ``directory``.
|
||||
|
||||
Case-insensitive full-name match against the preview candidate set, so
|
||||
files like ``model.WEBP`` or ``model.Png`` placed by external tools are
|
||||
migrated along with the exact-case variants.
|
||||
"""
|
||||
|
||||
targets = {f"{stem.lower()}{ext}" for ext in _PREVIEW_CANDIDATE_EXTENSIONS}
|
||||
try:
|
||||
entries = os.listdir(directory)
|
||||
except OSError:
|
||||
return []
|
||||
return [entry for entry in entries if entry.lower() in targets]
|
||||
|
||||
|
||||
class SidecarMigrationUseCase:
|
||||
"""Move sidecars and previews between alongside and centralized layouts."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
scanner_factories: Sequence[Tuple[str, ScannerFactory]] | None = None,
|
||||
settings_service=None,
|
||||
logger: Optional[logging.Logger] = None,
|
||||
) -> None:
|
||||
self._settings = settings_service or get_settings_manager()
|
||||
self._scanner_factories: Tuple[Tuple[str, ScannerFactory], ...] = tuple(
|
||||
scanner_factories
|
||||
or (
|
||||
("lora", ServiceRegistry.get_lora_scanner),
|
||||
("checkpoint", ServiceRegistry.get_checkpoint_scanner),
|
||||
("embedding", ServiceRegistry.get_embedding_scanner),
|
||||
("other", ServiceRegistry.get_other_scanner),
|
||||
)
|
||||
)
|
||||
self._logger = logger or logging.getLogger(__name__)
|
||||
|
||||
async def migrate_to_centralized(
|
||||
self,
|
||||
progress_cb: Optional[SidecarMigrationProgressReporter] = None,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Move sidecars/previews from alongside the models into the mirror root."""
|
||||
|
||||
if (
|
||||
not force
|
||||
and get_storage_mode() == STORAGE_MODE_CENTRALIZED
|
||||
and get_sidecar_root()
|
||||
):
|
||||
return self._refusal(
|
||||
DIRECTION_TO_CENTRALIZED,
|
||||
"sidecar storage is already centralized; pass force=true to migrate anyway",
|
||||
)
|
||||
return await self._migrate(
|
||||
direction=DIRECTION_TO_CENTRALIZED,
|
||||
to_centralized=True,
|
||||
progress_cb=progress_cb,
|
||||
)
|
||||
|
||||
async def migrate_to_alongside(
|
||||
self,
|
||||
progress_cb: Optional[SidecarMigrationProgressReporter] = None,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Move sidecars/previews from the mirror root back next to the models."""
|
||||
|
||||
if not force and get_storage_mode() != STORAGE_MODE_CENTRALIZED:
|
||||
return self._refusal(
|
||||
DIRECTION_TO_ALONGSIDE,
|
||||
"sidecar storage is already alongside; pass force=true to migrate anyway",
|
||||
)
|
||||
return await self._migrate(
|
||||
direction=DIRECTION_TO_ALONGSIDE,
|
||||
to_centralized=False,
|
||||
progress_cb=progress_cb,
|
||||
)
|
||||
|
||||
async def migrate_root(
|
||||
self,
|
||||
old_root: str,
|
||||
progress_cb: Optional[SidecarMigrationProgressReporter] = None,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Relocate the whole mirror tree from a previous root to the configured one.
|
||||
|
||||
Used after ``sidecar_storage_path`` changes while centralized storage
|
||||
is active: without it, every asset under the old root would silently
|
||||
disappear from the application. Moves every file keeping the
|
||||
root-relative structure, rewrites the ``preview_url`` prefix inside
|
||||
moved sidecars, reconciles scanner caches, and prunes the emptied old
|
||||
tree. Keep-newer conflict resolution matches :meth:`_transfer`.
|
||||
"""
|
||||
|
||||
if not force and get_storage_mode() != STORAGE_MODE_CENTRALIZED:
|
||||
return self._refusal(
|
||||
DIRECTION_RELOCATE_ROOT,
|
||||
"sidecar storage is not centralized; pass force=true to relocate anyway",
|
||||
)
|
||||
new_root = get_configured_sidecar_root()
|
||||
if not new_root:
|
||||
return self._refusal(
|
||||
DIRECTION_RELOCATE_ROOT,
|
||||
"cannot resolve the centralized sidecar root",
|
||||
)
|
||||
old = (
|
||||
os.path.abspath(os.path.expanduser(old_root.strip()))
|
||||
if isinstance(old_root, str) and old_root.strip()
|
||||
else ""
|
||||
)
|
||||
if not old:
|
||||
return self._refusal(DIRECTION_RELOCATE_ROOT, "old_root is required")
|
||||
if os.path.normpath(old) == os.path.normpath(new_root):
|
||||
return self._refusal(
|
||||
DIRECTION_RELOCATE_ROOT,
|
||||
"old_root matches the configured sidecar root",
|
||||
)
|
||||
|
||||
files: List[Tuple[str, str]] = []
|
||||
if os.path.isdir(old):
|
||||
for dirpath, _dirnames, filenames in os.walk(old):
|
||||
rel = os.path.relpath(dirpath, old)
|
||||
target_dir = new_root if rel == os.curdir else os.path.join(new_root, rel)
|
||||
for filename in filenames:
|
||||
files.append(
|
||||
(os.path.join(dirpath, filename), os.path.join(target_dir, filename))
|
||||
)
|
||||
|
||||
errors: List[Dict[str, str]] = []
|
||||
counters: Dict[str, Any] = {"moved": 0, "conflicts": 0}
|
||||
moved_sidecars: List[str] = []
|
||||
|
||||
async def emit(status: str, **extra: Any) -> None:
|
||||
if progress_cb is None:
|
||||
return
|
||||
payload: Dict[str, Any] = {
|
||||
"type": "sidecar_migration_progress",
|
||||
"status": status,
|
||||
"direction": DIRECTION_RELOCATE_ROOT,
|
||||
"total": len(files),
|
||||
"processed": extra.pop("processed", 0),
|
||||
"moved": counters["moved"],
|
||||
"skipped": 0,
|
||||
"conflicts": counters["conflicts"],
|
||||
"errors": len(errors),
|
||||
}
|
||||
payload.update(extra)
|
||||
await progress_cb.on_progress(payload)
|
||||
|
||||
await emit("started")
|
||||
|
||||
for index, (src, dst) in enumerate(files, start=1):
|
||||
try:
|
||||
if self._transfer(src, dst, counters) and src.endswith(METADATA_SUFFIX):
|
||||
moved_sidecars.append(dst)
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Sidecar root relocation failed for %s: %s", src, exc, exc_info=True
|
||||
)
|
||||
errors.append({"model": os.path.basename(src), "error": str(exc)})
|
||||
await emit("processing", processed=index, current=os.path.basename(src))
|
||||
|
||||
old_prefix = old.replace(os.sep, "/").rstrip("/") + "/"
|
||||
new_prefix = new_root.replace(os.sep, "/").rstrip("/") + "/"
|
||||
for sidecar in moved_sidecars:
|
||||
self._rewrite_root_prefix(sidecar, old_prefix, new_prefix)
|
||||
await self._reconcile_root_prefix(old_prefix, new_prefix)
|
||||
|
||||
# Prune the emptied old tree, best-effort.
|
||||
if os.path.isdir(old):
|
||||
for dirpath, dirnames, filenames in os.walk(old, topdown=False):
|
||||
if filenames:
|
||||
continue
|
||||
for dirname in dirnames:
|
||||
try:
|
||||
os.rmdir(os.path.join(dirpath, dirname))
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.rmdir(dirpath)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
await emit("completed")
|
||||
|
||||
return {
|
||||
"success": not errors,
|
||||
"direction": DIRECTION_RELOCATE_ROOT,
|
||||
"models_total": len(files),
|
||||
"models_processed": len(files),
|
||||
"models_moved": 0,
|
||||
"moved": counters["moved"],
|
||||
"skipped": 0,
|
||||
"conflicts": counters["conflicts"],
|
||||
"errors": errors,
|
||||
"error_count": len(errors),
|
||||
"sidecar_root": new_root,
|
||||
}
|
||||
|
||||
def _rewrite_root_prefix(
|
||||
self, sidecar_path: str, old_prefix: str, new_prefix: str
|
||||
) -> None:
|
||||
"""Repoint preview_url inside a relocated sidecar from old to new root."""
|
||||
|
||||
try:
|
||||
with open(sidecar_path, "r", encoding="utf-8") as handle:
|
||||
metadata = json.load(handle)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
self._logger.warning(
|
||||
"Sidecar root relocation: cannot read %s: %s", sidecar_path, exc
|
||||
)
|
||||
return
|
||||
|
||||
preview_url = metadata.get("preview_url")
|
||||
if not isinstance(preview_url, str) or not preview_url.startswith(old_prefix):
|
||||
return
|
||||
metadata["preview_url"] = new_prefix + preview_url[len(old_prefix):]
|
||||
try:
|
||||
with open(sidecar_path, "w", encoding="utf-8") as handle:
|
||||
json.dump(metadata, handle, ensure_ascii=False, indent=2)
|
||||
except OSError as exc:
|
||||
self._logger.warning(
|
||||
"Sidecar root relocation: cannot rewrite %s: %s", sidecar_path, exc
|
||||
)
|
||||
|
||||
async def _reconcile_root_prefix(self, old_prefix: str, new_prefix: str) -> None:
|
||||
"""Rewrite old-root preview URLs in every scanner cache after relocation."""
|
||||
|
||||
for model_type, factory in self._active_scanner_factories():
|
||||
try:
|
||||
scanner = await factory()
|
||||
cache = await scanner.get_cached_data()
|
||||
changed = False
|
||||
for item in cache.raw_data:
|
||||
preview_url = item.get("preview_url")
|
||||
if (
|
||||
isinstance(preview_url, str)
|
||||
and preview_url.startswith(old_prefix)
|
||||
):
|
||||
item["preview_url"] = new_prefix + preview_url[len(old_prefix):]
|
||||
changed = True
|
||||
if changed and hasattr(scanner, "_persist_current_cache"):
|
||||
await scanner._persist_current_cache()
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Sidecar root relocation: failed to reconcile %s cache: %s",
|
||||
model_type,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _refusal(direction: str, message: str) -> Dict[str, Any]:
|
||||
return {
|
||||
"success": False,
|
||||
"error": message,
|
||||
"direction": direction,
|
||||
"models_total": 0,
|
||||
"models_processed": 0,
|
||||
"models_moved": 0,
|
||||
"moved": 0,
|
||||
"skipped": 0,
|
||||
"conflicts": 0,
|
||||
"errors": [],
|
||||
"error_count": 0,
|
||||
"sidecar_root": get_configured_sidecar_root() or "",
|
||||
}
|
||||
|
||||
def _active_scanner_factories(self) -> Tuple[Tuple[str, ScannerFactory], ...]:
|
||||
"""Drop the opt-in other scanner while Other Models is disabled."""
|
||||
|
||||
if self._settings.is_other_models_enabled():
|
||||
return self._scanner_factories
|
||||
return tuple(entry for entry in self._scanner_factories if entry[0] != "other")
|
||||
|
||||
async def _collect_model_paths(
|
||||
self, errors: List[Dict[str, str]]
|
||||
) -> List[Tuple[Any, List[str]]]:
|
||||
"""Enumerate model file paths grouped by the scanner that owns them.
|
||||
|
||||
Excluded models are included: they are absent from the cache but still
|
||||
on disk, and leaving their sidecars behind would strand the metadata
|
||||
if the user later un-excludes them (the scanner would then look the
|
||||
sidecar up in the NEW layout and find nothing).
|
||||
"""
|
||||
|
||||
groups: List[Tuple[Any, List[str]]] = []
|
||||
for model_type, factory in self._active_scanner_factories():
|
||||
try:
|
||||
scanner = await factory()
|
||||
cache = await scanner.get_cached_data()
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Sidecar migration: failed to enumerate %s models: %s",
|
||||
model_type,
|
||||
exc,
|
||||
)
|
||||
errors.append({"model": model_type, "error": f"enumeration failed: {exc}"})
|
||||
continue
|
||||
paths = [
|
||||
entry["file_path"]
|
||||
for entry in cache.raw_data
|
||||
if entry.get("file_path")
|
||||
]
|
||||
get_excluded = getattr(scanner, "get_excluded_models", None)
|
||||
if callable(get_excluded):
|
||||
try:
|
||||
known = set(paths)
|
||||
paths.extend(
|
||||
path for path in get_excluded() if path and path not in known
|
||||
)
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Sidecar migration: failed to enumerate excluded %s models: %s",
|
||||
model_type,
|
||||
exc,
|
||||
)
|
||||
groups.append((scanner, paths))
|
||||
return groups
|
||||
|
||||
@staticmethod
|
||||
def _move_file(src: str, dst: str) -> None:
|
||||
"""Move a file, tolerating EXDEV when the layouts span filesystems."""
|
||||
|
||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||
try:
|
||||
os.rename(src, dst)
|
||||
except OSError as exc:
|
||||
if exc.errno != errno.EXDEV:
|
||||
raise
|
||||
shutil.copy2(src, dst)
|
||||
os.remove(src)
|
||||
|
||||
async def _migrate(
|
||||
self,
|
||||
*,
|
||||
direction: str,
|
||||
to_centralized: bool,
|
||||
progress_cb: Optional[SidecarMigrationProgressReporter],
|
||||
) -> Dict[str, Any]:
|
||||
root = get_configured_sidecar_root()
|
||||
if not root:
|
||||
return self._refusal(
|
||||
direction,
|
||||
"cannot resolve the centralized sidecar root",
|
||||
)
|
||||
|
||||
errors: List[Dict[str, str]] = []
|
||||
scanner_groups = await self._collect_model_paths(errors)
|
||||
|
||||
total = sum(len(paths) for _, paths in scanner_groups)
|
||||
processed = 0
|
||||
models_moved = 0
|
||||
moved = 0
|
||||
skipped = 0
|
||||
conflicts = 0
|
||||
# (file_path, final preview path at the destination layout), grouped
|
||||
# by scanner so caches can be reconciled after the move.
|
||||
preview_updates: List[Tuple[Any, List[Tuple[str, str]]]] = []
|
||||
|
||||
async def emit(status: str, **extra: Any) -> None:
|
||||
if progress_cb is None:
|
||||
return
|
||||
payload: Dict[str, Any] = {
|
||||
"type": "sidecar_migration_progress",
|
||||
"status": status,
|
||||
"direction": direction,
|
||||
"total": total,
|
||||
"processed": processed,
|
||||
"moved": moved,
|
||||
"skipped": skipped,
|
||||
"conflicts": conflicts,
|
||||
"errors": len(errors),
|
||||
}
|
||||
payload.update(extra)
|
||||
await progress_cb.on_progress(payload)
|
||||
|
||||
await emit("started")
|
||||
|
||||
for scanner, model_paths in scanner_groups:
|
||||
updates: List[Tuple[str, str]] = []
|
||||
for model_path in model_paths:
|
||||
processed += 1
|
||||
current = os.path.basename(model_path)
|
||||
try:
|
||||
result = await self._migrate_model(
|
||||
model_path,
|
||||
root=root,
|
||||
to_centralized=to_centralized,
|
||||
)
|
||||
moved += result["moved"]
|
||||
conflicts += result["conflicts"]
|
||||
if result["skipped"]:
|
||||
skipped += 1
|
||||
else:
|
||||
updates.append((model_path, result["preview_url"]))
|
||||
if result["moved"]:
|
||||
models_moved += 1
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Sidecar migration failed for %s: %s", model_path, exc, exc_info=True
|
||||
)
|
||||
errors.append({"model": current, "error": str(exc)})
|
||||
await emit("processing", current=current)
|
||||
preview_updates.append((scanner, updates))
|
||||
|
||||
await self._reconcile_scanner_caches(preview_updates)
|
||||
|
||||
await emit("completed")
|
||||
|
||||
return {
|
||||
"success": not errors,
|
||||
"direction": direction,
|
||||
"models_total": total,
|
||||
"models_processed": processed,
|
||||
"models_moved": models_moved,
|
||||
"moved": moved,
|
||||
"skipped": skipped,
|
||||
"conflicts": conflicts,
|
||||
"errors": errors,
|
||||
"error_count": len(errors),
|
||||
# Effective centralized root, so the UI can show/offer to open the
|
||||
# destination (or, for to_alongside, the source) after the run.
|
||||
"sidecar_root": root,
|
||||
}
|
||||
|
||||
async def _migrate_model(
|
||||
self,
|
||||
model_path: str,
|
||||
*,
|
||||
root: str,
|
||||
to_centralized: bool,
|
||||
) -> Dict[str, Any]:
|
||||
"""Migrate one model's sidecar + previews; return per-model counters.
|
||||
|
||||
``preview_url`` in the result is the model's final preview path in the
|
||||
destination layout ("" when none), used to reconcile scanner caches.
|
||||
"""
|
||||
|
||||
result: Dict[str, Any] = {"moved": 0, "conflicts": 0, "skipped": 0, "preview_url": ""}
|
||||
|
||||
model_path = os.path.abspath(model_path)
|
||||
if not os.path.exists(model_path):
|
||||
self._logger.warning(
|
||||
"Sidecar migration: model file missing, skipping: %s", model_path
|
||||
)
|
||||
result["skipped"] = 1
|
||||
return result
|
||||
|
||||
model_dir = os.path.dirname(model_path)
|
||||
mirror_dir = resolve_centralized_dir_for_dir(model_dir, sidecar_root=root)
|
||||
if mirror_dir is None:
|
||||
self._logger.warning(
|
||||
"Sidecar migration: %s is outside configured model roots, skipping",
|
||||
model_path,
|
||||
)
|
||||
result["skipped"] = 1
|
||||
return result
|
||||
|
||||
if to_centralized:
|
||||
src_dir, dst_dir = model_dir, mirror_dir
|
||||
else:
|
||||
src_dir, dst_dir = mirror_dir, model_dir
|
||||
|
||||
if os.path.normpath(src_dir) == os.path.normpath(dst_dir):
|
||||
result["skipped"] = 1
|
||||
return result
|
||||
|
||||
stem = os.path.splitext(os.path.basename(model_path))[0]
|
||||
sidecar_name = stem + METADATA_SUFFIX
|
||||
|
||||
moved_previews: List[str] = []
|
||||
for preview_name in _enumerate_preview_names(src_dir, stem):
|
||||
src = os.path.join(src_dir, preview_name)
|
||||
dst = os.path.join(dst_dir, preview_name)
|
||||
if self._transfer(src, dst, result):
|
||||
moved_previews.append(dst)
|
||||
|
||||
sidecar_src = os.path.join(src_dir, sidecar_name)
|
||||
sidecar_moved = False
|
||||
sidecar_dst = os.path.join(dst_dir, sidecar_name)
|
||||
if os.path.exists(sidecar_src):
|
||||
sidecar_moved = self._transfer(sidecar_src, sidecar_dst, result)
|
||||
|
||||
if sidecar_moved:
|
||||
await self._rewrite_sidecar_paths(sidecar_dst, model_path, moved_previews)
|
||||
|
||||
# Ground truth from the destination directory: covers conflict-keep
|
||||
# and partial moves, not just the previews transferred in this run.
|
||||
final_preview = find_preview_file(stem, dst_dir)
|
||||
if final_preview:
|
||||
result["preview_url"] = final_preview.replace(os.sep, "/")
|
||||
|
||||
return result
|
||||
|
||||
async def _reconcile_scanner_caches(
|
||||
self, preview_updates: List[Tuple[Any, List[Tuple[str, str]]]]
|
||||
) -> None:
|
||||
"""Point scanner cache entries at the post-migration preview locations.
|
||||
|
||||
Without this the list API keeps serving pre-migration ``preview_url``
|
||||
values whose files no longer exist; hitting one triggers the preview
|
||||
route's stale-URL cleanup, which would wipe the reference for good.
|
||||
A failing scanner is logged and skipped — the on-disk migration has
|
||||
already succeeded, and a full rescan repairs the cache.
|
||||
"""
|
||||
|
||||
for scanner, updates in preview_updates:
|
||||
if not updates:
|
||||
continue
|
||||
try:
|
||||
cache = await scanner.get_cached_data()
|
||||
changed = False
|
||||
for file_path, preview_url in updates:
|
||||
entry = next(
|
||||
(item for item in cache.raw_data if item.get("file_path") == file_path),
|
||||
None,
|
||||
)
|
||||
if entry is None:
|
||||
continue
|
||||
if entry.get("preview_url", "") == preview_url:
|
||||
continue
|
||||
if hasattr(cache, "update_preview_url"):
|
||||
await cache.update_preview_url(
|
||||
file_path,
|
||||
preview_url,
|
||||
entry.get("preview_nsfw_level", 0),
|
||||
)
|
||||
else: # pragma: no cover - minimal cache doubles
|
||||
entry["preview_url"] = preview_url
|
||||
changed = True
|
||||
if changed and hasattr(scanner, "_persist_current_cache"):
|
||||
await scanner._persist_current_cache()
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Sidecar migration: failed to reconcile scanner cache: %s",
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def _transfer(self, src: str, dst: str, result: Dict[str, Any]) -> bool:
|
||||
"""Move ``src`` to ``dst`` with keep-newer conflict resolution.
|
||||
|
||||
Returns True when the file was actually moved to the destination. On a
|
||||
conflict the newer file wins: a newer source replaces the destination;
|
||||
a newer (or equal) destination is kept and the source is deleted.
|
||||
"""
|
||||
|
||||
if os.path.exists(dst):
|
||||
result["conflicts"] += 1
|
||||
if os.path.getmtime(src) > os.path.getmtime(dst):
|
||||
self._logger.info(
|
||||
"Sidecar migration: conflict at %s; source is newer, replacing", dst
|
||||
)
|
||||
os.remove(dst)
|
||||
else:
|
||||
self._logger.info(
|
||||
"Sidecar migration: conflict at %s; destination is newer, keeping it",
|
||||
dst,
|
||||
)
|
||||
os.remove(src)
|
||||
return False
|
||||
self._move_file(src, dst)
|
||||
result["moved"] += 1
|
||||
return True
|
||||
|
||||
async def _rewrite_sidecar_paths(
|
||||
self,
|
||||
sidecar_path: str,
|
||||
model_path: str,
|
||||
moved_previews: List[str],
|
||||
) -> None:
|
||||
"""Update path fields inside a moved sidecar, mirroring ModelScanner."""
|
||||
|
||||
with open(sidecar_path, "r", encoding="utf-8") as handle:
|
||||
metadata = json.load(handle)
|
||||
|
||||
stem = os.path.splitext(os.path.basename(model_path))[0]
|
||||
metadata["file_path"] = model_path.replace(os.sep, "/")
|
||||
metadata["file_name"] = stem
|
||||
|
||||
if moved_previews and metadata.get("preview_url"):
|
||||
recorded_ext = get_preview_extension(metadata["preview_url"])
|
||||
chosen = next(
|
||||
(
|
||||
path
|
||||
for path in moved_previews
|
||||
if get_preview_extension(path) == recorded_ext
|
||||
),
|
||||
moved_previews[0],
|
||||
)
|
||||
metadata["preview_url"] = chosen.replace(os.sep, "/")
|
||||
|
||||
await MetadataManager.save_metadata(sidecar_path, metadata)
|
||||
|
||||
async def execute_with_error_handling(
|
||||
self,
|
||||
*,
|
||||
direction: str,
|
||||
progress_cb: Optional[SidecarMigrationProgressReporter] = None,
|
||||
force: bool = False,
|
||||
old_root: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Wrapper providing progress notification on unexpected failures."""
|
||||
|
||||
try:
|
||||
if direction == DIRECTION_TO_CENTRALIZED:
|
||||
return await self.migrate_to_centralized(progress_cb, force=force)
|
||||
if direction == DIRECTION_TO_ALONGSIDE:
|
||||
return await self.migrate_to_alongside(progress_cb, force=force)
|
||||
if direction == DIRECTION_RELOCATE_ROOT:
|
||||
return await self.migrate_root(old_root or "", progress_cb, force=force)
|
||||
raise ValueError(
|
||||
f"direction must be {DIRECTION_TO_CENTRALIZED!r}, "
|
||||
f"{DIRECTION_TO_ALONGSIDE!r} or {DIRECTION_RELOCATE_ROOT!r}"
|
||||
)
|
||||
except Exception as exc:
|
||||
if progress_cb is not None:
|
||||
await progress_cb.on_progress(
|
||||
{
|
||||
"type": "sidecar_migration_progress",
|
||||
"status": "error",
|
||||
"direction": direction,
|
||||
"error": str(exc),
|
||||
}
|
||||
)
|
||||
raise
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -273,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),
|
||||
@@ -293,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(
|
||||
|
||||
@@ -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,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
|
||||
@@ -8,6 +8,7 @@ from typing import Any, Dict, Optional, Type, Union, cast
|
||||
from .models import BaseModelMetadata, CheckpointMetadata, EmbeddingMetadata, LoraMetadata
|
||||
from .file_utils import normalize_path, find_preview_file, calculate_sha256, calculate_autov3
|
||||
from .lora_metadata import extract_lora_metadata, extract_checkpoint_metadata
|
||||
from .sidecar_paths import get_metadata_path, get_preview_dir, resolve_metadata_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -32,7 +33,7 @@ class MetadataManager:
|
||||
- metadata: BaseModelMetadata instance or None
|
||||
- should_skip: True if corrupted metadata file exists and model should be skipped
|
||||
"""
|
||||
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
|
||||
# Check if metadata file exists
|
||||
if not os.path.exists(metadata_path):
|
||||
@@ -98,11 +99,7 @@ class MetadataManager:
|
||||
payload.update(unknown_fields)
|
||||
else:
|
||||
if not should_skip:
|
||||
metadata_path = (
|
||||
file_path
|
||||
if file_path.endswith(".metadata.json")
|
||||
else f"{os.path.splitext(file_path)[0]}.metadata.json"
|
||||
)
|
||||
metadata_path = resolve_metadata_path(file_path)
|
||||
if os.path.exists(metadata_path):
|
||||
try:
|
||||
with open(metadata_path, "r", encoding="utf-8") as handle:
|
||||
@@ -150,7 +147,7 @@ class MetadataManager:
|
||||
return model_data
|
||||
|
||||
folder = model_data.get("folder")
|
||||
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
sidecar_exists = os.path.exists(metadata_path)
|
||||
cached = model_data.copy()
|
||||
payload = await MetadataManager.load_metadata_payload(file_path)
|
||||
@@ -188,15 +185,14 @@ class MetadataManager:
|
||||
bool: Success or failure
|
||||
"""
|
||||
# Determine if the input is a metadata path or a model file path
|
||||
if path.endswith('.metadata.json'):
|
||||
metadata_path = path
|
||||
else:
|
||||
# Use existing logic for model file paths
|
||||
file_path = path
|
||||
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
|
||||
metadata_path = resolve_metadata_path(path)
|
||||
temp_path = f"{metadata_path}.tmp"
|
||||
|
||||
try:
|
||||
# Centralized sidecar mirrors may not exist yet (unlike the model's
|
||||
# own directory in alongside mode, which always does).
|
||||
os.makedirs(os.path.dirname(metadata_path), exist_ok=True)
|
||||
|
||||
# Convert to dict if needed
|
||||
if isinstance(metadata, BaseModelMetadata):
|
||||
metadata_dict = metadata.to_dict()
|
||||
@@ -259,10 +255,9 @@ class MetadataManager:
|
||||
|
||||
try:
|
||||
base_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
dir_path = os.path.dirname(file_path)
|
||||
|
||||
|
||||
# Find preview image
|
||||
preview_url = find_preview_file(base_name, dir_path)
|
||||
preview_url = find_preview_file(base_name, get_preview_dir(file_path))
|
||||
|
||||
# Calculate file hash
|
||||
start_hash_time = time.perf_counter()
|
||||
@@ -386,15 +381,16 @@ class MetadataManager:
|
||||
# Check if preview exists at the current location
|
||||
preview_url = metadata.preview_url
|
||||
if preview_url:
|
||||
# Get directory parts of both paths
|
||||
file_dir = os.path.dirname(file_path)
|
||||
# Get directory parts of both paths; the preview directory is the
|
||||
# sidecar/preview dir (the model's own dir in alongside mode, the
|
||||
# centralized mirror otherwise).
|
||||
file_dir = get_preview_dir(file_path)
|
||||
preview_dir = os.path.dirname(preview_url)
|
||||
|
||||
# Update preview if it doesn't exist OR if model and preview are in different directories
|
||||
if not os.path.exists(preview_url) or file_dir != preview_dir:
|
||||
base_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
dir_path = os.path.dirname(file_path)
|
||||
new_preview_url = find_preview_file(base_name, dir_path)
|
||||
new_preview_url = find_preview_file(base_name, file_dir)
|
||||
if new_preview_url:
|
||||
metadata.preview_url = normalize_path(new_preview_url)
|
||||
need_update = True
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
"""Resolution of sidecar metadata and preview storage paths.
|
||||
|
||||
All code that needs the on-disk location of a model's ``.metadata.json``
|
||||
sidecar or preview assets MUST go through these helpers instead of deriving
|
||||
paths inline (``splitext(model_path)[0] + ".metadata.json"`` and friends).
|
||||
|
||||
Two storage modes are supported, selected by the ``sidecar_storage_mode``
|
||||
setting:
|
||||
|
||||
- ``alongside`` (default): sidecars and previews live next to the model
|
||||
file, the historical layout other tools may rely on.
|
||||
- ``centralized``: sidecars and previews live under a configurable root
|
||||
(``sidecar_storage_path`` setting, default ``<settings_dir>/sidecars``),
|
||||
mirroring the library-relative directory structure:
|
||||
``<root>/<library>/<root_basename-roothash>/<rel_dir>/<name>.metadata.json``.
|
||||
|
||||
All helpers are pure path computations: no directory scans and no file I/O
|
||||
on the hot path. Settings lookups go through ``SettingsManager.get`` (a dict
|
||||
read); config roots come from the already-initialized ``config`` singleton.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
METADATA_SUFFIX = ".metadata.json"
|
||||
|
||||
STORAGE_MODE_ALONGSIDE = "alongside"
|
||||
STORAGE_MODE_CENTRALIZED = "centralized"
|
||||
|
||||
_VALID_MODES = frozenset({STORAGE_MODE_ALONGSIDE, STORAGE_MODE_CENTRALIZED})
|
||||
|
||||
|
||||
def _get_settings_value(key: str, default=None):
|
||||
"""Read a setting defensively; never fail path resolution on settings errors."""
|
||||
|
||||
try:
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
|
||||
value = get_settings_manager().get(key)
|
||||
except Exception as exc: # pragma: no cover - defensive fallback
|
||||
logger.debug("sidecar_paths: settings lookup for %r failed: %s", key, exc)
|
||||
return default
|
||||
return default if value is None else value
|
||||
|
||||
|
||||
def get_storage_mode() -> str:
|
||||
"""Return the active sidecar storage mode (``alongside`` unless configured)."""
|
||||
|
||||
mode = _get_settings_value("sidecar_storage_mode", STORAGE_MODE_ALONGSIDE)
|
||||
if mode not in _VALID_MODES:
|
||||
return STORAGE_MODE_ALONGSIDE
|
||||
return mode
|
||||
|
||||
|
||||
def is_centralized() -> bool:
|
||||
"""Return True when centralized sidecar storage is active and resolvable."""
|
||||
|
||||
return get_storage_mode() == STORAGE_MODE_CENTRALIZED and bool(get_sidecar_root())
|
||||
|
||||
|
||||
def _resolve_root_from_settings() -> str:
|
||||
"""Resolve the configured/default centralized root, ignoring the active mode."""
|
||||
|
||||
configured = _get_settings_value("sidecar_storage_path", "")
|
||||
if configured and isinstance(configured, str):
|
||||
root = os.path.abspath(os.path.expanduser(configured.strip()))
|
||||
if root:
|
||||
return root
|
||||
|
||||
# Default: <settings_dir>/sidecars
|
||||
try:
|
||||
from .settings_paths import get_settings_dir
|
||||
|
||||
return os.path.join(get_settings_dir(), "sidecars")
|
||||
except Exception as exc: # pragma: no cover - defensive fallback
|
||||
logger.warning("sidecar_paths: cannot resolve default sidecar root: %s", exc)
|
||||
return ""
|
||||
|
||||
|
||||
def get_sidecar_root() -> str:
|
||||
"""Return the absolute root directory for centralized sidecar storage.
|
||||
|
||||
Empty string when centralized storage is not usable (mode alongside or an
|
||||
unresolvable configured path).
|
||||
"""
|
||||
|
||||
if get_storage_mode() != STORAGE_MODE_CENTRALIZED:
|
||||
return ""
|
||||
|
||||
return _resolve_root_from_settings()
|
||||
|
||||
|
||||
def get_configured_sidecar_root() -> str:
|
||||
"""Return the centralized sidecar root regardless of the active mode.
|
||||
|
||||
Unlike :func:`get_sidecar_root`, this resolves the configured
|
||||
``sidecar_storage_path`` (or the ``<settings_dir>/sidecars`` default) even
|
||||
when the storage mode is ``alongside``. Migration tooling needs both
|
||||
layouts at once and must not depend on which mode is currently active.
|
||||
"""
|
||||
|
||||
return _resolve_root_from_settings()
|
||||
|
||||
|
||||
def _installation_root() -> str:
|
||||
"""Return the plugin installation directory (repository root)."""
|
||||
|
||||
return os.path.dirname(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
)
|
||||
|
||||
|
||||
def _path_contains(base: str, path: str) -> bool:
|
||||
"""Containment check tolerant of symlinked installs (custom_nodes links)."""
|
||||
|
||||
for candidate in (os.path.abspath(path), os.path.realpath(path)):
|
||||
normalized = os.path.normcase(os.path.normpath(candidate))
|
||||
for root_variant in (os.path.abspath(base), os.path.realpath(base)):
|
||||
root_normalized = os.path.normcase(os.path.normpath(root_variant))
|
||||
if (
|
||||
normalized == root_normalized
|
||||
or normalized.startswith(root_normalized + os.sep)
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def describe_sidecar_root() -> dict:
|
||||
"""Describe the effective centralized sidecar root for UI display.
|
||||
|
||||
``inside_repo`` flags the portable-mode hazard: when settings live in the
|
||||
repository, the default root lands inside the plugin folder, where a
|
||||
reinstall or ``git clean`` would silently delete every sidecar.
|
||||
"""
|
||||
|
||||
configured = _get_settings_value("sidecar_storage_path", "")
|
||||
is_default = not (isinstance(configured, str) and configured.strip())
|
||||
root = _resolve_root_from_settings()
|
||||
return {
|
||||
"root": root,
|
||||
"is_default": is_default,
|
||||
"inside_repo": bool(root) and _path_contains(_installation_root(), root),
|
||||
}
|
||||
|
||||
|
||||
def sanitize_path_component(name: str) -> str:
|
||||
"""Return a filesystem-safe single path component."""
|
||||
|
||||
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", name or "")
|
||||
return safe or "_"
|
||||
|
||||
|
||||
def _iter_model_roots() -> List[str]:
|
||||
"""Return every configured model root for the active library."""
|
||||
|
||||
try:
|
||||
from ..config import config
|
||||
except Exception as exc: # pragma: no cover - defensive fallback
|
||||
logger.debug("sidecar_paths: config unavailable: %s", exc)
|
||||
return []
|
||||
|
||||
roots: List[str] = []
|
||||
for attr in (
|
||||
"loras_roots",
|
||||
"base_models_roots",
|
||||
"embeddings_roots",
|
||||
"other_roots",
|
||||
"extra_loras_roots",
|
||||
"extra_checkpoints_roots",
|
||||
"extra_unet_roots",
|
||||
"extra_embeddings_roots",
|
||||
):
|
||||
value = getattr(config, attr, None)
|
||||
if value:
|
||||
roots.extend(value)
|
||||
return roots
|
||||
|
||||
|
||||
def _normalize_for_match(path: str) -> str:
|
||||
return os.path.normpath(os.path.abspath(path))
|
||||
|
||||
|
||||
def root_mirror_component(root_path: str) -> str:
|
||||
"""Return the mirror path component identifying a model root.
|
||||
|
||||
``<sanitized basename>-<hash>`` where the hash is a short digest of the
|
||||
normalized absolute root path. Two roots sharing a basename (e.g.
|
||||
``/mnt/a/loras`` and ``/mnt/b/loras``) would otherwise map to the same
|
||||
mirror directory and overwrite each other's sidecars.
|
||||
"""
|
||||
|
||||
normalized = _normalize_for_match(root_path)
|
||||
digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:8]
|
||||
return f"{sanitize_path_component(os.path.basename(normalized))}-{digest}"
|
||||
|
||||
|
||||
def resolve_centralized_dir(model_path: str) -> Optional[str]:
|
||||
"""Return the centralized mirror directory for ``model_path``.
|
||||
|
||||
The mirror layout is
|
||||
``<sidecar_root>/<library>/<root_basename-roothash>/<rel_dir>``
|
||||
where ``rel_dir`` is the model's directory relative to the model root that
|
||||
contains it. The longest matching root wins so nested roots resolve to the
|
||||
most specific mirror. Returns ``None`` when centralized storage is inactive
|
||||
or the path is not under any configured model root.
|
||||
"""
|
||||
|
||||
return resolve_centralized_dir_for_dir(
|
||||
os.path.dirname(_normalize_for_match(model_path))
|
||||
)
|
||||
|
||||
|
||||
def resolve_centralized_dir_for_dir(
|
||||
model_dir: str, *, sidecar_root: Optional[str] = None
|
||||
) -> Optional[str]:
|
||||
"""Return the centralized mirror directory for a model *directory*.
|
||||
|
||||
Same layout as :func:`resolve_centralized_dir`, but accepts the directory
|
||||
itself. Used by folder-level operations (folder rename, mirror-tree walks)
|
||||
that have no model file path to derive from. Passing a configured model
|
||||
root returns the mirror base for that root.
|
||||
|
||||
``sidecar_root`` overrides the root lookup; pass
|
||||
:func:`get_configured_sidecar_root` to resolve mirror paths independently
|
||||
of the active storage mode (migration tooling).
|
||||
"""
|
||||
|
||||
root = sidecar_root if sidecar_root is not None else get_sidecar_root()
|
||||
if not root:
|
||||
return None
|
||||
|
||||
normalized_dir = _normalize_for_match(model_dir)
|
||||
|
||||
best_root: Optional[str] = None
|
||||
for candidate in _iter_model_roots():
|
||||
if not candidate:
|
||||
continue
|
||||
normalized = _normalize_for_match(candidate)
|
||||
if normalized_dir == normalized or normalized_dir.startswith(normalized + os.sep):
|
||||
if best_root is None or len(normalized) > len(best_root):
|
||||
best_root = normalized
|
||||
|
||||
if best_root is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
|
||||
library = get_settings_manager().get_active_library_name() or "default"
|
||||
except Exception: # pragma: no cover - defensive fallback
|
||||
library = "default"
|
||||
|
||||
rel_dir = os.path.relpath(normalized_dir, best_root)
|
||||
parts = [root, sanitize_path_component(library), root_mirror_component(best_root)]
|
||||
if rel_dir and rel_dir != os.curdir:
|
||||
parts.extend(sanitize_path_component(part) for part in rel_dir.split(os.sep) if part not in ("", os.curdir))
|
||||
return os.path.join(*parts)
|
||||
|
||||
|
||||
def get_sidecar_dir(model_path: str) -> str:
|
||||
"""Return the directory holding the model's sidecar/preview assets.
|
||||
|
||||
Centralized mode falls back to the model's own directory (with a warning)
|
||||
when the path lies outside every configured model root.
|
||||
"""
|
||||
|
||||
if get_storage_mode() == STORAGE_MODE_CENTRALIZED:
|
||||
mirror = resolve_centralized_dir(model_path)
|
||||
if mirror:
|
||||
return mirror
|
||||
logger.warning(
|
||||
"sidecar_paths: %s is outside configured model roots; storing sidecar alongside",
|
||||
model_path,
|
||||
)
|
||||
return os.path.dirname(os.path.abspath(model_path))
|
||||
|
||||
|
||||
def get_metadata_path(model_path: str) -> str:
|
||||
"""Return the ``.metadata.json`` sidecar path for a model file."""
|
||||
|
||||
base_name = os.path.splitext(os.path.basename(model_path))[0] + METADATA_SUFFIX
|
||||
return os.path.join(get_sidecar_dir(model_path), base_name)
|
||||
|
||||
|
||||
def is_metadata_path(path: str) -> bool:
|
||||
"""Return True when ``path`` already points at a metadata sidecar file."""
|
||||
|
||||
return path.endswith(METADATA_SUFFIX)
|
||||
|
||||
|
||||
def resolve_metadata_path(path: str) -> str:
|
||||
"""Accept either a model path or a sidecar path and return the sidecar path."""
|
||||
|
||||
if is_metadata_path(path):
|
||||
return path
|
||||
return get_metadata_path(path)
|
||||
|
||||
|
||||
def get_preview_dir(model_path: str) -> str:
|
||||
"""Return the directory holding the model's preview assets."""
|
||||
|
||||
return get_sidecar_dir(model_path)
|
||||
@@ -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)
|
||||
+55
-11
@@ -6,6 +6,11 @@ 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__)
|
||||
@@ -114,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"):
|
||||
@@ -417,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.
|
||||
@@ -449,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"
|
||||
|
||||
@@ -575,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)
|
||||
@@ -667,20 +704,22 @@ def calculate_filename_for_model(
|
||||
else:
|
||||
original_name = os.path.splitext(str(model_data.get("file_name", "")))[0]
|
||||
|
||||
def _sanitize_value(value: Any) -> str:
|
||||
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 ""
|
||||
return sanitize_folder_name(text) if text else ""
|
||||
if not text:
|
||||
return ""
|
||||
return sanitize_folder_name(text, max_length=max_length)
|
||||
|
||||
replacements = {
|
||||
"{model_name}": _sanitize_value(model_name),
|
||||
"{version_name}": _sanitize_value(version_name),
|
||||
"{base_model}": _sanitize_value(mapped_base_model),
|
||||
"{author}": _sanitize_value(author),
|
||||
"{first_tag}": _sanitize_value(first_tag),
|
||||
"{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),
|
||||
"{original_name}": _sanitize_value(original_name, MAX_FILENAME_STEM_LENGTH),
|
||||
}
|
||||
|
||||
result = template
|
||||
@@ -699,6 +738,11 @@ def calculate_filename_for_model(
|
||||
# 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
|
||||
|
||||
|
||||
|
||||
+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",
|
||||
|
||||
@@ -42,6 +42,18 @@
|
||||
animation: modalFadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* The folder delete confirm button is held disabled while the folder contents
|
||||
are checked against the backend, so it must not look clickable. */
|
||||
#deleteFolderModal .delete-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
#deleteFolderModal .delete-btn:disabled:hover {
|
||||
background: var(--lora-error);
|
||||
}
|
||||
|
||||
#resolveFilenameConflictsModal .confirmation-message {
|
||||
color: var(--text-color);
|
||||
margin: var(--space-2) 0;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -52,6 +52,9 @@ export class SidebarManager {
|
||||
this._renameFolderNode = null;
|
||||
this._pendingDeleteFolderPath = null;
|
||||
this._deleteFolderModalWired = false;
|
||||
// Bumped on every modal open/close so a late dry-run answer can never
|
||||
// repaint a modal the user has already dismissed or retargeted.
|
||||
this._deleteFolderProbeToken = 0;
|
||||
|
||||
// Bind methods
|
||||
this.handleTreeClick = this.handleTreeClick.bind(this);
|
||||
@@ -982,42 +985,71 @@ export class SidebarManager {
|
||||
/**
|
||||
* Open the folder delete modal for *path*.
|
||||
*
|
||||
* The tree already knows whether the subtree holds models (the same
|
||||
* models-only set that dims empty nodes), so the modal opens in one of two
|
||||
* states without a round trip: a confirmation for a model-free folder, or
|
||||
* an explanation when models would have to be cascaded over — a
|
||||
* folder-level cascade would bypass the per-model lifecycle bookkeeping,
|
||||
* so the backend refuses it and the UI says why.
|
||||
* The models-only set that dims empty nodes is only a prediction: it is
|
||||
* built from the scanned, non-excluded models, while the delete guard walks
|
||||
* the folder on disk and refuses on any weight file — excluded ones
|
||||
* included. So the modal opens on the prediction for an instant answer and
|
||||
* is then corrected by a dry run of the very delete the user is about to
|
||||
* confirm, which is the only way the button can never contradict the
|
||||
* backend (see `_verifyFolderContents`).
|
||||
*/
|
||||
showDeleteFolderModal(path) {
|
||||
async showDeleteFolderModal(path) {
|
||||
const modal = document.getElementById('deleteFolderModal');
|
||||
if (!modal) return;
|
||||
|
||||
// Defensive: the modal may have been absent when listeners were wired.
|
||||
this._wireDeleteFolderModal();
|
||||
|
||||
// Opening the modal — or targeting another folder — retires any
|
||||
// in-flight check from a previous open.
|
||||
const token = ++this._deleteFolderProbeToken;
|
||||
|
||||
const holdsModels = this.nonEmptyFolders ? this.nonEmptyFolders.has(path) : false;
|
||||
const prediction = holdsModels
|
||||
? {
|
||||
state: 'blocked',
|
||||
messageKey: 'sidebar.deleteFolderModal.notEmptyMessage',
|
||||
messageFallback: 'This folder still contains models. Delete or move them first.',
|
||||
}
|
||||
: { state: 'confirm' };
|
||||
|
||||
const probePending = this._supportsFolderManagement()
|
||||
&& typeof this.apiClient.deleteFolder === 'function';
|
||||
|
||||
this._renderDeleteFolderModal(path, prediction.state, {
|
||||
...prediction,
|
||||
checking: probePending,
|
||||
});
|
||||
|
||||
modalManager.showModal('deleteFolderModal');
|
||||
|
||||
if (!probePending) return;
|
||||
|
||||
await this._verifyFolderContents(path, token, prediction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paint one state of the folder delete modal.
|
||||
*
|
||||
* `state` is 'confirm' (deletion may proceed), 'blocked' (models would be
|
||||
* cascaded over, which the backend refuses) or 'busy' (a staged delete is
|
||||
* still pending inside the folder). `checking` keeps the confirm button
|
||||
* disabled while the authoritative server-side check runs.
|
||||
*/
|
||||
_renderDeleteFolderModal(path, state, options = {}) {
|
||||
const modal = document.getElementById('deleteFolderModal');
|
||||
if (!modal) return;
|
||||
|
||||
const title = modal.querySelector('[data-role="title"]');
|
||||
const message = modal.querySelector('[data-role="message"]');
|
||||
const info = modal.querySelector('[data-role="info"]');
|
||||
const confirmBtn = modal.querySelector('[data-action="confirm-delete-folder"]');
|
||||
|
||||
const holdsModels = this.nonEmptyFolders ? this.nonEmptyFolders.has(path) : false;
|
||||
const checking = Boolean(options.checking);
|
||||
|
||||
const pathLine = `<strong>${escapeHtml(translate('sidebar.deleteFolderModal.folderLabel', {}, 'Folder'))}:</strong> ${escapeHtml(path)}`;
|
||||
const extraLines = [];
|
||||
|
||||
if (holdsModels) {
|
||||
this._pendingDeleteFolderPath = null;
|
||||
title.textContent = translate(
|
||||
'sidebar.deleteFolderModal.notEmptyTitle', {}, 'Folder is not empty'
|
||||
);
|
||||
message.textContent = translate(
|
||||
'sidebar.deleteFolderModal.notEmptyMessage', {},
|
||||
'This folder still contains models. Delete or move them first.'
|
||||
);
|
||||
info.innerHTML = pathLine;
|
||||
confirmBtn.style.display = 'none';
|
||||
modal.dataset.state = 'blocked';
|
||||
} else {
|
||||
if (state === 'confirm') {
|
||||
this._pendingDeleteFolderPath = path;
|
||||
title.textContent = translate(
|
||||
'sidebar.deleteFolderModal.title', {}, 'Delete folder?'
|
||||
@@ -1026,18 +1058,146 @@ export class SidebarManager {
|
||||
'sidebar.deleteFolderModal.message', {},
|
||||
'The folder and everything inside it will be permanently removed from disk.'
|
||||
);
|
||||
info.innerHTML = `${pathLine}<br>${escapeHtml(translate(
|
||||
'sidebar.deleteFolderModal.emptyNote', {}, 'This folder contains no models.'
|
||||
))}`;
|
||||
if (!checking) {
|
||||
// While the check runs the "no models" claim is still only the
|
||||
// sidebar's prediction, so it is not repeated as a fact.
|
||||
extraLines.push(escapeHtml(translate(
|
||||
'sidebar.deleteFolderModal.emptyNote', {}, 'This folder contains no models.'
|
||||
)));
|
||||
}
|
||||
confirmBtn.style.display = '';
|
||||
modal.dataset.state = 'confirm';
|
||||
confirmBtn.disabled = checking;
|
||||
} else {
|
||||
this._pendingDeleteFolderPath = null;
|
||||
if (state === 'busy') {
|
||||
title.textContent = translate(
|
||||
'sidebar.deleteFolderModal.busyTitle', {}, 'A deletion is still pending'
|
||||
);
|
||||
message.textContent = translate(
|
||||
'sidebar.deleteFolderResult.busy', {},
|
||||
'A deletion is still pending inside this folder. Wait for the undo window to expire.'
|
||||
);
|
||||
} else {
|
||||
title.textContent = translate(
|
||||
'sidebar.deleteFolderModal.notEmptyTitle', {}, 'Folder is not empty'
|
||||
);
|
||||
message.textContent = translate(
|
||||
options.messageKey || 'sidebar.deleteFolderModal.notEmptyMessage',
|
||||
options.messageParams || {},
|
||||
options.messageFallback
|
||||
|| 'This folder still contains models. Delete or move them first.'
|
||||
);
|
||||
}
|
||||
confirmBtn.style.display = 'none';
|
||||
confirmBtn.disabled = true;
|
||||
}
|
||||
|
||||
modalManager.showModal('deleteFolderModal');
|
||||
if (checking) {
|
||||
extraLines.push(escapeHtml(translate(
|
||||
'sidebar.deleteFolderModal.checking', {}, 'Checking the folder contents...'
|
||||
)));
|
||||
}
|
||||
|
||||
info.innerHTML = [pathLine, ...extraLines].join('<br>');
|
||||
modal.dataset.state = state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the backend what deleting *relativePath* would actually remove.
|
||||
*
|
||||
* The dry run is authoritative: it walks the folder on disk and applies the
|
||||
* same guard the real delete uses, so it catches everything the sidebar
|
||||
* prediction cannot know — excluded models, weight files no scanner indexes
|
||||
* (a lora folder holding only a `.gguf`, say) and files added after the
|
||||
* last scan. A check that fails for any other reason falls back to the
|
||||
* prediction, leaving the real delete to report its own error.
|
||||
*/
|
||||
async _verifyFolderContents(relativePath, token, prediction) {
|
||||
let resolved = null;
|
||||
try {
|
||||
resolved = await this._resolveFolderAbsolutePath(relativePath);
|
||||
} catch (error) {
|
||||
console.error('[SidebarManager] Failed to resolve the folder path:', error);
|
||||
}
|
||||
|
||||
if (token !== this._deleteFolderProbeToken) return;
|
||||
|
||||
if (!resolved) {
|
||||
this._renderDeleteFolderModal(relativePath, prediction.state, prediction);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.apiClient.deleteFolder(resolved.absolutePath, { dryRun: true });
|
||||
if (token !== this._deleteFolderProbeToken) return;
|
||||
this._renderDeleteFolderModal(relativePath, 'confirm');
|
||||
} catch (error) {
|
||||
if (token !== this._deleteFolderProbeToken) return;
|
||||
if (error?.code === 'not_empty') {
|
||||
this._renderDeleteFolderModal(
|
||||
relativePath, 'blocked', this._notEmptyBlocker(error?.manifest)
|
||||
);
|
||||
} else if (error?.code === 'busy') {
|
||||
this._renderDeleteFolderModal(relativePath, 'busy');
|
||||
} else {
|
||||
this._renderDeleteFolderModal(relativePath, prediction.state, prediction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Message for a refused delete, split by whether the blocking models are
|
||||
* excluded from the library — the case where the sidebar legitimately shows
|
||||
* the folder as empty, which is exactly what used to be unexplained.
|
||||
*/
|
||||
_notEmptyBlocker(manifest) {
|
||||
const modelCount = Number(manifest?.model_count) || 0;
|
||||
const excludedCount = Number(manifest?.excluded_model_count) || 0;
|
||||
|
||||
if (modelCount > 0 && excludedCount > 0) {
|
||||
return {
|
||||
messageKey: 'sidebar.deleteFolderModal.notEmptyMessageExcluded',
|
||||
messageParams: { count: modelCount, excluded: excludedCount },
|
||||
messageFallback: `This folder still contains ${modelCount} model file(s), `
|
||||
+ `${excludedCount} of them excluded from the library. Un-exclude and `
|
||||
+ 'delete them first — deleting a folder never cascades over model files.',
|
||||
};
|
||||
}
|
||||
if (modelCount > 0) {
|
||||
return {
|
||||
messageKey: 'sidebar.deleteFolderModal.notEmptyMessageCount',
|
||||
messageParams: { count: modelCount },
|
||||
messageFallback: `This folder still contains ${modelCount} model file(s). `
|
||||
+ 'Delete or move them first — deleting a folder never cascades over model files.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
messageKey: 'sidebar.deleteFolderModal.notEmptyMessage',
|
||||
messageFallback: 'This folder still contains models. Delete or move them first.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a tree-relative folder path to the absolute business path the
|
||||
* folder APIs expect, or null when no model root is configured.
|
||||
*/
|
||||
async _resolveFolderAbsolutePath(relativePath) {
|
||||
const rootsData = await this.apiClient.fetchModelRoots();
|
||||
const roots = rootsData?.roots || [];
|
||||
const root = this._resolveDefaultRoot(roots);
|
||||
if (!root) return null;
|
||||
|
||||
return {
|
||||
root,
|
||||
absolutePath: this.combineRootAndRelativePath(root, relativePath),
|
||||
};
|
||||
}
|
||||
|
||||
hideDeleteFolderModal() {
|
||||
this._pendingDeleteFolderPath = null;
|
||||
// Retire any in-flight check so a late answer cannot repaint a modal
|
||||
// the user already dismissed.
|
||||
this._deleteFolderProbeToken += 1;
|
||||
modalManager.closeModal('deleteFolderModal');
|
||||
}
|
||||
|
||||
@@ -1057,16 +1217,13 @@ export class SidebarManager {
|
||||
}
|
||||
|
||||
try {
|
||||
const rootsData = await this.apiClient.fetchModelRoots();
|
||||
const roots = rootsData?.roots || [];
|
||||
const root = this._resolveDefaultRoot(roots);
|
||||
if (!root) {
|
||||
const resolved = await this._resolveFolderAbsolutePath(relativePath);
|
||||
if (!resolved) {
|
||||
showToast('sidebar.deleteFolderResult.noRoot', {}, 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
const absolutePath = this.combineRootAndRelativePath(root, relativePath);
|
||||
const result = await this.apiClient.deleteFolder(absolutePath);
|
||||
const result = await this.apiClient.deleteFolder(resolved.absolutePath);
|
||||
|
||||
// Drop the node (and its subtree) from the persisted expand state
|
||||
// before refreshing, otherwise stale keys accumulate forever. A
|
||||
@@ -1083,7 +1240,7 @@ export class SidebarManager {
|
||||
// the same 20s undo affordance the model delete flow uses.
|
||||
showActionToast('sidebar.deleteFolderResult.success', { name }, 'success', {
|
||||
actionText: translate('toast.undo.action', {}, 'Undo'),
|
||||
onAction: () => this._restoreDeletedFolder(absolutePath, relativePath),
|
||||
onAction: () => this._restoreDeletedFolder(resolved.absolutePath, relativePath),
|
||||
});
|
||||
} else {
|
||||
showToast(
|
||||
@@ -1097,7 +1254,18 @@ export class SidebarManager {
|
||||
} catch (error) {
|
||||
console.error('[SidebarManager] Error deleting folder:', error);
|
||||
if (error?.code === 'not_empty') {
|
||||
showToast('sidebar.deleteFolderResult.notEmpty', {}, 'warning');
|
||||
// The dry run normally catches this before the user can
|
||||
// confirm; reaching here means the folder changed in between.
|
||||
const modelCount = Number(error?.manifest?.model_count) || 0;
|
||||
if (modelCount > 0) {
|
||||
showToast(
|
||||
'sidebar.deleteFolderResult.notEmptyWithCount',
|
||||
{ count: modelCount },
|
||||
'warning'
|
||||
);
|
||||
} else {
|
||||
showToast('sidebar.deleteFolderResult.notEmpty', {}, 'warning');
|
||||
}
|
||||
} else if (error?.code === 'busy') {
|
||||
showToast('sidebar.deleteFolderResult.busy', {}, 'warning');
|
||||
} else {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -928,6 +928,7 @@ export class SettingsManager {
|
||||
|
||||
// Update API key status display (do NOT pre-fill the input)
|
||||
this.updateApiKeyStatus();
|
||||
this.updateHfApiKeyStatus();
|
||||
this.updateLlmApiKeyStatus();
|
||||
|
||||
// ── AI Provider settings ──────────────────────────────────────
|
||||
@@ -1191,6 +1192,9 @@ export class SettingsManager {
|
||||
|
||||
this.updateExampleImagesOpenSettingsVisibility();
|
||||
|
||||
// Load sidecar storage settings
|
||||
this.loadSidecarStorageSettings();
|
||||
|
||||
// Load download path templates
|
||||
this.loadDownloadPathTemplates();
|
||||
|
||||
@@ -1279,6 +1283,9 @@ export class SettingsManager {
|
||||
this.attachPathField('exampleImagesLocalRoot', {
|
||||
onAfterSelect: () => this.saveInputSetting('exampleImagesLocalRoot', 'example_images_local_root'),
|
||||
});
|
||||
this.attachPathField('sidecarStoragePath', {
|
||||
onAfterSelect: () => this.handleSidecarStoragePathChange(),
|
||||
});
|
||||
}
|
||||
|
||||
loadDownloadBackendSettings() {
|
||||
@@ -3378,6 +3385,402 @@ export class SettingsManager {
|
||||
this.updateExampleImagesOpenSettingsVisibility();
|
||||
}
|
||||
|
||||
loadSidecarStorageSettings() {
|
||||
const currentMode = state.global.settings.sidecar_storage_mode === 'centralized'
|
||||
? 'centralized'
|
||||
: 'alongside';
|
||||
|
||||
const modeSelect = document.getElementById('sidecarStorageMode');
|
||||
if (modeSelect) {
|
||||
modeSelect.value = currentMode;
|
||||
}
|
||||
// Baseline used to detect a mode change in handleSidecarStorageModeChange
|
||||
this._loadedSidecarStorageMode = currentMode;
|
||||
// Baseline used to detect a root change in handleSidecarStoragePathChange
|
||||
this._loadedSidecarStoragePath = state.global.settings.sidecar_storage_path || '';
|
||||
|
||||
const pathInput = document.getElementById('sidecarStoragePath');
|
||||
if (pathInput) {
|
||||
pathInput.value = state.global.settings.sidecar_storage_path || '';
|
||||
}
|
||||
|
||||
this.renderSidecarStorageInfo();
|
||||
this.updateSidecarStorageVisibility();
|
||||
}
|
||||
|
||||
// Show the backend-resolved storage root (covers the default location,
|
||||
// which the path input leaves blank) plus the portable-mode repo warning.
|
||||
renderSidecarStorageInfo() {
|
||||
const resolvedEl = document.getElementById('sidecarStorageResolvedPath');
|
||||
if (resolvedEl) {
|
||||
resolvedEl.textContent = state.global.settings.sidecar_storage_root || '';
|
||||
}
|
||||
const warningEl = document.getElementById('sidecarStorageRepoWarning');
|
||||
if (warningEl) {
|
||||
warningEl.style.display = state.global.settings.sidecar_storage_root_in_repo
|
||||
? 'block'
|
||||
: 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Re-pull just the derived sidecar fields after a path save: the resolved
|
||||
// root is computed server-side (default location, absolutization).
|
||||
async refreshSidecarStorageInfo() {
|
||||
try {
|
||||
const response = await fetch('/api/lm/settings');
|
||||
const data = await response.json();
|
||||
if (data.success && data.settings) {
|
||||
state.global.settings.sidecar_storage_root = data.settings.sidecar_storage_root;
|
||||
state.global.settings.sidecar_storage_root_is_default = data.settings.sidecar_storage_root_is_default;
|
||||
state.global.settings.sidecar_storage_root_in_repo = data.settings.sidecar_storage_root_in_repo;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to refresh sidecar storage info:', error);
|
||||
}
|
||||
this.renderSidecarStorageInfo();
|
||||
}
|
||||
|
||||
async openSidecarStorageLocation() {
|
||||
try {
|
||||
const response = await fetch('/api/lm/sidecars/open-location', {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.mode === 'clipboard' && data.path) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(data.path);
|
||||
showToast('settings.sidecarStorage.openLocationCopied', { path: data.path }, 'success');
|
||||
} catch (clipboardErr) {
|
||||
console.warn('Clipboard API not available:', clipboardErr);
|
||||
showToast('settings.sidecarStorage.openLocationClipboardFallback', { path: data.path }, 'info');
|
||||
}
|
||||
} else {
|
||||
showToast('settings.sidecarStorage.openLocationSuccess', {}, 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to open sidecar storage location:', error);
|
||||
showToast('settings.sidecarStorage.openLocationFailed', {}, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
updateSidecarStorageVisibility() {
|
||||
const modeSelect = document.getElementById('sidecarStorageMode');
|
||||
const pathSetting = document.getElementById('sidecarStoragePathSetting');
|
||||
if (!pathSetting) return;
|
||||
|
||||
const mode = modeSelect ? modeSelect.value : state.global.settings.sidecar_storage_mode;
|
||||
pathSetting.style.display = mode === 'centralized' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
async handleSidecarStorageModeChange() {
|
||||
const modeSelect = document.getElementById('sidecarStorageMode');
|
||||
if (!modeSelect) return;
|
||||
|
||||
const previousMode = this._loadedSidecarStorageMode || 'alongside';
|
||||
|
||||
await this.saveSelectSetting('sidecarStorageMode', 'sidecar_storage_mode');
|
||||
this.updateSidecarStorageVisibility();
|
||||
|
||||
const newMode = modeSelect.value;
|
||||
this._loadedSidecarStorageMode = newMode;
|
||||
|
||||
// Existing sidecars are not moved automatically; offer to migrate them.
|
||||
if (newMode !== previousMode) {
|
||||
const direction = newMode === 'centralized' ? 'to_centralized' : 'to_alongside';
|
||||
const confirmed = await this.confirmSidecarMigration(direction);
|
||||
if (confirmed) {
|
||||
await this.migrateSidecars(direction);
|
||||
} else {
|
||||
showToast('settings.sidecarStorage.migrationDeferred', {}, 'info');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Path change while centralized storage is active: the assets under the
|
||||
// previous root do not move by themselves, so offer a root relocation.
|
||||
async handleSidecarStoragePathChange() {
|
||||
const pathInput = document.getElementById('sidecarStoragePath');
|
||||
if (!pathInput) return;
|
||||
|
||||
const previousPath = this._loadedSidecarStoragePath || '';
|
||||
|
||||
await this.saveInputSetting('sidecarStoragePath', 'sidecar_storage_path');
|
||||
|
||||
const newPath = pathInput.value.trim();
|
||||
this._loadedSidecarStoragePath = newPath;
|
||||
|
||||
// The resolved root is server-side; refresh before any relocate
|
||||
// confirm so the dialog can name the real destination.
|
||||
await this.refreshSidecarStorageInfo();
|
||||
|
||||
const centralized = state.global.settings.sidecar_storage_mode === 'centralized';
|
||||
if (centralized && previousPath && previousPath !== newPath) {
|
||||
const confirmed = await this.confirmSidecarMigration('relocate_root');
|
||||
if (confirmed) {
|
||||
await this.migrateSidecars('relocate_root', { old_root: previousPath });
|
||||
} else {
|
||||
showToast('settings.sidecarStorage.migrationDeferred', {}, 'info');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Entry point for the "Migrate Sidecars Now" button: the direction follows
|
||||
// the currently saved storage mode.
|
||||
async confirmAndMigrateSidecars() {
|
||||
const direction = state.global.settings.sidecar_storage_mode === 'centralized'
|
||||
? 'to_centralized'
|
||||
: 'to_alongside';
|
||||
const confirmed = await this.confirmSidecarMigration(direction);
|
||||
if (confirmed) {
|
||||
await this.migrateSidecars(direction);
|
||||
}
|
||||
}
|
||||
|
||||
confirmSidecarMigration(direction) {
|
||||
const modalElement = document.getElementById('sidecarMigrationConfirmModal');
|
||||
if (!modalElement) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
const isToCentralized = direction === 'to_centralized';
|
||||
const isRelocate = direction === 'relocate_root';
|
||||
|
||||
const titleElement = modalElement.querySelector('[data-role="title"]');
|
||||
if (titleElement) {
|
||||
titleElement.textContent = isRelocate
|
||||
? translate('modals.sidecarMigrationConfirm.titleRelocateRoot', {}, 'Move sidecars to the new storage directory?')
|
||||
: isToCentralized
|
||||
? translate('modals.sidecarMigrationConfirm.titleToCentralized', {}, 'Move sidecars to centralized storage?')
|
||||
: translate('modals.sidecarMigrationConfirm.titleToAlongside', {}, 'Move sidecars back next to model files?');
|
||||
}
|
||||
|
||||
const messageElement = modalElement.querySelector('[data-role="message"]');
|
||||
if (messageElement) {
|
||||
messageElement.textContent = isRelocate
|
||||
? translate('settings.sidecarStorage.confirmRelocateRoot', {}, 'The centralized storage directory changed, but existing sidecars and preview images are still in the previous directory. Move them to the new directory now?')
|
||||
: isToCentralized
|
||||
? translate('settings.sidecarStorage.confirmToCentralized', {}, 'The storage mode changed, but existing .metadata.json sidecars and preview images are not moved automatically. Move them into the centralized storage directory now? You can also do this later with the "Migrate Sidecars Now" button.')
|
||||
: translate('settings.sidecarStorage.confirmToAlongside', {}, 'The storage mode changed, but existing .metadata.json sidecars and preview images are not moved automatically. Move them back next to their model files now? You can also do this later with the "Migrate Sidecars Now" button.');
|
||||
}
|
||||
|
||||
// Name the destination so users know where the files are going.
|
||||
const destinationElement = modalElement.querySelector('[data-role="destination"]');
|
||||
if (destinationElement) {
|
||||
const resolvedRoot = state.global.settings.sidecar_storage_root || '';
|
||||
if (!isToCentralized && !isRelocate || !resolvedRoot) {
|
||||
destinationElement.style.display = 'none';
|
||||
} else {
|
||||
destinationElement.textContent = translate(
|
||||
'modals.sidecarMigrationConfirm.destination', { path: resolvedRoot }, `Destination: ${resolvedRoot}`
|
||||
);
|
||||
destinationElement.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
const confirmButton = modalElement.querySelector('[data-action="confirm-sidecar-migration"]');
|
||||
const cancelButton = modalElement.querySelector('[data-action="cancel-sidecar-migration"]');
|
||||
if (!confirmButton || !cancelButton) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
confirmButton.textContent = translate('modals.sidecarMigrationConfirm.confirmButton', {}, 'Migrate Now');
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
|
||||
const cleanup = () => {
|
||||
confirmButton.removeEventListener('click', handleConfirm);
|
||||
cancelButton.removeEventListener('click', handleCancel);
|
||||
document.removeEventListener('keydown', handleEscape, true);
|
||||
};
|
||||
|
||||
const finalize = (proceed) => {
|
||||
if (resolved) {
|
||||
return;
|
||||
}
|
||||
resolved = true;
|
||||
cleanup();
|
||||
modalElement.classList.remove('show');
|
||||
// Keep body.modal-open: the settings modal underneath is still open.
|
||||
resolve(proceed);
|
||||
};
|
||||
|
||||
const handleConfirm = (event) => {
|
||||
event.preventDefault();
|
||||
finalize(true);
|
||||
};
|
||||
|
||||
const handleCancel = (event) => {
|
||||
event.preventDefault();
|
||||
finalize(false);
|
||||
};
|
||||
|
||||
// Capture phase + stopPropagation so ESC never reaches the
|
||||
// settings modal's own ESC handler underneath.
|
||||
const handleEscape = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation();
|
||||
finalize(false);
|
||||
}
|
||||
};
|
||||
|
||||
confirmButton.addEventListener('click', handleConfirm);
|
||||
cancelButton.addEventListener('click', handleCancel);
|
||||
document.addEventListener('keydown', handleEscape, true);
|
||||
|
||||
modalElement.classList.add('show');
|
||||
cancelButton.focus();
|
||||
});
|
||||
}
|
||||
|
||||
async migrateSidecars(direction, extraBody = {}) {
|
||||
const migrateBtn = document.getElementById('migrateSidecarsBtn');
|
||||
try {
|
||||
if (migrateBtn) {
|
||||
migrateBtn.disabled = true;
|
||||
migrateBtn.textContent = translate('settings.sidecarStorage.migratingButton', {}, 'Migrating...');
|
||||
}
|
||||
|
||||
state.loadingManager?.showSimpleLoading(
|
||||
translate('settings.sidecarStorage.migrating', {}, 'Migrating sidecars...')
|
||||
);
|
||||
|
||||
const response = await fetch('/api/lm/sidecars/migrate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
// The new mode/path is already saved by the time migration runs,
|
||||
// so the backend guard requires force=true to confirm the
|
||||
// "switch first, then migrate" flow.
|
||||
body: JSON.stringify({ direction, force: true, ...extraBody }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || data.success === false) {
|
||||
throw new Error(data.error || 'Migration failed');
|
||||
}
|
||||
|
||||
state.loadingManager?.hide();
|
||||
this.showSidecarMigrationResult(data);
|
||||
} catch (error) {
|
||||
console.error('Error migrating sidecars:', error);
|
||||
state.loadingManager?.hide();
|
||||
showToast('settings.sidecarStorage.migrateFailed', { message: error.message }, 'error');
|
||||
} finally {
|
||||
if (migrateBtn) {
|
||||
migrateBtn.disabled = false;
|
||||
migrateBtn.textContent = translate('settings.sidecarStorage.migrateButton', {}, 'Migrate Sidecars Now');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Post-migration summary: counters + storage location, with an "open
|
||||
// folder" shortcut. Closing reloads so cards pick up the new paths.
|
||||
showSidecarMigrationResult(result) {
|
||||
const modalElement = document.getElementById('sidecarMigrationResultModal');
|
||||
if (!modalElement) {
|
||||
showToast('settings.sidecarStorage.migrateSuccess', {}, 'success');
|
||||
resetAndReload(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const errorCount = result.error_count || 0;
|
||||
|
||||
const titleElement = modalElement.querySelector('[data-role="title"]');
|
||||
if (titleElement) {
|
||||
titleElement.textContent = errorCount
|
||||
? translate('modals.sidecarMigrationResult.titleWithErrors', { count: errorCount }, `Sidecar migration completed with ${errorCount} error(s)`)
|
||||
: translate('modals.sidecarMigrationResult.title', {}, 'Sidecar migration completed');
|
||||
}
|
||||
|
||||
const messageElement = modalElement.querySelector('[data-role="message"]');
|
||||
if (messageElement) {
|
||||
messageElement.textContent = translate(
|
||||
'modals.sidecarMigrationResult.summary',
|
||||
{
|
||||
moved: result.moved || 0,
|
||||
models: result.models_moved || 0,
|
||||
skipped: result.skipped || 0,
|
||||
conflicts: result.conflicts || 0,
|
||||
},
|
||||
`Moved ${result.moved || 0} files for ${result.models_moved || 0} models. Skipped: ${result.skipped || 0}, conflicts resolved: ${result.conflicts || 0}.`
|
||||
);
|
||||
}
|
||||
|
||||
const showLocation = result.direction !== 'to_alongside' && !!result.sidecar_root;
|
||||
|
||||
const destinationElement = modalElement.querySelector('[data-role="destination"]');
|
||||
if (destinationElement) {
|
||||
if (showLocation) {
|
||||
destinationElement.textContent = translate(
|
||||
'modals.sidecarMigrationResult.location',
|
||||
{ path: result.sidecar_root },
|
||||
`Storage location: ${result.sidecar_root}`
|
||||
);
|
||||
destinationElement.style.display = 'block';
|
||||
} else {
|
||||
destinationElement.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
const openButton = modalElement.querySelector('[data-action="open-sidecar-location"]');
|
||||
const closeButton = modalElement.querySelector('[data-action="close-sidecar-result"]');
|
||||
if (!closeButton) {
|
||||
resetAndReload(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (openButton) {
|
||||
openButton.style.display = showLocation ? '' : 'none';
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
closeButton.removeEventListener('click', handleClose);
|
||||
if (openButton) {
|
||||
openButton.removeEventListener('click', handleOpen);
|
||||
}
|
||||
document.removeEventListener('keydown', handleEscape, true);
|
||||
};
|
||||
|
||||
const handleClose = (event) => {
|
||||
event.preventDefault();
|
||||
cleanup();
|
||||
modalElement.classList.remove('show');
|
||||
// Reload so cards pick up metadata/preview paths from the new location
|
||||
resetAndReload(true);
|
||||
};
|
||||
|
||||
// Opening the folder keeps the result modal open; the reload happens
|
||||
// when the user closes it.
|
||||
const handleOpen = (event) => {
|
||||
event.preventDefault();
|
||||
this.openSidecarStorageLocation();
|
||||
};
|
||||
|
||||
// Capture phase + stopPropagation so ESC never reaches the settings
|
||||
// modal's own ESC handler underneath.
|
||||
const handleEscape = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation();
|
||||
handleClose(event);
|
||||
}
|
||||
};
|
||||
|
||||
closeButton.addEventListener('click', handleClose);
|
||||
if (openButton) {
|
||||
openButton.addEventListener('click', handleOpen);
|
||||
}
|
||||
document.addEventListener('keydown', handleEscape, true);
|
||||
|
||||
modalElement.classList.add('show');
|
||||
closeButton.focus();
|
||||
}
|
||||
|
||||
async loadMetadataArchiveSettings() {
|
||||
try {
|
||||
// Load current settings from state
|
||||
@@ -4350,6 +4753,28 @@ export class SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
updateHfApiKeyStatus() {
|
||||
const hasKey = !!(state.global.settings.huggingface_api_key_set ||
|
||||
state.global.settings.huggingface_api_key);
|
||||
const statusText = document.getElementById('huggingfaceApiKeyStatusText');
|
||||
const actionBtn = document.getElementById('huggingfaceApiKeyActionBtn');
|
||||
if (!statusText || !actionBtn) return;
|
||||
|
||||
if (hasKey) {
|
||||
statusText.classList.remove('api-key-status--unconfigured');
|
||||
statusText.classList.add('api-key-status--configured');
|
||||
statusText.innerHTML = '<i class="fas fa-check-circle text-success"></i> '
|
||||
+ translate('settings.huggingfaceApiKeyConfigured', {}, 'Configured');
|
||||
actionBtn.textContent = translate('common.actions.change', {}, 'Change');
|
||||
} else {
|
||||
statusText.classList.remove('api-key-status--configured');
|
||||
statusText.classList.add('api-key-status--unconfigured');
|
||||
statusText.innerHTML = '<i class="fas fa-times-circle text-error"></i> '
|
||||
+ translate('settings.huggingfaceApiKeyNotConfigured', {}, 'Not configured');
|
||||
actionBtn.textContent = translate('settings.huggingfaceApiKeySet', {}, 'Set up');
|
||||
}
|
||||
}
|
||||
|
||||
updateLlmApiKeyStatus() {
|
||||
const hasKey = !!(state.global.settings.llm_api_key_set || state.global.settings.llm_api_key);
|
||||
const statusText = document.getElementById('llmApiKeyStatusText');
|
||||
@@ -4397,9 +4822,17 @@ export class SettingsManager {
|
||||
const input = document.getElementById(inputId);
|
||||
if (input) input.value = '';
|
||||
if (!silent) {
|
||||
if (inputId === 'civitaiApiKey') {
|
||||
this.updateApiKeyStatus();
|
||||
}
|
||||
this.refreshApiKeyStatus(inputId);
|
||||
}
|
||||
}
|
||||
|
||||
refreshApiKeyStatus(inputId) {
|
||||
if (inputId === 'civitaiApiKey') {
|
||||
this.updateApiKeyStatus();
|
||||
} else if (inputId === 'huggingfaceApiKey') {
|
||||
this.updateHfApiKeyStatus();
|
||||
} else if (inputId === 'llmApiKey') {
|
||||
this.updateLlmApiKeyStatus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4409,11 +4842,16 @@ export class SettingsManager {
|
||||
|
||||
const value = input.value.trim();
|
||||
|
||||
const labelNames = {
|
||||
civitai_api_key: 'CivitAI API Key',
|
||||
huggingface_api_key: 'Hugging Face Access Token',
|
||||
llm_api_key: 'LLM API Key',
|
||||
};
|
||||
|
||||
try {
|
||||
await this.saveSetting(settingsKey, value);
|
||||
const labelName = settingsKey === 'civitai_api_key' ? 'CivitAI API Key' : 'LLM API Key';
|
||||
showToast('toast.settings.settingsUpdated',
|
||||
{ setting: labelName }, 'success');
|
||||
{ setting: labelNames[settingsKey] || 'API Key' }, 'success');
|
||||
} catch (error) {
|
||||
showToast('toast.settings.settingSaveFailed',
|
||||
{ message: error.message }, 'error');
|
||||
@@ -4421,13 +4859,12 @@ export class SettingsManager {
|
||||
}
|
||||
|
||||
// Update the in-memory flag so the UI reflects the change
|
||||
if (settingsKey === 'civitai_api_key') {
|
||||
state.global.settings.civitai_api_key_set = !!value;
|
||||
const setFlagKey = `${settingsKey}_set`;
|
||||
if (setFlagKey in state.global.settings) {
|
||||
state.global.settings[setFlagKey] = !!value;
|
||||
}
|
||||
this.cancelEditApiKey(true, inputId);
|
||||
if (inputId === 'civitaiApiKey') {
|
||||
this.updateApiKeyStatus();
|
||||
}
|
||||
this.refreshApiKeyStatus(inputId);
|
||||
}
|
||||
|
||||
toggleInputVisibility(button) {
|
||||
|
||||
@@ -6,6 +6,8 @@ import { DEFAULT_PATH_TEMPLATES, DEFAULT_FILENAME_TEMPLATES, DEFAULT_PRIORITY_TA
|
||||
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: '',
|
||||
@@ -60,6 +62,8 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||
download_skip_base_models: [],
|
||||
backup_auto_enabled: true,
|
||||
backup_retention_count: 5,
|
||||
sidecar_storage_mode: 'alongside',
|
||||
sidecar_storage_path: '',
|
||||
strip_lora_on_copy: false,
|
||||
use_new_license_icons: true,
|
||||
group_by_model: false,
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
+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">
|
||||
|
||||
@@ -96,6 +96,37 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidecar Migration Confirmation Modal
|
||||
Self-managed by SettingsManager (NOT registered with ModalManager): it
|
||||
stacks above the settings modal, like the directory picker. -->
|
||||
<div id="sidecarMigrationConfirmModal" class="modal delete-modal">
|
||||
<div class="modal-content delete-modal-content">
|
||||
<h2 data-role="title"></h2>
|
||||
<p class="delete-message" data-role="message"></p>
|
||||
<p class="delete-message sidecar-migration-destination" data-role="destination" style="display: none;"></p>
|
||||
<div class="modal-actions">
|
||||
<button class="cancel-btn" data-action="cancel-sidecar-migration">{{ t('common.actions.cancel') }}</button>
|
||||
<button class="primary-btn" data-action="confirm-sidecar-migration"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidecar Migration Result Modal
|
||||
Self-managed by SettingsManager: shown after a migration run with the
|
||||
outcome counters and the storage location; closing it reloads the page
|
||||
so cards pick up the new paths. -->
|
||||
<div id="sidecarMigrationResultModal" class="modal delete-modal">
|
||||
<div class="modal-content delete-modal-content">
|
||||
<h2 data-role="title"></h2>
|
||||
<p class="delete-message" data-role="message"></p>
|
||||
<p class="delete-message sidecar-migration-destination" data-role="destination" style="display: none;"></p>
|
||||
<div class="modal-actions">
|
||||
<button class="secondary-btn" data-action="open-sidecar-location" style="display: none;">{{ t('settings.sidecarStorage.openFolderButton') }}</button>
|
||||
<button class="primary-btn" data-action="close-sidecar-result">{{ t('common.actions.close') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar Folder Delete Confirmation Modal -->
|
||||
<!-- Shared by two states: 'confirm' (model-free folder) and 'blocked' (the
|
||||
subtree still holds models, so a cascade delete is refused). -->
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<h3>{{ t('help.gettingStarted.title') }}</h3>
|
||||
<div class="video-container">
|
||||
<div class="video-thumbnail" data-video-id="hvKw31YpE-U">
|
||||
<img src="/loras_static/images/video-thumbnails/getting-started.jpg" alt="Getting Started with LoRA Manager">
|
||||
<img src="{{ rel_prefix }}loras_static/images/video-thumbnails/getting-started.jpg" alt="Getting Started with LoRA Manager">
|
||||
<div class="video-play-overlay">
|
||||
<a href="https://www.youtube.com/watch?v=hvKw31YpE-U" target="_blank" class="external-link-btn">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
@@ -62,7 +62,7 @@
|
||||
<div class="video-item">
|
||||
<div class="video-container">
|
||||
<div class="video-thumbnail" data-video-id="videoseries?list=PLU2fMdHNl8ohz1u7Ke3ooOuMbU5Y4sgoj">
|
||||
<img src="/loras_static/images/video-thumbnails/updates-playlist.jpg" alt="LoRA Manager Updates Playlist">
|
||||
<img src="{{ rel_prefix }}loras_static/images/video-thumbnails/updates-playlist.jpg" alt="LoRA Manager Updates Playlist">
|
||||
<div class="video-play-overlay">
|
||||
<a href="https://www.youtube.com/playlist?list=PLU2fMdHNl8ohz1u7Ke3ooOuMbU5Y4sgoj" target="_blank" class="external-link-btn">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
|
||||
@@ -68,6 +68,43 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item api-key-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label>{{ t('settings.huggingfaceApiKey') }}</label>
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.huggingfaceApiKeyHelp') }}"></i>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<!-- Status display (shown when not editing) -->
|
||||
<div id="huggingfaceApiKeyStatus" class="api-key-status">
|
||||
<span id="huggingfaceApiKeyStatusText" class="api-key-status-text api-key-status--unconfigured">
|
||||
<i class="fas fa-times-circle text-error"></i>
|
||||
{{ t('settings.huggingfaceApiKeyNotConfigured') }}
|
||||
</span>
|
||||
<button type="button" class="secondary-btn" id="huggingfaceApiKeyActionBtn" onclick="settingsManager.editApiKey('huggingface_api_key', 'huggingfaceApiKey')">
|
||||
{{ t('settings.huggingfaceApiKeySet') }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Inline edit view (shown when editing) -->
|
||||
<div id="huggingfaceApiKeyEdit" class="api-key-edit is-hidden">
|
||||
<div class="api-key-input">
|
||||
<input type="text"
|
||||
id="huggingfaceApiKey"
|
||||
class="api-key-masked"
|
||||
placeholder="{{ t('settings.huggingfaceApiKeyPlaceholder') }}"
|
||||
autocomplete="off"
|
||||
data-mask="css" />
|
||||
<button type="button" class="toggle-visibility">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="primary-btn" onclick="settingsManager.saveApiKey('huggingface_api_key', 'huggingfaceApiKey')">{{ t('common.actions.save') }}</button>
|
||||
<button type="button" class="secondary-btn" onclick="settingsManager.cancelEditApiKey(true, 'huggingfaceApiKey')">{{ t('common.actions.cancel') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ sm.setting_select('civitaiHost', 'civitai_host', 'settings.civitaiHost.label', [
|
||||
('civitai.com', 'settings.civitaiHost.options.com'),
|
||||
('civitai.red', 'settings.civitaiHost.options.red'),
|
||||
|
||||
@@ -321,4 +321,77 @@
|
||||
('civitai_sqlite_archive', 'settings.metadataArchive.providerOrderCivitaiSqliteArchive'),
|
||||
], 'settings.metadataArchive.providerOrderHelp') }}
|
||||
</div>
|
||||
|
||||
<!-- Sidecar Storage -->
|
||||
<div class="settings-subsection">
|
||||
{{ sm.subsection_header('settings.sections.sidecarStorage') }}
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="sidecarStorageMode">
|
||||
{{ t('settings.sidecarStorage.mode') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.sidecarStorage.modeHelp') }}"></i>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-control select-control">
|
||||
<select id="sidecarStorageMode" onchange="settingsManager.handleSidecarStorageModeChange()">
|
||||
<option value="alongside">{{ t('settings.sidecarStorage.modeOptions.alongside') }}</option>
|
||||
<option value="centralized">{{ t('settings.sidecarStorage.modeOptions.centralized') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" id="sidecarStoragePathSetting" style="display: none;">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="sidecarStoragePath">
|
||||
{{ t('settings.sidecarStorage.path') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.sidecarStorage.pathHelp') }}"></i>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<div class="text-input-wrapper">
|
||||
<input type="text" id="sidecarStoragePath"
|
||||
placeholder="{{ t('settings.sidecarStorage.pathPlaceholder') }}"
|
||||
onblur="settingsManager.handleSidecarStoragePathChange()"
|
||||
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<div class="input-help sidecar-storage-location">
|
||||
{{ t('settings.sidecarStorage.effectivePathLabel') }}
|
||||
<code id="sidecarStorageResolvedPath" class="backup-location-path"></code>
|
||||
</div>
|
||||
<div class="input-help sidecar-storage-repo-warning" id="sidecarStorageRepoWarning" style="display: none;">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
{{ t('settings.sidecarStorage.repoWarning') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<button type="button" class="secondary-btn" id="sidecarStorageOpenBtn" onclick="settingsManager.openSidecarStorageLocation()">
|
||||
{{ t('settings.sidecarStorage.openFolderButton') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label>
|
||||
{{ t('settings.sidecarStorage.management') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.sidecarStorage.managementHelp') }}"></i>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<button type="button" id="migrateSidecarsBtn" class="primary-btn" onclick="settingsManager.confirmAndMigrateSidecars()">
|
||||
{{ t('settings.sidecarStorage.migrateButton') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
<i class="fas fa-chevron-down toggle-icon"></i>
|
||||
</button>
|
||||
<div class="qrcode-container" id="qrCodeContainer">
|
||||
<img src="/loras_static/images/wechat-qr.webp" alt="WeChat Pay QR Code" class="qrcode-image">
|
||||
<img src="{{ rel_prefix }}loras_static/images/wechat-qr.webp" alt="WeChat Pay QR Code" class="qrcode-image">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -71,5 +71,5 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block main_script %}
|
||||
<script type="module" src="/loras_static/js/embeddings.js?v={{ version }}"></script>
|
||||
<script type="module" src="{{ rel_prefix }}loras_static/js/embeddings.js?v={{ version }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -27,6 +27,6 @@
|
||||
|
||||
{% block main_script %}
|
||||
{% if not is_initializing %}
|
||||
<script type="module" src="/loras_static/js/loras.js?v={{ version }}"></script>
|
||||
<script type="module" src="{{ rel_prefix }}loras_static/js/loras.js?v={{ version }}"></script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -167,8 +167,8 @@
|
||||
|
||||
{% block main_script %}
|
||||
{% if other_disabled or other_no_paths %}
|
||||
<script type="module" src="/loras_static/js/other_disabled.js?v={{ version }}"></script>
|
||||
<script type="module" src="{{ rel_prefix }}loras_static/js/other_disabled.js?v={{ version }}"></script>
|
||||
{% else %}
|
||||
<script type="module" src="/loras_static/js/other.js?v={{ version }}"></script>
|
||||
<script type="module" src="{{ rel_prefix }}loras_static/js/other.js?v={{ version }}"></script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
{% block page_id %}recipes{% endblock %}
|
||||
|
||||
{% block page_css %}
|
||||
<link rel="stylesheet" href="/loras_static/css/components/card.css?v={{ version }}">
|
||||
<link rel="stylesheet" href="/loras_static/css/components/recipe-modal.css?v={{ version }}">
|
||||
<link rel="stylesheet" href="/loras_static/css/components/import-modal.css?v={{ version }}">
|
||||
<link rel="stylesheet" href="/loras_static/css/components/batch-import-modal.css?v={{ version }}">
|
||||
<link rel="stylesheet" href="{{ rel_prefix }}loras_static/css/components/card.css?v={{ version }}">
|
||||
<link rel="stylesheet" href="{{ rel_prefix }}loras_static/css/components/recipe-modal.css?v={{ version }}">
|
||||
<link rel="stylesheet" href="{{ rel_prefix }}loras_static/css/components/import-modal.css?v={{ version }}">
|
||||
<link rel="stylesheet" href="{{ rel_prefix }}loras_static/css/components/batch-import-modal.css?v={{ version }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block additional_components %}
|
||||
@@ -113,5 +113,5 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block main_script %}
|
||||
<script type="module" src="/loras_static/js/recipes.js?v={{ version }}"></script>
|
||||
<script type="module" src="{{ rel_prefix }}loras_static/js/recipes.js?v={{ version }}"></script>
|
||||
{% endblock %}
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{% block head_scripts %}
|
||||
<!-- Add Chart.js for statistics page -->
|
||||
<script src="/loras_static/vendor/chart.js/chart.umd.js"></script>
|
||||
<script src="{{ rel_prefix }}loras_static/vendor/chart.js/chart.umd.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block init_title %}{{ t('initialization.statistics.title') }}{% endblock %}
|
||||
@@ -192,6 +192,6 @@
|
||||
|
||||
{% block main_script %}
|
||||
{% if not is_initializing %}
|
||||
<script type="module" src="/loras_static/js/statistics.js?v={{ version }}"></script>
|
||||
<script type="module" src="{{ rel_prefix }}loras_static/js/statistics.js?v={{ version }}"></script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -50,6 +50,22 @@ vi.mock(UTILS_MODULE, () => ({
|
||||
chainCallback: (proto, property, callback) => {
|
||||
proto[property] = callback;
|
||||
},
|
||||
interceptModeChange: (node, onModeChange) => {
|
||||
let currentMode = node.mode;
|
||||
Object.defineProperty(node, "mode", {
|
||||
configurable: true,
|
||||
get() {
|
||||
return currentMode;
|
||||
},
|
||||
set(value) {
|
||||
const oldValue = currentMode;
|
||||
currentMode = value;
|
||||
if (oldValue !== value) {
|
||||
onModeChange(value, oldValue);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
getAllGraphNodes,
|
||||
getNodeFromGraph,
|
||||
getWidgetByName,
|
||||
|
||||
@@ -277,5 +277,44 @@ describe("Node mode change handling", () => {
|
||||
new Set(["LoaderLora1", "LoaderLora2"])
|
||||
);
|
||||
});
|
||||
|
||||
it("should keep bypass state in the shell state on ECS frontends (issue #1123)", async () => {
|
||||
// ComfyUI frontend >= 1.53 backs `mode` with a prototype accessor over
|
||||
// `node._state.mode` and serializes from `_state`; the interceptor must
|
||||
// delegate to it instead of shadowing it.
|
||||
class EcsLGraphNode {
|
||||
constructor() {
|
||||
this._state = { mode: 0 };
|
||||
}
|
||||
get mode() {
|
||||
return this._state.mode;
|
||||
}
|
||||
set mode(value) {
|
||||
this._state.mode = value;
|
||||
}
|
||||
}
|
||||
|
||||
const ecsNode = new EcsLGraphNode();
|
||||
Object.assign(ecsNode, {
|
||||
comfyClass: "Lora Loader (LoraManager)",
|
||||
widgets: [
|
||||
{ name: "text", value: "", options: {}, callback: null },
|
||||
{ name: "loras", value: [], options: {}, callback: null },
|
||||
],
|
||||
addInput: vi.fn(),
|
||||
graph: {},
|
||||
});
|
||||
|
||||
const nodeType = { comfyClass: "Lora Loader (LoraManager)", prototype: {} };
|
||||
await extension.beforeRegisterNodeDef(nodeType, {}, {});
|
||||
nodeType.prototype.onNodeCreated.call(ecsNode);
|
||||
|
||||
// Bypass the node: the write must reach the shell state that
|
||||
// serialization reads from.
|
||||
ecsNode.mode = 4;
|
||||
expect(ecsNode._state.mode).toBe(4);
|
||||
expect(ecsNode.mode).toBe(4);
|
||||
expect(updateConnectedTriggerWords).toHaveBeenCalledWith(ecsNode, expect.anything());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -631,24 +631,38 @@ describe('SidebarManager folder deletion', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('opens the confirm state for a folder whose subtree holds no models', () => {
|
||||
const manager = createManager(createApiClient());
|
||||
it('opens the confirm state for a folder whose subtree holds no models', async () => {
|
||||
const apiClient = createApiClient();
|
||||
const manager = createManager(apiClient);
|
||||
manager.nonEmptyFolders = new Set(['', 'full']);
|
||||
|
||||
manager.showDeleteFolderModal('empty');
|
||||
await manager.showDeleteFolderModal('empty');
|
||||
|
||||
const modal = document.getElementById('deleteFolderModal');
|
||||
expect(modal.dataset.state).toBe('confirm');
|
||||
expect(confirmBtn().style.display).toBe('');
|
||||
expect(confirmBtn().disabled).toBe(false);
|
||||
expect(manager._pendingDeleteFolderPath).toBe('empty');
|
||||
expect(modalManager.showModal).toHaveBeenCalledWith('deleteFolderModal');
|
||||
// The prediction is confirmed against the real guard before the user can
|
||||
// act on it.
|
||||
expect(apiClient.deleteFolder).toHaveBeenCalledWith(
|
||||
'/models/loras/empty', { dryRun: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('explains the refusal when the subtree still holds models', () => {
|
||||
const manager = createManager(createApiClient());
|
||||
it('explains the refusal when the subtree still holds models', async () => {
|
||||
const conflict = Object.assign(new Error('still contains models'), {
|
||||
code: 'not_empty',
|
||||
manifest: { model_count: 2, excluded_model_count: 0 },
|
||||
});
|
||||
const apiClient = createApiClient({
|
||||
deleteFolder: vi.fn().mockRejectedValue(conflict),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.nonEmptyFolders = new Set(['', 'full']);
|
||||
|
||||
manager.showDeleteFolderModal('full');
|
||||
await manager.showDeleteFolderModal('full');
|
||||
|
||||
const modal = document.getElementById('deleteFolderModal');
|
||||
expect(modal.dataset.state).toBe('blocked');
|
||||
@@ -656,17 +670,134 @@ describe('SidebarManager folder deletion', () => {
|
||||
expect(manager._pendingDeleteFolderPath).toBeNull();
|
||||
});
|
||||
|
||||
it('treats an unknown folder as model-free when the models-only set is missing', () => {
|
||||
// nonEmptyFolders is null outside the include-empty tree; the server still
|
||||
// refuses a non-empty folder, so the client falls back to the confirm state.
|
||||
it('treats an unknown folder as model-free when the models-only set is missing', async () => {
|
||||
// nonEmptyFolders is null outside the include-empty tree; the dry run is
|
||||
// what actually decides, so the prediction is only a starting point.
|
||||
const manager = createManager(createApiClient());
|
||||
manager.nonEmptyFolders = null;
|
||||
|
||||
manager.showDeleteFolderModal('empty');
|
||||
await manager.showDeleteFolderModal('empty');
|
||||
|
||||
expect(document.getElementById('deleteFolderModal').dataset.state).toBe('confirm');
|
||||
});
|
||||
|
||||
it('blocks a folder the tree shows as empty when only excluded models live there', async () => {
|
||||
// The reported mismatch: excluded models are absent from the models-only
|
||||
// set (so the node dims as empty), yet they are real weight files on disk
|
||||
// and the delete guard refuses to cascade over them.
|
||||
const conflict = Object.assign(
|
||||
new Error('Folder still contains 3 model file(s), all excluded from the library'),
|
||||
{ code: 'not_empty', manifest: { model_count: 3, excluded_model_count: 3 } }
|
||||
);
|
||||
const apiClient = createApiClient({
|
||||
deleteFolder: vi.fn().mockRejectedValue(conflict),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.nonEmptyFolders = new Set(['', 'full']);
|
||||
|
||||
await manager.showDeleteFolderModal('Flux.1 D/test');
|
||||
|
||||
const modal = document.getElementById('deleteFolderModal');
|
||||
expect(modal.dataset.state).toBe('blocked');
|
||||
expect(confirmBtn().style.display).toBe('none');
|
||||
expect(manager._pendingDeleteFolderPath).toBeNull();
|
||||
// The message names the excluded models instead of contradicting the tree.
|
||||
expect(modal.querySelector('[data-role="message"]').textContent)
|
||||
.toContain('excluded from the library');
|
||||
expect(apiClient.deleteFolder).toHaveBeenCalledWith(
|
||||
'/models/loras/Flux.1 D/test', { dryRun: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('reports how many model files block the delete when some are excluded', async () => {
|
||||
const conflict = Object.assign(new Error('still contains models'), {
|
||||
code: 'not_empty',
|
||||
manifest: { model_count: 4, excluded_model_count: 1 },
|
||||
});
|
||||
const apiClient = createApiClient({
|
||||
deleteFolder: vi.fn().mockRejectedValue(conflict),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.nonEmptyFolders = new Set(['', 'full']);
|
||||
|
||||
await manager.showDeleteFolderModal('mixed');
|
||||
|
||||
expect(
|
||||
document.getElementById('deleteFolderModal')
|
||||
.querySelector('[data-role="message"]').textContent
|
||||
).toContain('4 model file(s)');
|
||||
});
|
||||
|
||||
it('blocks the delete while a staged delete is still pending', async () => {
|
||||
const busy = Object.assign(new Error('staged delete pending'), { code: 'busy' });
|
||||
const apiClient = createApiClient({
|
||||
deleteFolder: vi.fn().mockRejectedValue(busy),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.nonEmptyFolders = new Set(['', 'full']);
|
||||
|
||||
await manager.showDeleteFolderModal('empty');
|
||||
|
||||
const modal = document.getElementById('deleteFolderModal');
|
||||
expect(modal.dataset.state).toBe('busy');
|
||||
expect(confirmBtn().style.display).toBe('none');
|
||||
});
|
||||
|
||||
it('keeps the confirm button disabled until the check settles', async () => {
|
||||
let release;
|
||||
const apiClient = createApiClient({
|
||||
fetchModelRoots: vi.fn(() => new Promise((resolve) => { release = resolve; })),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.nonEmptyFolders = new Set(['', 'full']);
|
||||
|
||||
const pending = manager.showDeleteFolderModal('empty');
|
||||
expect(confirmBtn().disabled).toBe(true);
|
||||
|
||||
release({ roots: ['/models/loras'] });
|
||||
await pending;
|
||||
|
||||
expect(confirmBtn().disabled).toBe(false);
|
||||
expect(document.getElementById('deleteFolderModal').dataset.state).toBe('confirm');
|
||||
});
|
||||
|
||||
it('ignores a dry-run answer that lands after the modal was dismissed', async () => {
|
||||
let rejectProbe;
|
||||
const apiClient = createApiClient({
|
||||
deleteFolder: vi.fn(() => new Promise((_resolve, reject) => { rejectProbe = reject; })),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.nonEmptyFolders = new Set(['', 'full']);
|
||||
|
||||
const pending = manager.showDeleteFolderModal('empty');
|
||||
expect(document.getElementById('deleteFolderModal').dataset.state).toBe('confirm');
|
||||
|
||||
await vi.waitFor(() => expect(rejectProbe).toBeTypeOf('function'));
|
||||
|
||||
manager.hideDeleteFolderModal();
|
||||
rejectProbe(Object.assign(new Error('still contains models'), {
|
||||
code: 'not_empty',
|
||||
manifest: { model_count: 1, excluded_model_count: 0 },
|
||||
}));
|
||||
await pending;
|
||||
|
||||
expect(document.getElementById('deleteFolderModal').dataset.state).toBe('confirm');
|
||||
});
|
||||
|
||||
it('falls back to the tree prediction when the check fails for another reason', async () => {
|
||||
const apiClient = createApiClient({
|
||||
deleteFolder: vi.fn().mockRejectedValue(new Error('network down')),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.nonEmptyFolders = new Set(['', 'full']);
|
||||
|
||||
await manager.showDeleteFolderModal('empty');
|
||||
|
||||
const modal = document.getElementById('deleteFolderModal');
|
||||
expect(modal.dataset.state).toBe('confirm');
|
||||
expect(confirmBtn().disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('deletes the folder and offers the undo affordance for an empty one', async () => {
|
||||
const apiClient = createApiClient();
|
||||
const manager = createManager(apiClient);
|
||||
@@ -732,6 +863,25 @@ describe('SidebarManager folder deletion', () => {
|
||||
expect(manager.refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('includes the model count in the stale-tree toast when the manifest has one', async () => {
|
||||
const conflict = Object.assign(new Error('still contains models'), {
|
||||
code: 'not_empty',
|
||||
manifest: { model_count: 3, excluded_model_count: 3 },
|
||||
});
|
||||
const apiClient = createApiClient({
|
||||
deleteFolder: vi.fn().mockRejectedValue(conflict),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const success = await manager._deleteFolder('full');
|
||||
|
||||
expect(success).toBe(false);
|
||||
expect(showToast).toHaveBeenCalledWith(
|
||||
'sidebar.deleteFolderResult.notEmptyWithCount', { count: 3 }, 'warning'
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces a busy folder with a staged delete', async () => {
|
||||
const busy = Object.assign(new Error('staged delete pending'), { code: 'busy' });
|
||||
const apiClient = createApiClient({
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
|
||||
modalManager: {
|
||||
closeModal: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||
showToast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/state/index.js', () => {
|
||||
const settings = {};
|
||||
return {
|
||||
state: {
|
||||
global: {
|
||||
settings,
|
||||
},
|
||||
loadingManager: {
|
||||
showSimpleLoading: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
},
|
||||
},
|
||||
createDefaultSettings: () => ({
|
||||
language: 'en',
|
||||
sidecar_storage_mode: 'alongside',
|
||||
sidecar_storage_path: '',
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
||||
resetAndReload: vi.fn(),
|
||||
getModelApiClient: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/constants.js', () => ({
|
||||
DOWNLOAD_PATH_TEMPLATES: {},
|
||||
DEFAULT_PATH_TEMPLATES: {},
|
||||
MAPPABLE_BASE_MODELS: [],
|
||||
PATH_TEMPLATE_PLACEHOLDERS: {},
|
||||
FILENAME_TEMPLATE_PLACEHOLDERS: [],
|
||||
DEFAULT_FILENAME_TEMPLATES: { lora: '', checkpoint: '', embedding: '' },
|
||||
DEFAULT_PRIORITY_TAG_CONFIG: {
|
||||
lora: 'character, style',
|
||||
checkpoint: 'base, guide',
|
||||
embedding: 'hint',
|
||||
},
|
||||
getMappableBaseModelsDynamic: () => [],
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||
translate: (_key, _params, fallback) => fallback ?? '',
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/i18n/index.js', () => ({
|
||||
i18n: {
|
||||
getCurrentLocale: () => 'en',
|
||||
setLanguage: vi.fn().mockResolvedValue(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/components/shared/ModelCard.js', () => ({
|
||||
configureModelCardVideo: vi.fn(),
|
||||
}));
|
||||
|
||||
import { SettingsManager } from '../../../static/js/managers/SettingsManager.js';
|
||||
import { showToast } from '../../../static/js/utils/uiHelpers.js';
|
||||
import { resetAndReload } from '../../../static/js/api/modelApiFactory.js';
|
||||
import { state } from '../../../static/js/state/index.js';
|
||||
|
||||
const createManager = () => {
|
||||
state.global.settings = {};
|
||||
const initSettingsSpy = vi
|
||||
.spyOn(SettingsManager.prototype, 'initializeSettings')
|
||||
.mockResolvedValue();
|
||||
const initializeSpy = vi
|
||||
.spyOn(SettingsManager.prototype, 'initialize')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const manager = new SettingsManager();
|
||||
|
||||
initSettingsSpy.mockRestore();
|
||||
initializeSpy.mockRestore();
|
||||
|
||||
return manager;
|
||||
};
|
||||
|
||||
const appendSidecarControls = () => {
|
||||
const select = document.createElement('select');
|
||||
select.id = 'sidecarStorageMode';
|
||||
['alongside', 'centralized'].forEach((value) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = value;
|
||||
select.appendChild(option);
|
||||
});
|
||||
|
||||
const pathSetting = document.createElement('div');
|
||||
pathSetting.id = 'sidecarStoragePathSetting';
|
||||
pathSetting.style.display = 'none';
|
||||
|
||||
const pathInput = document.createElement('input');
|
||||
pathInput.id = 'sidecarStoragePath';
|
||||
|
||||
const migrateBtn = document.createElement('button');
|
||||
migrateBtn.id = 'migrateSidecarsBtn';
|
||||
|
||||
document.body.append(select, pathSetting, pathInput, migrateBtn);
|
||||
return { select, pathSetting, pathInput, migrateBtn };
|
||||
};
|
||||
|
||||
const appendMigrationModal = () => {
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'sidecarMigrationConfirmModal';
|
||||
modal.innerHTML = `
|
||||
<h2 data-role="title"></h2>
|
||||
<p data-role="message"></p>
|
||||
<p data-role="destination" style="display:none"></p>
|
||||
<button data-action="confirm-sidecar-migration"></button>
|
||||
<button data-action="cancel-sidecar-migration"></button>`;
|
||||
document.body.appendChild(modal);
|
||||
return modal;
|
||||
};
|
||||
|
||||
const mockFetchOk = (payload = { success: true }) => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(payload),
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete global.fetch;
|
||||
});
|
||||
|
||||
describe('SettingsManager sidecar storage', () => {
|
||||
describe('loadSidecarStorageSettings', () => {
|
||||
it('loads alongside mode and hides the centralized path input', () => {
|
||||
const manager = createManager();
|
||||
const { select, pathSetting, pathInput } = appendSidecarControls();
|
||||
state.global.settings = { sidecar_storage_mode: 'alongside', sidecar_storage_path: '/data/sidecars' };
|
||||
|
||||
manager.loadSidecarStorageSettings();
|
||||
|
||||
expect(select.value).toBe('alongside');
|
||||
expect(pathInput.value).toBe('/data/sidecars');
|
||||
expect(pathSetting.style.display).toBe('none');
|
||||
expect(manager._loadedSidecarStorageMode).toBe('alongside');
|
||||
});
|
||||
|
||||
it('loads centralized mode and shows the path input', () => {
|
||||
const manager = createManager();
|
||||
const { select, pathSetting } = appendSidecarControls();
|
||||
state.global.settings = { sidecar_storage_mode: 'centralized' };
|
||||
|
||||
manager.loadSidecarStorageSettings();
|
||||
|
||||
expect(select.value).toBe('centralized');
|
||||
expect(pathSetting.style.display).toBe('block');
|
||||
expect(manager._loadedSidecarStorageMode).toBe('centralized');
|
||||
});
|
||||
|
||||
it('falls back to alongside for unknown stored modes', () => {
|
||||
const manager = createManager();
|
||||
const { select } = appendSidecarControls();
|
||||
state.global.settings = { sidecar_storage_mode: 'bogus' };
|
||||
|
||||
manager.loadSidecarStorageSettings();
|
||||
|
||||
expect(select.value).toBe('alongside');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleSidecarStorageModeChange', () => {
|
||||
it('does not prompt for migration when the mode is unchanged', async () => {
|
||||
const manager = createManager();
|
||||
const { select } = appendSidecarControls();
|
||||
appendMigrationModal();
|
||||
state.global.settings = { sidecar_storage_mode: 'alongside' };
|
||||
manager._loadedSidecarStorageMode = 'alongside';
|
||||
select.value = 'alongside';
|
||||
mockFetchOk();
|
||||
|
||||
await manager.handleSidecarStorageModeChange();
|
||||
|
||||
expect(global.fetch).not.toHaveBeenCalledWith(
|
||||
'/api/lm/sidecars/migrate',
|
||||
expect.anything()
|
||||
);
|
||||
expect(showToast).toHaveBeenCalledWith(
|
||||
'toast.settings.settingsUpdated',
|
||||
expect.anything(),
|
||||
'success'
|
||||
);
|
||||
});
|
||||
|
||||
it('migrates to centralized after the user confirms the prompt', async () => {
|
||||
const manager = createManager();
|
||||
const { select, pathSetting } = appendSidecarControls();
|
||||
const modal = appendMigrationModal();
|
||||
state.global.settings = { sidecar_storage_mode: 'alongside' };
|
||||
manager._loadedSidecarStorageMode = 'alongside';
|
||||
select.value = 'centralized';
|
||||
mockFetchOk();
|
||||
|
||||
const changePromise = manager.handleSidecarStorageModeChange();
|
||||
|
||||
await vi.waitFor(() => expect(modal.classList.contains('show')).toBe(true));
|
||||
modal.querySelector('[data-action="confirm-sidecar-migration"]').click();
|
||||
await changePromise;
|
||||
|
||||
expect(state.global.settings.sidecar_storage_mode).toBe('centralized');
|
||||
expect(pathSetting.style.display).toBe('block');
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/lm/sidecars/migrate', expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ direction: 'to_centralized', force: true }),
|
||||
}));
|
||||
expect(showToast).toHaveBeenCalledWith('settings.sidecarStorage.migrateSuccess', {}, 'success');
|
||||
expect(resetAndReload).toHaveBeenCalledWith(true);
|
||||
expect(modal.classList.contains('show')).toBe(false);
|
||||
});
|
||||
|
||||
it('names the resolved destination in the confirm dialog', async () => {
|
||||
const manager = createManager();
|
||||
const { select } = appendSidecarControls();
|
||||
const modal = appendMigrationModal();
|
||||
state.global.settings = {
|
||||
sidecar_storage_mode: 'alongside',
|
||||
sidecar_storage_root: '/data/sidecars',
|
||||
};
|
||||
manager._loadedSidecarStorageMode = 'alongside';
|
||||
select.value = 'centralized';
|
||||
mockFetchOk();
|
||||
|
||||
const changePromise = manager.handleSidecarStorageModeChange();
|
||||
await vi.waitFor(() => expect(modal.classList.contains('show')).toBe(true));
|
||||
|
||||
const destination = modal.querySelector('[data-role="destination"]');
|
||||
expect(destination.textContent).toContain('/data/sidecars');
|
||||
expect(destination.style.display).toBe('block');
|
||||
|
||||
modal.querySelector('[data-action="cancel-sidecar-migration"]').click();
|
||||
await changePromise;
|
||||
});
|
||||
|
||||
it('shows a deferred notice and skips migration when the user cancels', async () => { const manager = createManager();
|
||||
const { select } = appendSidecarControls();
|
||||
const modal = appendMigrationModal();
|
||||
state.global.settings = { sidecar_storage_mode: 'centralized' };
|
||||
manager._loadedSidecarStorageMode = 'centralized';
|
||||
select.value = 'alongside';
|
||||
mockFetchOk();
|
||||
|
||||
const changePromise = manager.handleSidecarStorageModeChange();
|
||||
await vi.waitFor(() => expect(modal.classList.contains('show')).toBe(true));
|
||||
modal.querySelector('[data-action="cancel-sidecar-migration"]').click();
|
||||
await changePromise;
|
||||
|
||||
const migrateCalls = global.fetch.mock.calls.filter(([url]) => url === '/api/lm/sidecars/migrate');
|
||||
expect(migrateCalls).toHaveLength(0);
|
||||
expect(showToast).toHaveBeenCalledWith('settings.sidecarStorage.migrationDeferred', {}, 'info');
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirmAndMigrateSidecars', () => {
|
||||
it('derives the migration direction from the saved mode', async () => {
|
||||
const manager = createManager();
|
||||
appendSidecarControls();
|
||||
const modal = appendMigrationModal();
|
||||
state.global.settings = { sidecar_storage_mode: 'centralized' };
|
||||
mockFetchOk();
|
||||
|
||||
const confirmPromise = manager.confirmAndMigrateSidecars();
|
||||
await vi.waitFor(() => expect(modal.classList.contains('show')).toBe(true));
|
||||
modal.querySelector('[data-action="confirm-sidecar-migration"]').click();
|
||||
await confirmPromise;
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/lm/sidecars/migrate', expect.objectContaining({
|
||||
body: JSON.stringify({ direction: 'to_centralized', force: true }),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('migrateSidecars', () => {
|
||||
it('surfaces backend failures as an error toast', async () => {
|
||||
const manager = createManager();
|
||||
const { migrateBtn } = appendSidecarControls();
|
||||
mockFetchOk({ success: false, error: 'disk full' });
|
||||
|
||||
await manager.migrateSidecars('to_alongside');
|
||||
|
||||
expect(showToast).toHaveBeenCalledWith(
|
||||
'settings.sidecarStorage.migrateFailed',
|
||||
{ message: 'disk full' },
|
||||
'error'
|
||||
);
|
||||
expect(resetAndReload).not.toHaveBeenCalled();
|
||||
expect(migrateBtn.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleSidecarStoragePathChange', () => { it('offers root relocation when the path changes in centralized mode', async () => {
|
||||
const manager = createManager();
|
||||
const { pathInput } = appendSidecarControls();
|
||||
const modal = appendMigrationModal();
|
||||
state.global.settings = { sidecar_storage_mode: 'centralized', sidecar_storage_path: '/old/root' };
|
||||
manager._loadedSidecarStoragePath = '/old/root';
|
||||
pathInput.value = '/new/root';
|
||||
mockFetchOk();
|
||||
|
||||
const changePromise = manager.handleSidecarStoragePathChange();
|
||||
await vi.waitFor(() => expect(modal.classList.contains('show')).toBe(true));
|
||||
modal.querySelector('[data-action="confirm-sidecar-migration"]').click();
|
||||
await changePromise;
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/lm/sidecars/migrate', expect.objectContaining({
|
||||
body: JSON.stringify({ direction: 'relocate_root', force: true, old_root: '/old/root' }),
|
||||
}));
|
||||
expect(manager._loadedSidecarStoragePath).toBe('/new/root');
|
||||
});
|
||||
|
||||
it('does not prompt when the path changes in alongside mode', async () => {
|
||||
const manager = createManager();
|
||||
const { pathInput } = appendSidecarControls();
|
||||
appendMigrationModal();
|
||||
state.global.settings = { sidecar_storage_mode: 'alongside', sidecar_storage_path: '/old/root' };
|
||||
manager._loadedSidecarStoragePath = '/old/root';
|
||||
pathInput.value = '/new/root';
|
||||
mockFetchOk();
|
||||
|
||||
await manager.handleSidecarStoragePathChange();
|
||||
|
||||
const migrateCalls = global.fetch.mock.calls.filter(([url]) => url === '/api/lm/sidecars/migrate');
|
||||
expect(migrateCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('shows a deferred notice when relocation is cancelled', async () => {
|
||||
const manager = createManager();
|
||||
const { pathInput } = appendSidecarControls();
|
||||
const modal = appendMigrationModal();
|
||||
state.global.settings = { sidecar_storage_mode: 'centralized', sidecar_storage_path: '/old/root' };
|
||||
manager._loadedSidecarStoragePath = '/old/root';
|
||||
pathInput.value = '/new/root';
|
||||
mockFetchOk();
|
||||
|
||||
const changePromise = manager.handleSidecarStoragePathChange();
|
||||
await vi.waitFor(() => expect(modal.classList.contains('show')).toBe(true));
|
||||
modal.querySelector('[data-action="cancel-sidecar-migration"]').click();
|
||||
await changePromise;
|
||||
|
||||
const migrateCalls = global.fetch.mock.calls.filter(([url]) => url === '/api/lm/sidecars/migrate');
|
||||
expect(migrateCalls).toHaveLength(0);
|
||||
expect(showToast).toHaveBeenCalledWith('settings.sidecarStorage.migrationDeferred', {}, 'info');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderSidecarStorageInfo', () => {
|
||||
const appendStorageInfoElements = () => {
|
||||
const resolved = document.createElement('code');
|
||||
resolved.id = 'sidecarStorageResolvedPath';
|
||||
const warning = document.createElement('div');
|
||||
warning.id = 'sidecarStorageRepoWarning';
|
||||
warning.style.display = 'none';
|
||||
document.body.append(resolved, warning);
|
||||
return { resolved, warning };
|
||||
};
|
||||
|
||||
it('shows the resolved root and the repo warning when inside the install folder', () => {
|
||||
const manager = createManager();
|
||||
const { resolved, warning } = appendStorageInfoElements();
|
||||
state.global.settings = {
|
||||
sidecar_storage_root: '/repo/ComfyUI-Lora-Manager/sidecars',
|
||||
sidecar_storage_root_in_repo: true,
|
||||
};
|
||||
|
||||
manager.renderSidecarStorageInfo();
|
||||
|
||||
expect(resolved.textContent).toBe('/repo/ComfyUI-Lora-Manager/sidecars');
|
||||
expect(warning.style.display).toBe('block');
|
||||
});
|
||||
|
||||
it('hides the repo warning when the root lives outside the install folder', () => {
|
||||
const manager = createManager();
|
||||
const { resolved, warning } = appendStorageInfoElements();
|
||||
state.global.settings = {
|
||||
sidecar_storage_root: '/data/sidecars',
|
||||
sidecar_storage_root_in_repo: false,
|
||||
};
|
||||
|
||||
manager.renderSidecarStorageInfo();
|
||||
|
||||
expect(resolved.textContent).toBe('/data/sidecars');
|
||||
expect(warning.style.display).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('openSidecarStorageLocation', () => {
|
||||
it('posts to the open-location endpoint', async () => {
|
||||
const manager = createManager();
|
||||
mockFetchOk({ success: true });
|
||||
|
||||
await manager.openSidecarStorageLocation();
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/lm/sidecars/open-location', { method: 'POST' });
|
||||
expect(showToast).toHaveBeenCalledWith('settings.sidecarStorage.openLocationSuccess', {}, 'success');
|
||||
});
|
||||
|
||||
it('copies the path to the clipboard in clipboard mode', async () => {
|
||||
const manager = createManager();
|
||||
mockFetchOk({ success: true, mode: 'clipboard', path: '/data/sidecars' });
|
||||
const writeText = vi.fn().mockResolvedValue();
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText },
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
await manager.openSidecarStorageLocation();
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith('/data/sidecars');
|
||||
expect(showToast).toHaveBeenCalledWith(
|
||||
'settings.sidecarStorage.openLocationCopied',
|
||||
{ path: '/data/sidecars' },
|
||||
'success'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('showSidecarMigrationResult', () => {
|
||||
const appendResultModal = () => {
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'sidecarMigrationResultModal';
|
||||
modal.innerHTML = `
|
||||
<h2 data-role="title"></h2>
|
||||
<p data-role="message"></p>
|
||||
<p data-role="destination" style="display:none"></p>
|
||||
<button data-action="open-sidecar-location" style="display:none"></button>
|
||||
<button data-action="close-sidecar-result"></button>`;
|
||||
document.body.appendChild(modal);
|
||||
return modal;
|
||||
};
|
||||
|
||||
it('renders counters and location, reloads only when closed', async () => {
|
||||
const manager = createManager();
|
||||
const modal = appendResultModal();
|
||||
mockFetchOk({ success: true });
|
||||
|
||||
manager.showSidecarMigrationResult({
|
||||
success: true,
|
||||
direction: 'to_centralized',
|
||||
moved: 12,
|
||||
models_moved: 5,
|
||||
skipped: 1,
|
||||
conflicts: 2,
|
||||
error_count: 0,
|
||||
sidecar_root: '/data/sidecars',
|
||||
});
|
||||
|
||||
expect(modal.classList.contains('show')).toBe(true);
|
||||
expect(modal.querySelector('[data-role="message"]').textContent).toContain('12');
|
||||
expect(modal.querySelector('[data-role="destination"]').textContent).toContain('/data/sidecars');
|
||||
expect(modal.querySelector('[data-action="open-sidecar-location"]').style.display).not.toBe('none');
|
||||
expect(resetAndReload).not.toHaveBeenCalled();
|
||||
|
||||
// "Open Folder" keeps the result modal open.
|
||||
modal.querySelector('[data-action="open-sidecar-location"]').click();
|
||||
await vi.waitFor(() => expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/lm/sidecars/open-location',
|
||||
{ method: 'POST' }
|
||||
));
|
||||
expect(modal.classList.contains('show')).toBe(true);
|
||||
|
||||
modal.querySelector('[data-action="close-sidecar-result"]').click();
|
||||
expect(modal.classList.contains('show')).toBe(false);
|
||||
expect(resetAndReload).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('hides the location row and open button when migrating back alongside', () => {
|
||||
const manager = createManager();
|
||||
const modal = appendResultModal();
|
||||
|
||||
manager.showSidecarMigrationResult({
|
||||
success: true,
|
||||
direction: 'to_alongside',
|
||||
moved: 3,
|
||||
models_moved: 3,
|
||||
skipped: 0,
|
||||
conflicts: 0,
|
||||
error_count: 0,
|
||||
sidecar_root: '/data/sidecars',
|
||||
});
|
||||
|
||||
expect(modal.querySelector('[data-role="destination"]').style.display).toBe('none');
|
||||
expect(modal.querySelector('[data-action="open-sidecar-location"]').style.display).toBe('none');
|
||||
});
|
||||
|
||||
it('falls back to toast plus reload when the modal is absent', () => {
|
||||
const manager = createManager();
|
||||
|
||||
manager.showSidecarMigrationResult({ success: true, direction: 'to_centralized' });
|
||||
|
||||
expect(showToast).toHaveBeenCalledWith('settings.sidecarStorage.migrateSuccess', {}, 'success');
|
||||
expect(resetAndReload).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
|
||||
import { getBasePath, withBasePath } from '../../../static/js/utils/basePath.js';
|
||||
|
||||
describe('static/js/utils/basePath.js', () => {
|
||||
afterEach(() => {
|
||||
delete window.LM_BASE_PATH;
|
||||
});
|
||||
|
||||
it('returns empty base path when bootstrap did not set one', () => {
|
||||
expect(getBasePath()).toBe('');
|
||||
expect(withBasePath('/loras')).toBe('/loras');
|
||||
});
|
||||
|
||||
it('prepends the detected base path', () => {
|
||||
window.LM_BASE_PATH = '/comfyui';
|
||||
expect(getBasePath()).toBe('/comfyui');
|
||||
expect(withBasePath('/loras')).toBe('/comfyui/loras');
|
||||
expect(withBasePath('/loras/recipes')).toBe('/comfyui/loras/recipes');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user