mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-26 13:34:08 -03:00
Compare commits
20
Commits
3b9e8efb3d
..
v1.2.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3555ddb588 | ||
|
|
ede15032ce | ||
|
|
c48feeddb6 | ||
|
|
8a80f82d93 | ||
|
|
c4676183b5 | ||
|
|
8b7ba59263 | ||
|
|
067e605e75 | ||
|
|
dae18b3d1d | ||
|
|
2f9bd3ee7d | ||
|
|
755e1a5bca | ||
|
|
c202654d49 | ||
|
|
74736f7560 | ||
|
|
0ada32d0c7 | ||
|
|
e9aff35957 | ||
|
|
521531111a | ||
|
|
474da1b264 | ||
|
|
78d38b449e | ||
|
|
2bc9860b24 | ||
|
|
327da0465b | ||
|
|
c8c84bfc54 |
@@ -261,6 +261,11 @@ If a cross-layer issue ever needs a live server, the sandboxed helpers live in
|
||||
## Important Notes
|
||||
|
||||
- ALWAYS use English for comments (per copilot-instructions.md)
|
||||
- **`.civitai.info` files are NOT LoRA Manager sidecars.** They are written by
|
||||
third-party apps; LoRA Manager treats them as read-only and only consumes
|
||||
them during migration/import. Never write, modify, or delete them, and never
|
||||
propose doing so as a fix — LoRA Manager's own metadata lives in the
|
||||
`.metadata.json` sidecar it owns.
|
||||
- **`settings.json.example` must stay minimal**: only `use_portable_settings`,
|
||||
`civitai_api_key`, and the four core `folder_paths` keys (`loras`,
|
||||
`checkpoints`, `unet`, `embeddings`). Do NOT add optional/default keys
|
||||
|
||||
@@ -18,6 +18,7 @@ try: # pragma: no cover - import fallback for pytest collection
|
||||
from .py.nodes.lora_info import LoraInfoLM
|
||||
from .py.nodes.lora_syntax_to_path import LoraSyntaxToPath
|
||||
from .py.nodes.create_hook_lora import CreateHookLoraLM
|
||||
from .py.nodes.load_image_metadata import LoadImageMetadataLM
|
||||
from .py.nodes.metadata_overwrite import MetadataOverwriteLM
|
||||
from .py.metadata_collector import init as init_metadata_collector
|
||||
except (
|
||||
@@ -70,6 +71,7 @@ except (
|
||||
MetadataOverwriteLM = importlib.import_module(
|
||||
"py.nodes.metadata_overwrite"
|
||||
).MetadataOverwriteLM
|
||||
LoadImageMetadataLM = importlib.import_module("py.nodes.load_image_metadata").LoadImageMetadataLM
|
||||
init_metadata_collector = importlib.import_module("py.metadata_collector").init
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
@@ -93,6 +95,7 @@ NODE_CLASS_MAPPINGS = {
|
||||
LoraSyntaxToPath.NAME: LoraSyntaxToPath,
|
||||
CreateHookLoraLM.NAME: CreateHookLoraLM,
|
||||
MetadataOverwriteLM.NAME: MetadataOverwriteLM,
|
||||
LoadImageMetadataLM.NAME: LoadImageMetadataLM,
|
||||
}
|
||||
|
||||
WEB_DIRECTORY = "./web/comfyui"
|
||||
|
||||
+214
-203
@@ -6,36 +6,46 @@
|
||||
"Scott R"
|
||||
],
|
||||
"allSupporters": [
|
||||
"2018cfh",
|
||||
"Takkan",
|
||||
"Charles Blakemore",
|
||||
"Rob Williams",
|
||||
"megakirbs",
|
||||
"Brennok",
|
||||
"2018cfh",
|
||||
"Rob Williams",
|
||||
"Charles Blakemore",
|
||||
"Arlecchino Shion",
|
||||
"Insomnia Art Designs",
|
||||
"Skalabananen",
|
||||
"Mozzel",
|
||||
"Gingko Biloba",
|
||||
"stone9k",
|
||||
"Kiba",
|
||||
"onesecondinosaur",
|
||||
"Skalabananen",
|
||||
"Sterilized",
|
||||
"Polymorphic Indeterminate",
|
||||
"Marc Whiffen",
|
||||
"stone9k",
|
||||
"Rosenthal",
|
||||
"Francisco Tatis",
|
||||
"Kiba",
|
||||
"Birdy",
|
||||
"onesecondinosaur",
|
||||
"Reno Lam",
|
||||
"Sterilized",
|
||||
"Liam MacDougal",
|
||||
"sig",
|
||||
"Christian Byrne",
|
||||
"DM",
|
||||
"Sen314",
|
||||
"Estragon",
|
||||
"Rosenthal",
|
||||
"J\\B/ 8r0wns0n",
|
||||
"ClockDaemon",
|
||||
"Francisco Tatis",
|
||||
"KD",
|
||||
"Omnidex",
|
||||
"Tobi_Swagg",
|
||||
"SG",
|
||||
"James Dooley",
|
||||
"zenbound",
|
||||
"jmack",
|
||||
"Andrew Wilson",
|
||||
"Greybush",
|
||||
"Ricky Carter",
|
||||
"James Todd",
|
||||
"JongWon Han",
|
||||
"VantAI",
|
||||
"レプサイ",
|
||||
@@ -45,28 +55,28 @@
|
||||
"JackieWang",
|
||||
"FreelancerZ",
|
||||
"fnkylove",
|
||||
"Vik71it",
|
||||
"Echo",
|
||||
"Lilleman",
|
||||
"Robert Stacey",
|
||||
"PM",
|
||||
"Marc Whiffen",
|
||||
"Dogwalkerbr",
|
||||
"Birdy",
|
||||
"quarz",
|
||||
"$MetaSamsara",
|
||||
"Greg",
|
||||
"jean jahren",
|
||||
"Reno Lam",
|
||||
"Aleksander Wujczyk",
|
||||
"AM Kuro",
|
||||
"JSST",
|
||||
"sig",
|
||||
"J\\B/ 8r0wns0n",
|
||||
"Snaggwort",
|
||||
"lmsupporter",
|
||||
"wfpearl",
|
||||
"jeaness",
|
||||
"Anthony+Rizzo",
|
||||
"W+K+White",
|
||||
"Baekdoosixt",
|
||||
"Jonathan Ross",
|
||||
"KD",
|
||||
"Omnidex",
|
||||
"Jack B Nimble",
|
||||
"Nolife_M",
|
||||
"Melville Parrish",
|
||||
"daniel dove",
|
||||
@@ -75,30 +85,32 @@
|
||||
"Release Cabrakan",
|
||||
"JW Sin",
|
||||
"Alex",
|
||||
"bh",
|
||||
"carozzz",
|
||||
"Marlon Daniels",
|
||||
"James Dooley",
|
||||
"zenbound",
|
||||
"Buzzard",
|
||||
"Aaron Bleuer",
|
||||
"LacesOut!",
|
||||
"Adam Shaw",
|
||||
"Mark Corneglio",
|
||||
"RedrockVP",
|
||||
"James Todd",
|
||||
"Wicked Choices by ASLPro3D",
|
||||
"Jacob Hoehler",
|
||||
"FinalyFree",
|
||||
"Weasyl",
|
||||
"Fyf",
|
||||
"Timmy",
|
||||
"Johnny",
|
||||
"Cory Paza",
|
||||
"Tak",
|
||||
"Lisster",
|
||||
"Big Red",
|
||||
"whudunit",
|
||||
"Luc Job",
|
||||
"Philip Hempel",
|
||||
"corde",
|
||||
"Yushio",
|
||||
"Vik71it",
|
||||
"Bishoujoker",
|
||||
"Echo",
|
||||
"Todd Keck",
|
||||
"Briton Heilbrun",
|
||||
"wildnut",
|
||||
@@ -106,104 +118,99 @@
|
||||
"BadassArabianMofo",
|
||||
"MiraiKuriyamaSy",
|
||||
"Pascal Dahle",
|
||||
"Greg",
|
||||
"Sangheili460",
|
||||
"MagnaInsomnia",
|
||||
"Akira HentAI",
|
||||
"Karl P.",
|
||||
"otaku fra",
|
||||
"lmsupporter",
|
||||
"andrew.tappan",
|
||||
"N/A",
|
||||
"The Spawn",
|
||||
"wackop",
|
||||
"Phil",
|
||||
"graysock",
|
||||
"Greenmoustache",
|
||||
"Carl G.",
|
||||
"wfpearl",
|
||||
"jeaness",
|
||||
"fancypants",
|
||||
"Dsperado",
|
||||
"Jack B Nimble",
|
||||
"bh",
|
||||
"JaxMax",
|
||||
"Jwk0205",
|
||||
"Starkselle",
|
||||
"carey6409",
|
||||
"Olive",
|
||||
"Aaron Bleuer",
|
||||
"LacesOut!",
|
||||
"greebles",
|
||||
"SarcasticHashtag",
|
||||
"Some Guy Named Barry",
|
||||
"M Postkasse",
|
||||
"Jacob Hoehler",
|
||||
"AELOX",
|
||||
"Nicfit23",
|
||||
"wamekukyouzin",
|
||||
"drum matthieu",
|
||||
"DogmaR34",
|
||||
"Matt Wenzel",
|
||||
"Weasyl",
|
||||
"Lex Song",
|
||||
"Cory Paza",
|
||||
"Christopher Michel",
|
||||
"Gonzalo Andre Allendes Lopez",
|
||||
"Serge Bekenkamp",
|
||||
"AIJimmy",
|
||||
"Philip Hempel",
|
||||
"LeoZero",
|
||||
"Dustin Chen",
|
||||
"dan",
|
||||
"aai",
|
||||
"Mouthlessman",
|
||||
"Ran C",
|
||||
"ViperC",
|
||||
"itismyelement",
|
||||
"Sangheili460",
|
||||
"MagnaInsomnia",
|
||||
"Karl P.",
|
||||
"LarsesFPC",
|
||||
"Weird_With_A_Beard",
|
||||
"N/A",
|
||||
"The Spawn",
|
||||
"graysock",
|
||||
"Pozadine1",
|
||||
"Qarob",
|
||||
"AIGooner",
|
||||
"Luc",
|
||||
"ProtonPrince",
|
||||
"DiffDuck",
|
||||
"fancypants",
|
||||
"elu3199",
|
||||
"Hasturkun",
|
||||
"Ubivis",
|
||||
"griffin+dahlberg",
|
||||
"John+Edwards",
|
||||
"Joboshy",
|
||||
"Digital",
|
||||
"JaxMax",
|
||||
"Bohemian Corporal",
|
||||
"Dan",
|
||||
"Bro Xie",
|
||||
"seed123_AIart",
|
||||
"batblue",
|
||||
"carey6409",
|
||||
"Error_Rule34_Not_found",
|
||||
"太郎 ゲーム",
|
||||
"Roslynd",
|
||||
"jinxedx",
|
||||
"AELOX",
|
||||
"Neco28",
|
||||
"David Ortega",
|
||||
"Dankin-Pics",
|
||||
"Nicfit23",
|
||||
"Cristian Vazquez",
|
||||
"wamekukyouzin",
|
||||
"drum matthieu",
|
||||
"DogmaR34",
|
||||
"Frank Nitty",
|
||||
"The Magic Noob",
|
||||
"Christopher Michel",
|
||||
"runte3221",
|
||||
"DougPeterson",
|
||||
"LeoZero",
|
||||
"dl0901dm",
|
||||
"Antonio Pontes",
|
||||
"Bruce",
|
||||
"kushiroK9",
|
||||
"Kevin John Duck",
|
||||
"Dustin Chen",
|
||||
"Kevin Christopher",
|
||||
"Blackfish95",
|
||||
"Tori",
|
||||
"Mouthlessman",
|
||||
"dd",
|
||||
"Paul Kroll",
|
||||
"Fraser Cross",
|
||||
"Bas Imagineer",
|
||||
"John Statham",
|
||||
"Dušan Ryban",
|
||||
"Adam Taylor",
|
||||
"AlexDuKaNa",
|
||||
"decoy",
|
||||
"elu3199",
|
||||
"Hasturkun",
|
||||
"Jon Sandman",
|
||||
"Ubivis",
|
||||
"zounic",
|
||||
"CloudValley",
|
||||
"thesoftwaredruid",
|
||||
@@ -215,37 +222,39 @@
|
||||
"Gus",
|
||||
"MJG",
|
||||
"linnfrey",
|
||||
"griffin+dahlberg",
|
||||
"ae",
|
||||
"Tr4shP4nda",
|
||||
"capn",
|
||||
"truethug",
|
||||
"yukina",
|
||||
"ElitaSSJ4",
|
||||
"Matt+J",
|
||||
"Brian M",
|
||||
"Josef Lanzl",
|
||||
"New folder (1)",
|
||||
"sanborondon",
|
||||
"Error_Rule34_Not_found",
|
||||
"Thought2Form",
|
||||
"jcay015",
|
||||
"Erik Lopez",
|
||||
"Mateo Curić",
|
||||
"Geolog",
|
||||
"Neco28",
|
||||
"Eris3D",
|
||||
"Resist's Creations - Spicy Edition 🔥",
|
||||
"David Ortega",
|
||||
"Wolffen",
|
||||
"m",
|
||||
"Pierce McBride",
|
||||
"Jamie Ogletree",
|
||||
"a _",
|
||||
"Jeff",
|
||||
"nwalker94",
|
||||
"James Coleman",
|
||||
"Kevin Christopher",
|
||||
"Ouro Boros",
|
||||
"Chad Idk",
|
||||
"dd",
|
||||
"Sam",
|
||||
"sjon kreutz",
|
||||
"Ace Ventura",
|
||||
"Metryman55",
|
||||
"AlexDuKaNa",
|
||||
"ae",
|
||||
"Tr4shP4nda",
|
||||
"Gamalonia",
|
||||
"capn",
|
||||
"Joseph",
|
||||
"Mirko Katzula",
|
||||
"dan",
|
||||
@@ -256,56 +265,57 @@
|
||||
"Kland",
|
||||
"Hailshem",
|
||||
"Naomi Hale Danchi",
|
||||
"ken",
|
||||
"epicgamer0020690",
|
||||
"Joshua Porrata",
|
||||
"Andrew",
|
||||
"Brian M",
|
||||
"Robert Wegemund",
|
||||
"Littlehuggy",
|
||||
"Brian Buie",
|
||||
"Thought2Form",
|
||||
"RAIDiation",
|
||||
"Sadlip",
|
||||
"Gooohokrbe",
|
||||
"m",
|
||||
"OldBones",
|
||||
"Pierce McBride",
|
||||
"Zach Gonser",
|
||||
"Mikko Hemilä",
|
||||
"Jacob McDaniel",
|
||||
"Jamie Ogletree",
|
||||
"Temikus",
|
||||
"Artokun",
|
||||
"Michael Taylor",
|
||||
"Martial",
|
||||
"Emil Andersson",
|
||||
"Ouro Boros",
|
||||
"Atilla Berke Pekduyar",
|
||||
"Decx _",
|
||||
"Yuji Kaneko",
|
||||
"Rops Alot",
|
||||
"Penfore",
|
||||
"Gordon Cole",
|
||||
"Ace Ventura",
|
||||
"AbstractAss",
|
||||
"David LaVallee",
|
||||
"ken",
|
||||
"Crocket",
|
||||
"keemun",
|
||||
"SuBu",
|
||||
"RedPIXel",
|
||||
"Wind",
|
||||
"Jackthemind",
|
||||
"Nexus",
|
||||
"Ramneek“Guy”Ashok",
|
||||
"squid_actually",
|
||||
"Nat_20",
|
||||
"Edward Weeks",
|
||||
"kyoumei",
|
||||
"RadStorm04",
|
||||
"JohnDoe42054",
|
||||
"BillyHill",
|
||||
"emyth",
|
||||
"gzmzmvp",
|
||||
"Andrew",
|
||||
"Robert Wegemund",
|
||||
"Littlehuggy",
|
||||
"Brian Buie",
|
||||
"RAIDiation",
|
||||
"Sadlip",
|
||||
"Eric Whitney",
|
||||
"Joey Callahan",
|
||||
"Ivan Tadic",
|
||||
"Mike Simone",
|
||||
"Gooohokrbe",
|
||||
"OldBones",
|
||||
"Morgandel",
|
||||
"Zach Gonser",
|
||||
"Mikko Hemilä",
|
||||
"Jacob McDaniel",
|
||||
"X",
|
||||
"SloanSteddyAI",
|
||||
"Temikus",
|
||||
"Artokun",
|
||||
"Michael Taylor",
|
||||
"Derek Baker",
|
||||
"Martial",
|
||||
"Emil Andersson",
|
||||
"Atilla Berke Pekduyar",
|
||||
"Decx _",
|
||||
"Rops Alot",
|
||||
"Penfore",
|
||||
"Gordon Cole",
|
||||
"AbstractAss",
|
||||
"David LaVallee",
|
||||
"Crocket",
|
||||
"Jackthemind",
|
||||
"Edward Weeks",
|
||||
"KitKatM",
|
||||
"socrasteeze",
|
||||
"MudkipMedkitz",
|
||||
@@ -316,26 +326,32 @@
|
||||
"InformedViewz",
|
||||
"Bubbafett",
|
||||
"leaf",
|
||||
"Skyfire83",
|
||||
"Adam Rinehart",
|
||||
"gzmzmvp",
|
||||
"Pitpe11",
|
||||
"TheD1rtyD03",
|
||||
"moonpetal",
|
||||
"g9p0o",
|
||||
"TheHolySheep",
|
||||
"Monte Won",
|
||||
"SpringBootisTrash",
|
||||
"carsten",
|
||||
"D",
|
||||
"takyamtom",
|
||||
"Aberr",
|
||||
"Gregory Kozhemiak",
|
||||
"elleshar666",
|
||||
"aezin",
|
||||
"Eric Whitney",
|
||||
"Joey Callahan",
|
||||
"Ivan Tadic",
|
||||
"Mike Simone",
|
||||
"ACTUALLY_the_Real_Willem_Dafoe",
|
||||
"FloPro4Sho",
|
||||
"John J Linehan",
|
||||
"Elliot E",
|
||||
"Morgandel",
|
||||
"Theerat Jiramate",
|
||||
"X",
|
||||
"SloanSteddyAI",
|
||||
"Vane Holzer",
|
||||
"Steven Owens",
|
||||
"hexxish",
|
||||
"Derek Baker",
|
||||
"Michael Anthony Scott",
|
||||
"notedfakes",
|
||||
"NICHOLAS BAXLEY",
|
||||
"Ed Wang",
|
||||
"Saya",
|
||||
@@ -347,18 +363,13 @@
|
||||
"chriphost",
|
||||
"ResidentDeviant",
|
||||
"Ginnie",
|
||||
"Skyfire83",
|
||||
"Pitpe11",
|
||||
"IamAyam",
|
||||
"TheD1rtyD03",
|
||||
"moonpetal",
|
||||
"g9p0o",
|
||||
"Pkrsky",
|
||||
"TheHolySheep",
|
||||
"Monte Won",
|
||||
"SpringBootisTrash",
|
||||
"carsten",
|
||||
"nanana",
|
||||
"ikok",
|
||||
"Doug+Rintoul",
|
||||
"Noor",
|
||||
"Yorunai",
|
||||
"quantenmecha",
|
||||
"Jason+Nash",
|
||||
"DarkRoast",
|
||||
@@ -368,36 +379,34 @@
|
||||
"Duk3+Rand0m",
|
||||
"Nathen+Choi",
|
||||
"T",
|
||||
"D",
|
||||
"David Schenck",
|
||||
"Wolfe7D1",
|
||||
"Andrew Marshall",
|
||||
"Taylor Funk",
|
||||
"elleshar666",
|
||||
"Gerald Welly",
|
||||
"Tee Gee",
|
||||
"ACTUALLY_the_Real_Willem_Dafoe",
|
||||
"Михал Михалыч",
|
||||
"Matt",
|
||||
"tarek helmi",
|
||||
"Kauffy",
|
||||
"Max Marklund",
|
||||
"SPJ",
|
||||
"Joshua Gray",
|
||||
"Edward Kennedy",
|
||||
"Nick Kage",
|
||||
"Vane Holzer",
|
||||
"psytrax",
|
||||
"Cyrus Fett",
|
||||
"lh qwe",
|
||||
"conner",
|
||||
"Xenon Xue",
|
||||
"Michael Anthony Scott",
|
||||
"notedfakes",
|
||||
"Edward Ten Eyck",
|
||||
"Princess Bright Eyes",
|
||||
"Michael Scott",
|
||||
"Solixer",
|
||||
"Jimmy Borup",
|
||||
"Wes Sims",
|
||||
"Donor4115",
|
||||
"Manu Thetug",
|
||||
"Filippo Ferrari",
|
||||
"Douglas Gaspar",
|
||||
"George",
|
||||
@@ -406,11 +415,19 @@
|
||||
"momokai",
|
||||
"몽타주",
|
||||
"kudari",
|
||||
"dc7431",
|
||||
"Inversity",
|
||||
"Whitepinetrader",
|
||||
"OrganicArtifact",
|
||||
"Raku",
|
||||
"CHKeeho80",
|
||||
"nanana",
|
||||
"Flob",
|
||||
"ShiroSenpai",
|
||||
"Gumbyte",
|
||||
"Tan+Huynh",
|
||||
"Bob+Barker",
|
||||
"Dark_Pest",
|
||||
"Eldithor",
|
||||
"Alex",
|
||||
"Karru",
|
||||
"ChaChanoKo",
|
||||
@@ -425,25 +442,28 @@
|
||||
"Alan+Cano",
|
||||
"FeralOpticsAI",
|
||||
"Pavlaki",
|
||||
"Doug+Rintoul",
|
||||
"Noor",
|
||||
"Yorunai",
|
||||
"Richard",
|
||||
"奚明 刘",
|
||||
"Kalli Core",
|
||||
"준희 김",
|
||||
"Ronan Delevacq",
|
||||
"りん あめ",
|
||||
"Matt",
|
||||
"Tomohiro Baba",
|
||||
"Dave Abraham",
|
||||
"Joaquin Hierrezuelo",
|
||||
"Noora",
|
||||
"Frogmilk",
|
||||
"SPJ",
|
||||
"StudOx Tech",
|
||||
"Jarrid Lee",
|
||||
"Kor",
|
||||
"John Rednoulf",
|
||||
"Bryan Rutkowski",
|
||||
"Boba Smith",
|
||||
"Noah",
|
||||
"Sauv",
|
||||
"TenaciousD",
|
||||
"Dmitry Ryzhov",
|
||||
"DarkSunset",
|
||||
"Edward Ten Eyck",
|
||||
"Steam Steam",
|
||||
"CryptoTraderJK",
|
||||
"Davaitamin",
|
||||
@@ -454,17 +474,23 @@
|
||||
"jinksta187",
|
||||
"Fotek Design",
|
||||
"Maxim",
|
||||
"Manu Thetug",
|
||||
"Lyavph",
|
||||
"Nihongasuki",
|
||||
"MadSpin",
|
||||
"inbijiburu",
|
||||
"Nick “Loadstone” D",
|
||||
"Marcus thronico",
|
||||
"地獄の禄",
|
||||
"starbugx",
|
||||
"dc7431",
|
||||
"Inversity",
|
||||
"Vir",
|
||||
"Kachac",
|
||||
"Alex+Zaw",
|
||||
"Rune+Osnes",
|
||||
"PoorStudent",
|
||||
"Supporter",
|
||||
"ExLightSaber",
|
||||
"vinter",
|
||||
"YaboiRay",
|
||||
"Sildoren",
|
||||
"Darv",
|
||||
"Seon+Song",
|
||||
@@ -480,58 +506,50 @@
|
||||
"YassineKhaled",
|
||||
"Y",
|
||||
"MatteKey",
|
||||
"Flob",
|
||||
"ShiroSenpai",
|
||||
"Inkognito",
|
||||
"Gumbyte",
|
||||
"Tan+Huynh",
|
||||
"Bob+Barker",
|
||||
"Dark_Pest",
|
||||
"Eldithor",
|
||||
"Ko-fi+Supporter",
|
||||
"lrdchs2",
|
||||
"Obsidian.Studios",
|
||||
"Tú Nguyễn Lý Hoàng",
|
||||
"shira1011",
|
||||
"Kalli Core",
|
||||
"Neko Desco",
|
||||
"Ben D",
|
||||
"Draven T",
|
||||
"marioandluigi",
|
||||
"G",
|
||||
"Ronan Delevacq",
|
||||
"Vinarus",
|
||||
"Leslie Andrew Ridings",
|
||||
"Aquatic Coffee",
|
||||
"Dave Abraham",
|
||||
"Joaquin Hierrezuelo",
|
||||
"Locrospiel",
|
||||
"StudOx Tech",
|
||||
"yves.poezevara",
|
||||
"Jarrid Lee",
|
||||
"Poophead27 Blyat",
|
||||
"Joseph Hanson",
|
||||
"John Rednoulf",
|
||||
"Focuschannel",
|
||||
"Boba Smith",
|
||||
"matt",
|
||||
"somethingtosay8",
|
||||
"Terminuz",
|
||||
"ivistorm",
|
||||
"Anthony Faxlandez",
|
||||
"Sauv",
|
||||
"Borte",
|
||||
"Ted Cart",
|
||||
"Sage Himeros",
|
||||
"Zeeble",
|
||||
"Pat Hen",
|
||||
"SkibidiRizzler",
|
||||
"Jack Lawfield",
|
||||
"Draconach",
|
||||
"Kalle Björk",
|
||||
"Tigon",
|
||||
"ItsGeneralButtNaked",
|
||||
"Jordan Shaw",
|
||||
"g unit",
|
||||
"Nacho Ferrando",
|
||||
"Dkom22",
|
||||
"Marcos Tortosa Carmona",
|
||||
"Distortik",
|
||||
"JC",
|
||||
"Prompt Pirate",
|
||||
"uwutismxd",
|
||||
"Marcus thronico",
|
||||
"zenobeus",
|
||||
"ryoma",
|
||||
"dg",
|
||||
@@ -540,6 +558,11 @@
|
||||
"Menard",
|
||||
"SomeDude",
|
||||
"raf8osz",
|
||||
"Jasper",
|
||||
"megameganck",
|
||||
"thomasand01",
|
||||
"Shiba+Sama",
|
||||
"Celestial+Kitten",
|
||||
"Gold_miner_ego",
|
||||
"bakeliteboy",
|
||||
"TequiTequi",
|
||||
@@ -556,32 +579,26 @@
|
||||
"imer",
|
||||
"Akkas+Haque",
|
||||
"AZ+Party+Oasis",
|
||||
"Alex+Zaw",
|
||||
"Kachac",
|
||||
"Kevin+Isom",
|
||||
"Rune+Osnes",
|
||||
"PoorStudent",
|
||||
"vinter",
|
||||
"Supporter",
|
||||
"Mobius2020",
|
||||
"ExLightSaber",
|
||||
"YaboiRay",
|
||||
"boston666",
|
||||
"Adam+Spreer",
|
||||
"cocona",
|
||||
"Obsidian.Studios",
|
||||
"Welkor",
|
||||
"Zomba Mann",
|
||||
"Aquaneo",
|
||||
"blikkies",
|
||||
"JBsuede",
|
||||
"Wolf and Fox Legends",
|
||||
"ゼクス、六",
|
||||
"Neko Desco",
|
||||
"Vinarus",
|
||||
"Josh Snyder",
|
||||
"Shock Shockor",
|
||||
"Goldwaters",
|
||||
"swra",
|
||||
"JollRodrigo",
|
||||
"Zude",
|
||||
"Room Light",
|
||||
"Patryk Serious",
|
||||
"Kyler",
|
||||
"Justin Blaylock",
|
||||
"aRtFuL_DodGeR",
|
||||
@@ -589,23 +606,23 @@
|
||||
"TheFusion",
|
||||
"MR.Bear",
|
||||
"3zS4QNQ4",
|
||||
"Terminuz",
|
||||
"Matt M.",
|
||||
"Ivan Imes",
|
||||
"J M",
|
||||
"Slacks",
|
||||
"Steven",
|
||||
"Borte",
|
||||
"Khánh Đặng",
|
||||
"Homero Banda",
|
||||
"yyuvuvu",
|
||||
"Billy Gladky",
|
||||
"Nomki",
|
||||
"Probis",
|
||||
"Jack Lawfield",
|
||||
"SkibidiRizzler",
|
||||
"Never_M",
|
||||
"Maxon - Plans",
|
||||
"Kalle Björk",
|
||||
"Rudeff VonRod",
|
||||
"Karlanx",
|
||||
"operationancut",
|
||||
"Nacho Ferrando",
|
||||
"deadwishd",
|
||||
"Youguang",
|
||||
"andrewzpong",
|
||||
"BossGame",
|
||||
@@ -616,6 +633,12 @@
|
||||
"Kevinj",
|
||||
"Mitchell Robson",
|
||||
"POPPIN",
|
||||
"Lorabitch",
|
||||
"21omen",
|
||||
"NopeNahGoodTy",
|
||||
"BG",
|
||||
"plonk",
|
||||
"Kotetsu",
|
||||
"meatyalien",
|
||||
"Tony+V",
|
||||
"draganjankovic1975dj528",
|
||||
@@ -627,59 +650,50 @@
|
||||
"JACKY",
|
||||
"Otokomyouri+",
|
||||
"d",
|
||||
"Jasper",
|
||||
"megameganck",
|
||||
"thomasand01",
|
||||
"Shiba+Sama",
|
||||
"Celestial+Kitten",
|
||||
"IshouI;_;",
|
||||
"SAVEagleBasement",
|
||||
"Adam+Spreer",
|
||||
"BillyBoy84",
|
||||
"Buecyb99",
|
||||
"Welkor",
|
||||
"dubious1one",
|
||||
"Brandon Thomas",
|
||||
"BakunyuuWaifu",
|
||||
"Dustin Hendel",
|
||||
"moranqianlong",
|
||||
"Liberation",
|
||||
"Ninja Tom",
|
||||
"75marc",
|
||||
"Elemnt",
|
||||
"tafapayo",
|
||||
"Bradley Turner",
|
||||
"swra",
|
||||
"JollRodrigo",
|
||||
"Oliverfish",
|
||||
"uruksayshi",
|
||||
"Patryk Serious",
|
||||
"nk8",
|
||||
"Kyron Mahan",
|
||||
"Mythspire",
|
||||
"Nimhloth",
|
||||
"Justin Defer",
|
||||
"TBitz33",
|
||||
"Anonym dkjglfleeoeldldldlkf",
|
||||
"Tsani Prodanov",
|
||||
"V Bj",
|
||||
"Ezokewn",
|
||||
"Rj Joplin",
|
||||
"SendingRavens",
|
||||
"Slacks",
|
||||
"Myrthrac",
|
||||
"Taylor Dominy",
|
||||
"Glenn Hoetker",
|
||||
"JackJohnnyJim",
|
||||
"Khánh Đặng",
|
||||
"Michael Hicks",
|
||||
"Homero Banda",
|
||||
"Michael Docherty",
|
||||
"MadGod",
|
||||
"GhostyGhost",
|
||||
"Paul Hartsuyker",
|
||||
"elitassj",
|
||||
"Never_M",
|
||||
"Jacob Winter",
|
||||
"Rudeff VonRod",
|
||||
"Andrew Wilkinson",
|
||||
"David",
|
||||
"floeki75pad",
|
||||
"TheJohnes",
|
||||
"deadwishd",
|
||||
"shinonomeiro",
|
||||
"Snille",
|
||||
"MaartenAlbers",
|
||||
@@ -696,6 +710,7 @@
|
||||
"Scott",
|
||||
"Muratoraccio",
|
||||
"D",
|
||||
"yukina",
|
||||
"Daevalus",
|
||||
"Milky+Mai",
|
||||
"Krash",
|
||||
@@ -719,13 +734,7 @@
|
||||
"MackeMan",
|
||||
"conkisdonkis",
|
||||
"badnews",
|
||||
"Lorabitch",
|
||||
"21omen",
|
||||
"NopeNahGoodTy",
|
||||
"Brandon+G",
|
||||
"plonk",
|
||||
"Anvil+Girl",
|
||||
"Kotetsu",
|
||||
"miduzza",
|
||||
"Somebody",
|
||||
"てぃんてぃんひーろー",
|
||||
@@ -744,11 +753,10 @@
|
||||
"hayden",
|
||||
"ahoystan",
|
||||
"Civitaier",
|
||||
"BakunyuuWaifu",
|
||||
"edk",
|
||||
"Super Sigma Reborne",
|
||||
"Joey Leto",
|
||||
"Anagra Nouma",
|
||||
"tafapayo",
|
||||
"ja s",
|
||||
"Doug Mason",
|
||||
"scoreswazey",
|
||||
@@ -756,24 +764,21 @@
|
||||
"Owen Gwosdz",
|
||||
"GJT",
|
||||
"Manuel Reyes",
|
||||
"Xae Phiel",
|
||||
"FinoRulez",
|
||||
"CHEL_C",
|
||||
"Gentle Sartori",
|
||||
"Caleb Larson",
|
||||
"David Murcko",
|
||||
"Justin Defer",
|
||||
"Ben Brogger",
|
||||
"Jack Dole",
|
||||
"dsffsdfsdfsdfsdfsdf",
|
||||
"V Bj",
|
||||
"Rj Joplin",
|
||||
"Kurt",
|
||||
"max blo",
|
||||
"Myrthrac",
|
||||
"Taylor Dominy",
|
||||
"Faith",
|
||||
"Bouya shaka",
|
||||
"Maso",
|
||||
"BigBoss",
|
||||
"Kevin Wallace",
|
||||
"ChicRic",
|
||||
"Bastard-Sama",
|
||||
@@ -832,6 +837,14 @@
|
||||
"SelfishMedic",
|
||||
"adderleighn",
|
||||
"EnragedAntelope",
|
||||
"D3aty",
|
||||
"Somebody",
|
||||
"(ᵕ+˶•́﹏•̀˶+)",
|
||||
"Brian+Harvey",
|
||||
"JustDrewIt",
|
||||
"Sumoninja",
|
||||
"FrostByte404",
|
||||
"Eita",
|
||||
"mcmalt",
|
||||
"cesasol",
|
||||
"Null",
|
||||
@@ -892,9 +905,9 @@
|
||||
"Hans Meier",
|
||||
"jboul",
|
||||
"Michael Eid",
|
||||
"Super Sigma Reborne",
|
||||
"Veloce",
|
||||
"Bob barker",
|
||||
"Even",
|
||||
"Michael Rivera",
|
||||
"karim ben brik",
|
||||
"Vincent",
|
||||
@@ -907,13 +920,12 @@
|
||||
"John C",
|
||||
"beltaloth",
|
||||
"Rim",
|
||||
"Daniel Bennett",
|
||||
"yfx507",
|
||||
"Jairus Knudsen",
|
||||
"Xan Dionysus",
|
||||
"Mario Cano",
|
||||
"Nathan lee",
|
||||
"lylepaul",
|
||||
"Xae Phiel",
|
||||
"DafmanD2",
|
||||
"Middo",
|
||||
"Smokey Jesus",
|
||||
@@ -921,7 +933,6 @@
|
||||
"forbiddenatelierofficial",
|
||||
"Thomas Sankowski",
|
||||
"ThreadingReality",
|
||||
"DrB",
|
||||
"wknight",
|
||||
"Moneymaker412K",
|
||||
"Jacid",
|
||||
@@ -933,10 +944,10 @@
|
||||
"fal",
|
||||
"Andrew Ly",
|
||||
"john Greene",
|
||||
"Knives909",
|
||||
"jimyjomson",
|
||||
"JaeHyun Jang",
|
||||
"sbone",
|
||||
"BigBoss",
|
||||
"Chase Kwon",
|
||||
"Bob Ling",
|
||||
"Inyoshu",
|
||||
@@ -968,5 +979,5 @@
|
||||
"Somebody",
|
||||
"CK"
|
||||
],
|
||||
"totalCount": 965
|
||||
"totalCount": 976
|
||||
}
|
||||
@@ -66,6 +66,18 @@ Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
|
||||
> section" — and re-translated in all 9 locales. The `on disk` phrase now survives only in
|
||||
> the ComfyUI variant (`descriptionComfyUI`).
|
||||
|
||||
> **Status (2026-09, settings Organization tab):** the settings modal split its overloaded
|
||||
> Library tab, adding the single `settings.nav.organization` key (renderings in §2,
|
||||
> "Settings Organization tab"). All 9 locales are translated, so the "no remaining
|
||||
> placeholders" claim holds again.
|
||||
|
||||
> **Status (2026-09, filename templates):** the Filename Templates feature (per-model-type
|
||||
> download filename templates + bulk "Apply to Library Now" rename, with an empty template
|
||||
> restoring recorded original filenames) added 26 keys across `settings.filenameTemplates.*`,
|
||||
> `loras.bulkOperations.filenameTemplateProgress.*`, `modals.filenameTemplateConfirm.*` and
|
||||
> the `toast.loras.filenameTemplate*` / `toast.settings.filenameTemplates*` toasts. All 9
|
||||
> locales are translated (terminology in §2, "Filename Templates feature").
|
||||
|
||||
---
|
||||
|
||||
## 1. Hard rules (do not violate)
|
||||
@@ -353,6 +365,20 @@ in `en`, not "Enrich HF Metadata": they cover ModelScope as well, so no locale m
|
||||
an `HF` qualifier in `loras.contextMenu.enrichHfAgent` / `loras.bulkOperations.enrichHfAgent`
|
||||
(the key names keep the historical `Hf`; only the values changed).
|
||||
|
||||
The gated/private-repository download support added `settings.huggingfaceApiKey*` (label,
|
||||
placeholder, help, and the three status strings). "Access token" renderings, and the status
|
||||
strings reuse each locale's existing `civitaiApiKey*` forms ("Configured" / "Not configured" /
|
||||
"Set up") verbatim:
|
||||
|
||||
| Term | Rendering |
|
||||
|---|---|
|
||||
| access token | zh-CN 访问令牌 · zh-TW 存取權杖 · ja アクセストークン · ko 액세스 토큰 · fr jeton d'accès · de Access Token (Latin, like `CivitAI API Key`) · es token de acceso · ru токен доступа · he אסימון גישה |
|
||||
| gated repository | zh-CN 受限(gated)仓库 · zh-TW 受限(gated)倉庫 · ja ゲート付きリポジトリ · ko 게이트가 설정된 저장소 · fr dépôt restreint (gated) · de gated Repository (loanword) · es repositorio restringido (gated) · ru закрытый (gated) репозиторий · he מאגר מוגבל (gated) |
|
||||
|
||||
The help text tells the user to create a **read-only** token at
|
||||
`huggingface.co/settings/tokens` and to accept the repository's terms on its page first —
|
||||
keep both clauses: a token alone does not unlock a gated repository.
|
||||
|
||||
### Folder sidebar feature (create / rename / delete folders, empty folders, view options)
|
||||
|
||||
The model-root sidebar manages on-disk folders. "Folder" reuses the noun already fixed in §2
|
||||
@@ -371,6 +397,44 @@ clause (and its `—`) when the copy is edited. The `{name}` / `{count}` / `{mes
|
||||
`sidebar.createFolderResult.*`, `sidebar.deleteFolderResult.*` and `sidebar.renameFolderResult.*`
|
||||
are verbatim §1-R2 placeholders; `successWithFiles` is the only key carrying `{count}`.
|
||||
|
||||
### Settings Organization tab
|
||||
|
||||
The settings modal's fourth nav tab groups everything about how files are arranged on
|
||||
disk: download path templates, priority tags, and auto-organize exclusions. The label is
|
||||
the **noun for arranging files**, matching each locale's existing
|
||||
`settings.sections.autoOrganize` rendering minus the "auto":
|
||||
|
||||
| Locale | `settings.nav.organization` |
|
||||
|---|---|
|
||||
| fr | Organisation |
|
||||
| zh-CN | 整理 |
|
||||
| zh-TW | 整理 |
|
||||
| ja | 整理 |
|
||||
| ko | 정리 |
|
||||
| de | Organisation |
|
||||
| es | Organización |
|
||||
| ru | Организация |
|
||||
| he | ארגון |
|
||||
|
||||
zh-CN/zh-TW use 整理 ("tidying/arranging"), not 组织/組織 (an organization as a group).
|
||||
|
||||
### Filename Templates feature
|
||||
|
||||
Per-model-type templates that name downloaded model files; "Apply to Library Now"
|
||||
bulk-renames existing files, and an **empty template restores the recorded original
|
||||
filenames** (recorded in each model's metadata at its first rename). "Template" follows
|
||||
each locale's existing download-path-template noun (zh-CN 模板 vs zh-TW 範本 — note the
|
||||
split); progress strings mirror `loras.bulkOperations.autoOrganizeProgress` verbatim with
|
||||
the locale's "moved" verb swapped for its "renamed" verb, and the toasts mirror the
|
||||
`autoOrganize*` / `downloadTemplates*` toast shapes.
|
||||
|
||||
| Term | Rendering |
|
||||
|---|---|
|
||||
| filename template(s) | zh-CN 文件名模板 · zh-TW 檔案名稱範本 · ja ファイル名テンプレート · ko 파일명 템플릿 · fr modèle(s) de nom de fichier · de Dateinamen-Vorlage(n) · es plantilla(s) de nombres de archivo · ru шаблон(ы) имён файлов · he תבנית שם קובץ / תבניות שמות קבצים |
|
||||
| Apply to Library Now (button) | zh-CN 立即应用到库 · zh-TW 立即套用至模型庫 · ja ライブラリに今すぐ適用 · ko 지금 라이브러리에 적용 · fr Appliquer à la bibliothèque maintenant · de Jetzt auf Bibliothek anwenden · es Aplicar a la biblioteca ahora · ru Применить к библиотеке сейчас · he החל על הספרייה כעת |
|
||||
| Restore original filenames (modal title / button) | zh-CN 恢复原始文件名?/ 恢复原始文件名 · zh-TW 要還原原始檔案名稱嗎?/ 還原原始檔案名稱 · ja 元のファイル名を復元しますか?/ 元のファイル名を復元 · ko 원본 파일명을 복원하시겠습니까? / 원본 파일명 복원 · fr Restaurer les noms de fichier d'origine ? / Restaurer les noms de fichier d'origine · de Ursprüngliche Dateinamen wiederherstellen? / Ursprüngliche Dateinamen wiederherstellen · es ¿Restaurar los nombres de archivo originales? / Restaurar nombres de archivo originales · ru Восстановить исходные имена файлов? / Восстановить исходные имена файлов · he לשחזר שמות קבצים מקוריים? / שחזר שמות קבצים מקוריים |
|
||||
| "renamed" (progress/toast counter) | zh-CN 已重命名 · zh-TW 已重新命名 · ja リネーム · ko 이름 변경 · fr renommés · de umbenannt · es renombrados · ru переименовано · he שונו שמותם |
|
||||
|
||||
### Chip reordering (model tags / trigger words)
|
||||
|
||||
Model tags and trigger-word chips share a single reorder affordance (drag the chip, or its
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
# Load Image Metadata (LoraManager)
|
||||
|
||||
Load a source image and reuse its prompts, local models, LoRAs, and sampling settings.
|
||||
The node lives under **Lora Manager → loaders**. Restart ComfyUI after installing
|
||||
this change and refresh the page. This Python node needs no Vue widget build.
|
||||
|
||||
## Wiring a checkpoint workflow
|
||||
|
||||
1. Upload/select an image in **Load Image Metadata (LoraManager)**.
|
||||
2. Convert `ckpt_name` on **Checkpoint Loader (LoraManager)** to an input and
|
||||
connect `model_name`. Leave its randomization control fixed.
|
||||
3. Connect the checkpoint's MODEL and CLIP to **Lora Loader (LoraManager)**.
|
||||
Connect the metadata node's `lora_stack` to that loader. Leave its LoRA widget
|
||||
empty unless you intentionally want additional LoRAs.
|
||||
4. Connect the LoRA loader's CLIP to two CLIP Text Encode nodes. Connect metadata
|
||||
`positive` and `negative` to their text inputs, and their conditioning outputs
|
||||
to KSampler. Connect the LoRA loader's MODEL to KSampler.
|
||||
5. Convert KSampler's seed, steps, cfg, sampler_name, scheduler, and denoise
|
||||
widgets to inputs and connect the corresponding metadata outputs.
|
||||
6. For text-to-image, connect width/height to an appropriate Empty Latent node.
|
||||
For img2img, encode the `image` output with the appropriate VAE instead.
|
||||
7. Connect KSampler's samples and the checkpoint's VAE to VAE Decode, then Save Image.
|
||||
8. Connect `readable_report` to a text display node for prompts, sampling settings,
|
||||
model/LoRA names, local resolution status and warnings. The original `report`
|
||||
output remains notes followed by formatted JSON; it is not a pure JSON string.
|
||||
|
||||
`model_name`, `sampler_name`, and `scheduler` use COMBO outputs for converted
|
||||
dropdown inputs in current ComfyUI. `model_name` contains the matching local
|
||||
checkpoint or diffusion-model filename. The report identifies the resolved type;
|
||||
connect it to the appropriate loader. Lookup searches both categories regardless
|
||||
of how the original metadata labels the model.
|
||||
|
||||
For a diffusion-model workflow, connect `model_name` to **Unet Loader
|
||||
(LoraManager)** and select the correct text encoder(s), VAE, latent node and
|
||||
architecture-specific conditioning separately. These settings do not reconstruct
|
||||
an entire workflow or guarantee pixel-identical reproduction.
|
||||
|
||||
## Selection and overrides
|
||||
|
||||
`prefer_saved_image_metadata` is enabled by default. It prefers the saved
|
||||
A1111-style generation parameters (including ComfyUI exports in that format)
|
||||
over the workflow. The report identifies this source; `sampler_node_id` is
|
||||
ignored in this mode when valid saved parameters are available. If saved
|
||||
parameters are absent or malformed, the node tries workflow metadata and
|
||||
reports any parsing failure.
|
||||
|
||||
Disable the flag to prefer workflow extraction. Only active samplers are
|
||||
eligible: muted/bypassed sampler nodes and samplers inside muted/bypassed
|
||||
subgraph instances are excluded. This uses the saved UI workflow's mode flags
|
||||
when available, including nested subgraphs, and any modes in the API graph.
|
||||
Explicitly selecting an inactive sampler produces an error report and the
|
||||
usual saved-parameter/default recovery; it never extracts that inactive stage.
|
||||
|
||||
With one supported active sampler, leave `sampler_node_id` blank. With several, enter
|
||||
its original node ID. Reports list candidate IDs when selection is ambiguous.
|
||||
Native subgraphs in API prompt metadata use colon-qualified paths: `1481:1783`
|
||||
means node 1783 inside subgraph instance 1481. Nested paths such as `10:20:30`
|
||||
are supported; slash notation (`1481/1783`) is also accepted. A container ID
|
||||
(`1481`) or leaf ID (`1783`) is accepted only if it identifies one sampler.
|
||||
An exact sampler ID takes precedence over abbreviated matching.
|
||||
|
||||
Selection follows that sampler's graph, rather than mixing branches. Supported
|
||||
sampling nodes include KSampler, KSamplerAdvanced and SamplerCustomAdvanced with
|
||||
standard RandomNoise, CFGGuider/BasicGuider, BasicScheduler and KSamplerSelect
|
||||
components. BasicGuider's CFG is 1; its architecture-specific lack of negative
|
||||
conditioning is reported. Known Image Saver parameter/selector outputs and
|
||||
rgthree seed values can be read without executing those nodes.
|
||||
|
||||
Detail Daemon's underlying sampler name is recovered, but its sampling effects
|
||||
are explicitly unsupported. Other custom model/conditioning nodes can still
|
||||
require defaults or overrides. If a requested stage cannot be read and global
|
||||
image parameters are used instead, the report explicitly says those parameters
|
||||
cannot verify the selected stage. Subgraph traversal requires the expanded API
|
||||
prompt; UI-workflow-only subgraph definitions are not expanded or executed.
|
||||
|
||||
Extraction errors do not stop this node. If an API prompt uses unsupported
|
||||
samplers, the node first tries the image's saved generation parameters. Any
|
||||
remaining unavailable or invalid extracted fields use the SDXL starter defaults
|
||||
(width/height fall back to the source image dimensions instead);
|
||||
valid extracted fields are preserved. `readable_report` starts with **❌ ERROR**
|
||||
and explains each recovery or substitution. This also applies to existing nodes
|
||||
saved with `missing_settings=strict`; that legacy option no longer blocks
|
||||
extraction recovery. New nodes default to `use_defaults`.
|
||||
|
||||
The report uses emoji section markers (🖼️ image, 📦 model, ⚙️ sampling, 🧩 LoRAs,
|
||||
➕/➖ prompts) and ❌/⚠️/ℹ️ status markers. It is plain text, so colors depend on the
|
||||
connected display node. Missing/ambiguous local files still appear in
|
||||
`missing_files`. An empty model output requires selecting a local model manually.
|
||||
Invalid explicit overrides and unreadable image files remain execution errors.
|
||||
|
||||
`overrides_json` replaces extracted values, for example:
|
||||
|
||||
```json
|
||||
{
|
||||
"scheduler": "normal",
|
||||
"model_name": "portraits/model.safetensors",
|
||||
"seed": 12345,
|
||||
"loras": [["styles/ink.safetensors", 0.7, 0.3]]
|
||||
}
|
||||
```
|
||||
|
||||
Supported keys: `positive`, `negative`, `model_name`, `seed`,
|
||||
`steps`, `cfg`, `sampler_name`, `scheduler`, `width`, `height`, `denoise`, `loras`.
|
||||
LoRA entries are `[name, model_strength, clip_strength]`; `"loras": []` explicitly
|
||||
clears the extracted stack. Legacy `checkpoint_name` and `unet_name` override
|
||||
keys remain accepted as aliases for `model_name`; supply only one model key.
|
||||
Exact relative or absolute local
|
||||
business paths disambiguate duplicate basenames. Matching falls back to a unique
|
||||
filename or extensionless filename, then an exact unique catalog `file_name` or
|
||||
`model_name` alias. Version dots are preserved when stripping known file
|
||||
extensions. It never downloads or fuzzy-matches models, and stale entries whose
|
||||
files no longer exist are excluded.
|
||||
|
||||
Images with no metadata automatically use a bottle-inspired SDXL starter preset,
|
||||
even with an existing saved `strict` setting: a glass-bottle/galaxy landscape
|
||||
prompt, negative `text, watermark`, seed 0, 20 steps, CFG 7, Euler/normal,
|
||||
1024×1024 and denoise 1, with no LoRAs. These settings are clearly identified as
|
||||
synthetic defaults in both reports. Source image pixels and mask are unchanged.
|
||||
Overrides take precedence. The node selects `sd_xl_base_1.0.safetensors` only
|
||||
when uniquely indexed; otherwise choose an SDXL checkpoint manually or supply
|
||||
`model_name`. Malformed or unsupported metadata also recovers with an explicit ERROR report.
|
||||
|
||||
## Supported metadata and limits
|
||||
|
||||
- PNG API prompt metadata; JPEG/WebP EXIF parameter comments; ComfyUI WebP
|
||||
`prompt:`/`workflow:` EXIF fields.
|
||||
- Standard KSampler, core checkpoint/UNet/LoRA loaders, LoRA Manager checkpoint,
|
||||
UNet, LoRA/text loaders and LoRA stacks. LoRA application order and separate
|
||||
model/CLIP strengths are preserved, including intentional repeated entries.
|
||||
Different LoRA chains on model and prompt CLIP branches require an explicit
|
||||
stack override rather than being silently merged.
|
||||
- Literal CLIPTextEncode text and supported primitive value connections. Prompt
|
||||
polarity comes from sampler wiring, never from words such as “ugly”.
|
||||
- A1111/Forge generation text with explicit sampler alias mappings. Recognized
|
||||
LoRA directives become stack entries and are removed from prompt text. Literal
|
||||
tags in ComfyUI encoder text remain literal; graph loaders determine its stack.
|
||||
- A1111 `Automatic`/absent schedules do not reliably identify a ComfyUI schedule.
|
||||
The node substitutes `normal` and reports the missing information as an ERROR;
|
||||
an explicit override can select a different schedule.
|
||||
- UI-workflow-only fallback supports known core widget layouts, with a report
|
||||
warning. Saved widgets can differ from executed values (for example a seed
|
||||
randomized after generation). Custom widget layouts are not guessed.
|
||||
- KSamplerAdvanced partial/noise settings require an explicit denoise override;
|
||||
this is an intentional approximation, not a reconstruction of those controls.
|
||||
- Distinct SDXL/Flux encoder prompts, combined/regional/zeroed conditioning,
|
||||
arbitrary custom nodes, dynamic wildcards and unsupported custom sampling components are
|
||||
not automatically reconstructed. Supply explicit overrides or retain the
|
||||
original workflow for those cases.
|
||||
- Width/height come from a recognized latent source or fall back to source-image
|
||||
dimensions; resized/upscaled images can therefore need dimension overrides.
|
||||
Only the synthetic starter preset for metadata-free images uses a fixed
|
||||
1024×1024 regardless of the source image size.
|
||||
- VAE, text encoder choice, CLIP skip, ControlNet and architecture-specific
|
||||
conditioning still need the appropriate nodes. No embedded code is executed
|
||||
and no external metadata service is contacted.
|
||||
|
||||
LoRA Manager must have indexed the required models. Library resolution includes
|
||||
its configured extra folders and preserves business paths through symlinks.
|
||||
|
||||
## Extraction without a local catalog
|
||||
|
||||
The parser extracts names before attempting local resolution. In recovery mode,
|
||||
`report` includes `source_resources` with original model names, LoRA names and
|
||||
strengths, and embedded resource hashes even when none are installed. The model
|
||||
output sockets remain empty and the resolved stack excludes missing files.
|
||||
|
||||
Combined sampler labels such as `Euler a SGM Uniform`, `Euler Normal` and
|
||||
`er_sde simple` are split into sampler and scheduler. Multiline parameter blocks
|
||||
and their nested JSON resource lists are supported. If prompt LoRA tags are
|
||||
absent, one hash-name entry and one weighted resource can be matched offline;
|
||||
multiple entries require an explicit mapping rather than guessing from order.
|
||||
A single resource also disambiguates duplicated identical prompt tags.
|
||||
|
||||
The `Model` field in A1111-style metadata does not distinguish checkpoints from
|
||||
standalone diffusion models. The node searches both indexed categories by name,
|
||||
then reports the matched type. Local model type and filename cannot be verified
|
||||
without an indexed library. Multiple equally good matches are reported as
|
||||
ambiguous; specify a relative path through `model_name` to disambiguate.
|
||||
|
||||
## “Image contains no supported generation metadata”
|
||||
|
||||
For older versions, this means extraction failed before any library lookup.
|
||||
The current node uses the starter preset when metadata is entirely absent. The error identifies the
|
||||
actual server file, its format, byte size and metadata keys. PNG text chunks are
|
||||
read both before and after pixel data. If no generation metadata remains, upload
|
||||
the original saved file: clipboard copies and re-encoded/exported images may
|
||||
lose it. `use_defaults` supplies replacement settings; it does not recover the
|
||||
original prompts or seed.
|
||||
|
||||
## Missing local resources
|
||||
|
||||
`missing_files` is a text output listing unresolved checkpoints/UNets and LoRAs.
|
||||
LoRA entries include both model and CLIP weights and the resolution failure.
|
||||
It is empty when all requested resources resolve. Missing and ambiguous LoRAs
|
||||
are excluded from `lora_stack`, including in strict mode, so downstream loaders
|
||||
receive only resolved files. Valid entries keep their original order and weights.
|
||||
Unresolved model-name sockets are empty: select a model manually or override its
|
||||
name before connecting that socket to a loader.
|
||||
|
||||
## Output layout and upgrade
|
||||
|
||||
The outputs start with `image`, `mask`, `positive`, `negative`, **`model_name`**,
|
||||
**`lora_stack`**, **`lora_stack_text`**, followed by the sampling settings and reports.
|
||||
`lora_stack_text` lists each resolved stack path with model and CLIP weights in
|
||||
application order. It is empty for an empty stack; unresolved files appear only
|
||||
in `missing_files`, with their requested weights.
|
||||
|
||||
This replaces the former separate checkpoint/UNet sockets and renames `lost_list`
|
||||
to `missing_files`. Restart ComfyUI, refresh, and recreate existing instances of
|
||||
this node; reconnect the model and stack outputs to avoid stale saved slot indices.
|
||||
Sampling and report output indices remain unchanged. No Vue build is required.
|
||||
@@ -0,0 +1,107 @@
|
||||
# Plan: Filename Template Follow-ups
|
||||
|
||||
**Issue:** [#1071 — Lora Renaming](https://github.com/willmiao/ComfyUI-Lora-Manager/issues/1071)
|
||||
**Status:** Core feature **implemented** (2026-09-19, commit `2bc9860b`,
|
||||
preceded by the settings-tab split in `327da046`). Follow-ups 1 and 2 were
|
||||
resolved together on 2026-09-19 by redefining the empty template as
|
||||
"revert to recorded original filename" (see below). Follow-up 3 remains open.
|
||||
|
||||
## What shipped in `2bc9860b`
|
||||
|
||||
- Per-model-type `download_filename_templates` setting (empty = keep current
|
||||
filename; opt-in). Placeholders: `{model_name}`, `{version_name}`,
|
||||
`{base_model}`, `{author}`, `{first_tag}`, `{hash_short}`,
|
||||
`{original_name}`.
|
||||
- `calculate_filename_for_model()` in `py/utils/utils.py` renders the
|
||||
template; templates containing path separators are rejected.
|
||||
- Downloads apply the template post-download
|
||||
(`DownloadManager._apply_download_filename_template`); rename conflicts
|
||||
keep the original name and never fail the download.
|
||||
- `ModelLifecycleService.rename_model` records `original_file_name` in the
|
||||
`.metadata.json` sidecar (first rename wins via `setdefault`).
|
||||
- Bulk apply: `GET|POST /api/lm/{prefix}/apply-filename-template`
|
||||
(`FilenameTemplateUseCase`, shares the auto-organize lock, WS progress type
|
||||
`filename_template_progress`).
|
||||
- Settings UI: "Filename Templates" subsection in the new **Organization**
|
||||
settings tab (`templates/components/modals/settings/organization.html`),
|
||||
with validation, live preview, and per-type "Apply to Library Now".
|
||||
|
||||
Sandbox E2E verified: rename incl. companion files (previews, sidecars),
|
||||
metadata pointer updates, `original_file_name` recording, idempotency,
|
||||
conflict handling (failure counted, batch continues), empty-template no-op,
|
||||
GET variant.
|
||||
|
||||
## Follow-ups 1 & 2 — RESOLVED: empty template = revert to recorded original
|
||||
|
||||
Follow-up 1 asked to reword the ambiguous "Valid (keep original filename)"
|
||||
empty-template message; Follow-up 2 asked for a bulk revert to the recorded
|
||||
`original_file_name`. Both were resolved by a single semantic change: **an
|
||||
empty template now means "restore the recorded original filename"** instead of
|
||||
"leave the current filename untouched".
|
||||
|
||||
Rationale: for never-renamed models a revert is a no-op (no recorded
|
||||
original), for renamed models it restores the pre-rename name, and new
|
||||
downloads with an empty template keep the download name as before — so the
|
||||
two contexts (download path and bulk apply) share one coherent meaning, and
|
||||
no separate revert feature or `{recorded_original}` placeholder is needed.
|
||||
|
||||
Implemented changes:
|
||||
|
||||
- `FilenameTemplateUseCase._process_model`: an empty template now resolves
|
||||
the target name from the sidecar's `original_file_name` via the injected
|
||||
`metadata_loader` (default `load_local_metadata`); models without a
|
||||
recorded original or whose original matches the current name are skipped.
|
||||
Cache entries do not project `original_file_name`, so the sidecar is read
|
||||
per model.
|
||||
- `SettingsManager.js`: removed the empty-template early return and the
|
||||
apply-button disable (`updateFilenameTemplateApplyButton` deleted — the
|
||||
button is now always enabled). The browser-native `confirm()` was replaced
|
||||
with `filenameTemplateConfirmModal`
|
||||
(`templates/components/modals/confirm_modals.html`), a **self-managed**
|
||||
modal (like `DirectoryPickerModal`, NOT registered with ModalManager):
|
||||
ModalManager's "close current modal on open" behavior would kill the
|
||||
settings modal underneath. It stacks via `z-index: 10010`
|
||||
(`delete-modal.css`), handles ESC in capture phase with
|
||||
`stopPropagation`, and shows apply vs revert wording
|
||||
(`modals.filenameTemplateConfirm.titleApply` / `titleRevert` /
|
||||
`revertButton`; messages reuse `settings.filenameTemplates.confirmApply` /
|
||||
`confirmRevert`).
|
||||
- `locales/en.json`: reworded `help` / `applyHelp`, replaced
|
||||
`validation.keepOriginal` with `validation.restoreOriginal`
|
||||
("Valid (empty template restores original filenames)"), added
|
||||
`confirmRevert`, removed the now-unused `emptyTemplateInfo`. Other locales
|
||||
re-synced with `[TODO: Translate]` placeholders — retranslation waits for
|
||||
the feature owner's request per `docs/i18n-translation-guidelines.md` §7.
|
||||
- Tests: revert / no-record-skip / same-name-skip cases in
|
||||
`tests/services/test_use_cases.py`; modal confirm-and-revert and
|
||||
cancel paths in
|
||||
`tests/frontend/managers/settingsManager.filenameTemplates.test.js`.
|
||||
|
||||
Sandbox E2E verified (standalone server, sandboxed settings + library under
|
||||
`/tmp`, 2026-09-19): template apply renames and records
|
||||
`original_file_name`; empty-template apply reverts to the recorded name;
|
||||
revert target occupied by a newer file counts as failure and keeps the
|
||||
current name; models without a recorded original are skipped;
|
||||
apply → revert → re-apply cycles repeat cleanly.
|
||||
|
||||
Standing caveats (unchanged):
|
||||
|
||||
- The revert target may collide with an existing file — the existing conflict
|
||||
handling (count as failure, keep current name) covers this.
|
||||
- `original_file_name` only exists for models renamed after `2bc9860b`;
|
||||
older renames have no recorded original and are skipped.
|
||||
- `original_file_name` is kept (not cleared) after a revert, so
|
||||
apply → revert → re-apply stays repeatable.
|
||||
|
||||
## Follow-up 3 — Cross-page refresh after bulk apply
|
||||
|
||||
**Problem:** the settings-modal "Apply to Library Now" button calls
|
||||
`resetAndReload(true)`, which refreshes only the page type currently open.
|
||||
Applying the checkpoint template while on the loras page leaves the loras
|
||||
view refreshed but does not touch the checkpoints page state (same
|
||||
limitation as the existing bulk auto-organize flow in
|
||||
`static/js/managers/SettingsManager.js#applyFilenameTemplate`).
|
||||
|
||||
**Fix options:** broadcast a generic "library changed" event that every
|
||||
page's state listens to, or accept the limitation (the other page reloads
|
||||
its cache on next visit). Low priority.
|
||||
@@ -30,7 +30,9 @@ Aliases live inside `()` and are separated with `|`. The canonical name is what
|
||||
When your path template contains `{first_tag}`, the app picks a folder name based on your priority list and the model’s own tags:
|
||||
|
||||
- It checks the priority list from top to bottom. If a canonical tag or any of its aliases appear in the model tags, that canonical name becomes the folder name.
|
||||
- If no priority tags are found but the model has tags, the very first model tag is used.
|
||||
- If no priority tags are found but the model has tags, the first tag that can be used as a folder name is chosen.
|
||||
- Tags that contain a comma, or that are longer than 50 characters, are treated as unusable and skipped: some uploaders pack their whole keyword list into a single tag. If every tag is unusable, the folder falls back to `no tags`.
|
||||
- Civitai's structural labels, such as `base model`, describe the listing rather than the model, so the automatic fallback skips them too. Add one to your priority list if you really want it as a folder name.
|
||||
- If the model has no tags at all, the folder falls back to `no tags`.
|
||||
|
||||
### Example
|
||||
@@ -42,6 +44,8 @@ With a template like `/{model_type}/{first_tag}` and the priority entry list `ch
|
||||
| `["chars", "female"]` | `character` | `chars` matches the `character` alias, so the canonical wins. |
|
||||
| `["anime", "portrait"]` | `style` | `anime` hits the `style` entry, so its canonical label is used. |
|
||||
| `["portrait", "bw"]` | `portrait` | No priority match, so the first model tag is used. |
|
||||
| `["lora, character, rosie, ... face"]` | `no tags` | The only tag is a keyword dump, so it is skipped. |
|
||||
| `["lora, character, ... face", "base model"]` | `no tags` | A keyword dump plus a Civitai label: nothing usable is left. |
|
||||
| `[]` | `no tags` | Nothing to match, so the fallback is applied. |
|
||||
|
||||
## 3. Save the Settings
|
||||
@@ -61,10 +65,12 @@ After editing the entry list, press **Enter** to save. Use **Shift+Enter** whene
|
||||
- Keep canonical names short and meaningful—they become folder names.
|
||||
- Place the most important categories first; the first match wins.
|
||||
- Avoid duplicate canonical names within the same list; only the first instance is used.
|
||||
- Folder names built from tags are sanitized for filesystem safety and truncated to 50 characters.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Unexpected folder name?** Check that the canonical name you want is placed before other matches.
|
||||
- **Folder named `no tags`?** Every model tag was either missing or unusable (a comma-separated keyword dump, or longer than 50 characters). Add the tags you care about to your priority list so they match by name instead.
|
||||
- **Alias not working?** Ensure the alias is inside parentheses and separated with `|`, e.g. `character(char|chars)`.
|
||||
- **Validation error?** Look for missing parentheses or stray commas. Each entry must follow the `canonical(alias|alias)` pattern or just `canonical`.
|
||||
|
||||
|
||||
@@ -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.",
|
||||
@@ -383,6 +389,7 @@
|
||||
"general": "Allgemein",
|
||||
"interface": "Oberfläche",
|
||||
"library": "Bibliothek",
|
||||
"organization": "Organisation",
|
||||
"modelPaths": "Modellpfade"
|
||||
},
|
||||
"search": {
|
||||
@@ -680,6 +687,22 @@
|
||||
"validTemplate": "Gültige Vorlage"
|
||||
}
|
||||
},
|
||||
"filenameTemplates": {
|
||||
"title": "Dateinamen-Vorlagen",
|
||||
"help": "Konfigurieren Sie Dateinamen für heruntergeladene Modelle pro Modelltyp. Leer lassen, um den ursprünglichen Dateinamen zu behalten. Der ursprüngliche Dateiname bleibt immer in den Metadaten des Modells erhalten.",
|
||||
"availablePlaceholders": "Verfügbare Platzhalter:",
|
||||
"templatePlaceholder": "Dateinamen-Vorlage eingeben (z.B. {base_model}-{model_name}-{version_name})",
|
||||
"applyButton": "Jetzt auf Bibliothek anwenden",
|
||||
"applyHelp": "Benennt alle vorhandenen Dateien dieses Modelltyps gemäß der Vorlage um. Warnung: Das Umbenennen ändert den relativen Pfad, den ComfyUI-Loader sehen; vorhandene Workflows, die den alten Dateinamen referenzieren, müssen möglicherweise aktualisiert werden. Der ursprüngliche Dateiname bleibt in den Metadaten jedes Modells erhalten.",
|
||||
"confirmApply": "Alle vorhandenen Dateien dieses Modelltyps gemäß der Dateinamen-Vorlage umbenennen? Dies ändert den relativen Pfad, den ComfyUI-Loader sehen. Der ursprüngliche Dateiname bleibt in den Metadaten jedes Modells erhalten.",
|
||||
"confirmRevert": "Die gespeicherten ursprünglichen Dateinamen aller zuvor umbenannten Dateien dieses Modelltyps wiederherstellen? Dies ändert den relativen Pfad, den ComfyUI-Loader sehen. Dateien ohne gespeicherten ursprünglichen Dateinamen werden übersprungen.",
|
||||
"validation": {
|
||||
"restoreOriginal": "Gültig (leere Vorlage stellt ursprüngliche Dateinamen wieder her)",
|
||||
"invalidChars": "Ungültige Zeichen erkannt (ein Dateiname darf / \\ < > : \" | ? * nicht enthalten)",
|
||||
"invalidPlaceholder": "Ungültiger Platzhalter: {placeholder}",
|
||||
"validTemplate": "Gültige Vorlage"
|
||||
}
|
||||
},
|
||||
"exampleImages": {
|
||||
"downloadLocation": "Download-Speicherort",
|
||||
"downloadLocationPlaceholder": "Ordnerpfad für Beispielbilder eingeben",
|
||||
@@ -912,6 +935,14 @@
|
||||
"complete": "Automatische Organisation abgeschlossen",
|
||||
"error": "Fehler: {error}"
|
||||
},
|
||||
"filenameTemplateProgress": {
|
||||
"initializing": "Anwendung der Dateinamen-Vorlage wird initialisiert...",
|
||||
"starting": "Dateinamen-Vorlage wird auf {type} angewendet...",
|
||||
"processing": "Verarbeitung ({processed}/{total}) – {success} umbenannt, {skipped} übersprungen, {failures} fehlgeschlagen",
|
||||
"completed": "Abgeschlossen: {success} umbenannt, {skipped} übersprungen, {failures} fehlgeschlagen",
|
||||
"complete": "Anwendung der Dateinamen-Vorlage abgeschlossen",
|
||||
"error": "Fehler: {error}"
|
||||
},
|
||||
"enrichHfAgent": "Metadaten mit KI anreichern"
|
||||
},
|
||||
"contextMenu": {
|
||||
@@ -1601,6 +1632,11 @@
|
||||
"tip": "Möchten Sie in Etappen prüfen? Wechseln Sie in den Massenmodus, wählen Sie die benötigten Modelle aus und nutzen Sie anschließend \"Auswahl auf Updates prüfen\".",
|
||||
"action": "Alles prüfen"
|
||||
},
|
||||
"filenameTemplateConfirm": {
|
||||
"titleApply": "Dateinamen-Vorlage auf Bibliothek anwenden?",
|
||||
"titleRevert": "Ursprüngliche Dateinamen wiederherstellen?",
|
||||
"revertButton": "Ursprüngliche Dateinamen wiederherstellen"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Tags zu mehreren Modellen hinzufügen",
|
||||
"description": "Tags hinzufügen zu",
|
||||
@@ -2310,6 +2346,9 @@
|
||||
"autoOrganizeSuccess": "Automatische Organisation für {count} {type} erfolgreich abgeschlossen",
|
||||
"autoOrganizePartialSuccess": "Automatische Organisation abgeschlossen: {success} verschoben, {failures} fehlgeschlagen von insgesamt {total} Modellen",
|
||||
"autoOrganizeFailed": "Automatische Organisation fehlgeschlagen: {error}",
|
||||
"filenameTemplateSuccess": "Dateinamen-Vorlage erfolgreich für {count} {type} angewendet",
|
||||
"filenameTemplatePartialSuccess": "Dateinamen-Vorlage angewendet: {success} umbenannt, {failures} von {total} Modellen fehlgeschlagen",
|
||||
"filenameTemplateFailed": "Anwendung der Dateinamen-Vorlage fehlgeschlagen: {error}",
|
||||
"noModelsSelected": "Keine Modelle ausgewählt"
|
||||
},
|
||||
"recipes": {
|
||||
@@ -2476,6 +2515,8 @@
|
||||
"mappingSaveFailed": "Fehler beim Speichern der Basismodell-Zuordnungen: {message}",
|
||||
"downloadTemplatesUpdated": "Download-Pfad-Vorlagen aktualisiert",
|
||||
"downloadTemplatesFailed": "Fehler beim Speichern der Download-Pfad-Vorlagen: {message}",
|
||||
"filenameTemplatesUpdated": "Dateinamen-Vorlagen aktualisiert",
|
||||
"filenameTemplatesFailed": "Dateinamen-Vorlagen konnten nicht gespeichert werden: {message}",
|
||||
"recipesPathUpdated": "Rezepte-Speicherpfad aktualisiert",
|
||||
"recipesPathSaveFailed": "Fehler beim Aktualisieren des Rezepte-Speicherpfads: {message}",
|
||||
"settingsUpdated": "Einstellungen aktualisiert: {setting}",
|
||||
|
||||
@@ -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.",
|
||||
@@ -383,6 +389,7 @@
|
||||
"general": "General",
|
||||
"interface": "Interface",
|
||||
"library": "Library",
|
||||
"organization": "Organization",
|
||||
"modelPaths": "Model Paths"
|
||||
},
|
||||
"search": {
|
||||
@@ -680,6 +687,22 @@
|
||||
"validTemplate": "Valid template"
|
||||
}
|
||||
},
|
||||
"filenameTemplates": {
|
||||
"title": "Filename Templates",
|
||||
"help": "Configure filenames for downloaded models per model type. Leave empty to keep original filenames on download; applying an empty template restores the recorded original filenames of previously renamed models. The original filename is always preserved in the model's metadata.",
|
||||
"availablePlaceholders": "Available placeholders:",
|
||||
"templatePlaceholder": "Enter filename template (e.g., {base_model}-{model_name}-{version_name})",
|
||||
"applyButton": "Apply to Library Now",
|
||||
"applyHelp": "Renames all existing files of this model type according to the template; with an empty template, restores the recorded original filenames instead. Warning: renaming changes the relative path seen by ComfyUI loaders, so existing workflows referencing the old filename may need to be updated. The original filename is preserved in each model's metadata.",
|
||||
"confirmApply": "Rename all existing files of this model type according to the filename template? This changes the relative path seen by ComfyUI loaders. The original filename is preserved in each model's metadata.",
|
||||
"confirmRevert": "Restore the recorded original filenames of all previously renamed files of this model type? This changes the relative path seen by ComfyUI loaders. Files without a recorded original filename are skipped.",
|
||||
"validation": {
|
||||
"restoreOriginal": "Valid (empty template restores original filenames)",
|
||||
"invalidChars": "Invalid characters detected (a filename cannot contain / \\ < > : \" | ? *)",
|
||||
"invalidPlaceholder": "Invalid placeholder: {placeholder}",
|
||||
"validTemplate": "Valid template"
|
||||
}
|
||||
},
|
||||
"exampleImages": {
|
||||
"downloadLocation": "Download Location",
|
||||
"downloadLocationPlaceholder": "Enter folder path for example images",
|
||||
@@ -912,6 +935,14 @@
|
||||
"complete": "Auto-organize complete",
|
||||
"error": "Error: {error}"
|
||||
},
|
||||
"filenameTemplateProgress": {
|
||||
"initializing": "Initializing filename template apply...",
|
||||
"starting": "Applying filename template to {type}...",
|
||||
"processing": "Processing ({processed}/{total}) - {success} renamed, {skipped} skipped, {failures} failed",
|
||||
"completed": "Completed: {success} renamed, {skipped} skipped, {failures} failed",
|
||||
"complete": "Filename template apply complete",
|
||||
"error": "Error: {error}"
|
||||
},
|
||||
"enrichHfAgent": "Enrich Metadata with AI"
|
||||
},
|
||||
"contextMenu": {
|
||||
@@ -1601,6 +1632,11 @@
|
||||
"tip": "To work in smaller batches, switch to bulk mode, choose the ones you need, then use \"Check Updates for Selected\".",
|
||||
"action": "Check All"
|
||||
},
|
||||
"filenameTemplateConfirm": {
|
||||
"titleApply": "Apply filename template to library?",
|
||||
"titleRevert": "Restore original filenames?",
|
||||
"revertButton": "Restore Original Filenames"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Add Tags to Multiple Models",
|
||||
"description": "Add tags to",
|
||||
@@ -2310,6 +2346,9 @@
|
||||
"autoOrganizeSuccess": "Auto-organize completed successfully for {count} {type}",
|
||||
"autoOrganizePartialSuccess": "Auto-organize completed with {success} moved, {failures} failed out of {total} models",
|
||||
"autoOrganizeFailed": "Auto-organize failed: {error}",
|
||||
"filenameTemplateSuccess": "Filename template applied successfully for {count} {type}",
|
||||
"filenameTemplatePartialSuccess": "Filename template applied with {success} renamed, {failures} failed out of {total} models",
|
||||
"filenameTemplateFailed": "Applying filename template failed: {error}",
|
||||
"noModelsSelected": "No models selected"
|
||||
},
|
||||
"recipes": {
|
||||
@@ -2476,6 +2515,8 @@
|
||||
"mappingSaveFailed": "Failed to save base model mappings: {message}",
|
||||
"downloadTemplatesUpdated": "Download path templates updated",
|
||||
"downloadTemplatesFailed": "Failed to save download path templates: {message}",
|
||||
"filenameTemplatesUpdated": "Filename templates updated",
|
||||
"filenameTemplatesFailed": "Failed to save filename templates: {message}",
|
||||
"recipesPathUpdated": "Recipes storage path updated",
|
||||
"recipesPathSaveFailed": "Failed to update recipes storage path: {message}",
|
||||
"settingsUpdated": "Settings updated: {setting}",
|
||||
|
||||
@@ -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\".",
|
||||
@@ -383,6 +389,7 @@
|
||||
"general": "General",
|
||||
"interface": "Interfaz",
|
||||
"library": "Biblioteca",
|
||||
"organization": "Organización",
|
||||
"modelPaths": "Rutas de modelos"
|
||||
},
|
||||
"search": {
|
||||
@@ -680,6 +687,22 @@
|
||||
"validTemplate": "Plantilla válida"
|
||||
}
|
||||
},
|
||||
"filenameTemplates": {
|
||||
"title": "Plantillas de nombres de archivo",
|
||||
"help": "Configurar nombres de archivo de los modelos descargados por tipo de modelo. Dejar vacío para conservar los nombres de archivo originales al descargar; aplicar una plantilla vacía restaura los nombres de archivo originales registrados de los modelos renombrados previamente. El nombre de archivo original siempre se conserva en los metadatos del modelo.",
|
||||
"availablePlaceholders": "Marcadores de posición disponibles:",
|
||||
"templatePlaceholder": "Introduce plantilla de nombre de archivo (ej., {base_model}-{model_name}-{version_name})",
|
||||
"applyButton": "Aplicar a la biblioteca ahora",
|
||||
"applyHelp": "Renombra todos los archivos existentes de este tipo de modelo según la plantilla; con una plantilla vacía, restaura los nombres de archivo originales registrados. Advertencia: renombrar cambia la ruta relativa que ven los cargadores de ComfyUI, por lo que los workflows existentes que hagan referencia al nombre de archivo anterior pueden necesitar actualizarse. El nombre de archivo original se conserva en los metadatos de cada modelo.",
|
||||
"confirmApply": "¿Renombrar todos los archivos existentes de este tipo de modelo según la plantilla de nombres de archivo? Esto cambia la ruta relativa que ven los cargadores de ComfyUI. El nombre de archivo original se conserva en los metadatos de cada modelo.",
|
||||
"confirmRevert": "¿Restaurar los nombres de archivo originales registrados de todos los archivos renombrados previamente de este tipo de modelo? Esto cambia la ruta relativa que ven los cargadores de ComfyUI. Los archivos sin un nombre de archivo original registrado se omiten.",
|
||||
"validation": {
|
||||
"restoreOriginal": "Válido (la plantilla vacía restaura los nombres de archivo originales)",
|
||||
"invalidChars": "Caracteres inválidos detectados (un nombre de archivo no puede contener / \\ < > : \" | ? *)",
|
||||
"invalidPlaceholder": "Marcador de posición inválido: {placeholder}",
|
||||
"validTemplate": "Plantilla válida"
|
||||
}
|
||||
},
|
||||
"exampleImages": {
|
||||
"downloadLocation": "Ubicación de descarga",
|
||||
"downloadLocationPlaceholder": "Introduce la ruta de la carpeta para imágenes de ejemplo",
|
||||
@@ -912,6 +935,14 @@
|
||||
"complete": "Auto-organización completada",
|
||||
"error": "Error: {error}"
|
||||
},
|
||||
"filenameTemplateProgress": {
|
||||
"initializing": "Inicializando aplicación de plantilla de nombres de archivo...",
|
||||
"starting": "Aplicando plantilla de nombres de archivo a {type}...",
|
||||
"processing": "Procesando ({processed}/{total}) - {success} renombrados, {skipped} omitidos, {failures} fallidos",
|
||||
"completed": "Completado: {success} renombrados, {skipped} omitidos, {failures} fallidos",
|
||||
"complete": "Aplicación de plantilla de nombres de archivo completada",
|
||||
"error": "Error: {error}"
|
||||
},
|
||||
"enrichHfAgent": "Enriquecer metadatos con IA"
|
||||
},
|
||||
"contextMenu": {
|
||||
@@ -1601,6 +1632,11 @@
|
||||
"tip": "¿Quieres hacerlo por partes? Activa el modo por lotes, selecciona los modelos que necesites y usa \"Comprobar actualizaciones para la selección\".",
|
||||
"action": "Comprobar todo"
|
||||
},
|
||||
"filenameTemplateConfirm": {
|
||||
"titleApply": "¿Aplicar la plantilla de nombres de archivo a la biblioteca?",
|
||||
"titleRevert": "¿Restaurar los nombres de archivo originales?",
|
||||
"revertButton": "Restaurar nombres de archivo originales"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Añadir etiquetas a múltiples modelos",
|
||||
"description": "Añadir etiquetas a",
|
||||
@@ -2310,6 +2346,9 @@
|
||||
"autoOrganizeSuccess": "Auto-organización completada exitosamente para {count} {type}",
|
||||
"autoOrganizePartialSuccess": "Auto-organización completada con {success} movidos, {failures} fallidos de un total de {total} modelos",
|
||||
"autoOrganizeFailed": "Auto-organización fallida: {error}",
|
||||
"filenameTemplateSuccess": "Plantilla de nombres de archivo aplicada exitosamente para {count} {type}",
|
||||
"filenameTemplatePartialSuccess": "Plantilla de nombres de archivo aplicada con {success} renombrados, {failures} fallidos de un total de {total} modelos",
|
||||
"filenameTemplateFailed": "Aplicación de la plantilla de nombres de archivo fallida: {error}",
|
||||
"noModelsSelected": "No hay modelos seleccionados"
|
||||
},
|
||||
"recipes": {
|
||||
@@ -2476,6 +2515,8 @@
|
||||
"mappingSaveFailed": "Error al guardar mapeos de modelo base: {message}",
|
||||
"downloadTemplatesUpdated": "Plantillas de rutas de descarga actualizadas",
|
||||
"downloadTemplatesFailed": "Error al guardar plantillas de rutas de descarga: {message}",
|
||||
"filenameTemplatesUpdated": "Plantillas de nombres de archivo actualizadas",
|
||||
"filenameTemplatesFailed": "Error al guardar plantillas de nombres de archivo: {message}",
|
||||
"recipesPathUpdated": "Ruta de almacenamiento de recetas actualizada",
|
||||
"recipesPathSaveFailed": "Error al actualizar la ruta de almacenamiento de recetas: {message}",
|
||||
"settingsUpdated": "Configuración actualizada: {setting}",
|
||||
|
||||
@@ -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 ».",
|
||||
@@ -383,6 +389,7 @@
|
||||
"general": "Général",
|
||||
"interface": "Interface",
|
||||
"library": "Bibliothèque",
|
||||
"organization": "Organisation",
|
||||
"modelPaths": "Chemins de modèles"
|
||||
},
|
||||
"search": {
|
||||
@@ -680,6 +687,22 @@
|
||||
"validTemplate": "Modèle valide"
|
||||
}
|
||||
},
|
||||
"filenameTemplates": {
|
||||
"title": "Modèles de nom de fichier",
|
||||
"help": "Configurer les noms de fichier des modèles téléchargés par type de modèle. Laisser vide pour conserver le nom de fichier d'origine. Le nom de fichier d'origine est toujours conservé dans les métadonnées du modèle.",
|
||||
"availablePlaceholders": "Espaces réservés disponibles :",
|
||||
"templatePlaceholder": "Entrez un modèle de nom de fichier (ex: {base_model}-{model_name}-{version_name})",
|
||||
"applyButton": "Appliquer à la bibliothèque maintenant",
|
||||
"applyHelp": "Renomme tous les fichiers existants de ce type de modèle selon le modèle. Attention : le renommage change le chemin relatif vu par les loaders ComfyUI, les workflows existants référençant l'ancien nom de fichier peuvent donc nécessiter une mise à jour. Le nom de fichier d'origine est conservé dans les métadonnées de chaque modèle.",
|
||||
"confirmApply": "Renommer tous les fichiers existants de ce type de modèle selon le modèle de nom de fichier ? Cela change le chemin relatif vu par les loaders ComfyUI. Le nom de fichier d'origine est conservé dans les métadonnées de chaque modèle.",
|
||||
"confirmRevert": "Restaurer les noms de fichier d'origine enregistrés de tous les fichiers précédemment renommés de ce type de modèle ? Cela change le chemin relatif vu par les loaders ComfyUI. Les fichiers sans nom de fichier d'origine enregistré sont ignorés.",
|
||||
"validation": {
|
||||
"restoreOriginal": "Valide (un modèle vide restaure les noms de fichier d'origine)",
|
||||
"invalidChars": "Caractères invalides détectés (un nom de fichier ne peut pas contenir / \\ < > : \" | ? *)",
|
||||
"invalidPlaceholder": "Espace réservé invalide : {placeholder}",
|
||||
"validTemplate": "Modèle valide"
|
||||
}
|
||||
},
|
||||
"exampleImages": {
|
||||
"downloadLocation": "Emplacement de téléchargement",
|
||||
"downloadLocationPlaceholder": "Entrez le chemin du dossier pour les images d'exemple",
|
||||
@@ -912,6 +935,14 @@
|
||||
"complete": "Auto-organisation terminée",
|
||||
"error": "Erreur : {error}"
|
||||
},
|
||||
"filenameTemplateProgress": {
|
||||
"initializing": "Initialisation de l'application du modèle de nom de fichier...",
|
||||
"starting": "Application du modèle de nom de fichier pour {type}...",
|
||||
"processing": "Traitement ({processed}/{total}) - {success} renommés, {skipped} ignorés, {failures} échecs",
|
||||
"completed": "Terminé : {success} renommés, {skipped} ignorés, {failures} échecs",
|
||||
"complete": "Application du modèle de nom de fichier terminée",
|
||||
"error": "Erreur : {error}"
|
||||
},
|
||||
"enrichHfAgent": "Enrichir les métadonnées avec l'IA"
|
||||
},
|
||||
"contextMenu": {
|
||||
@@ -1601,6 +1632,11 @@
|
||||
"tip": "Besoin de procéder par étapes ? Passez en mode groupé, sélectionnez les modèles souhaités puis utilisez \"Vérifier les mises à jour pour la sélection\".",
|
||||
"action": "Tout vérifier"
|
||||
},
|
||||
"filenameTemplateConfirm": {
|
||||
"titleApply": "Appliquer le modèle de nom de fichier à la bibliothèque ?",
|
||||
"titleRevert": "Restaurer les noms de fichier d'origine ?",
|
||||
"revertButton": "Restaurer les noms de fichier d'origine"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Ajouter des tags à plusieurs modèles",
|
||||
"description": "Ajouter des tags à",
|
||||
@@ -2310,6 +2346,9 @@
|
||||
"autoOrganizeSuccess": "Auto-organisation terminée avec succès pour {count} {type}",
|
||||
"autoOrganizePartialSuccess": "Auto-organisation terminée avec {success} déplacés, {failures} échecs sur {total} modèles",
|
||||
"autoOrganizeFailed": "Échec de l'auto-organisation : {error}",
|
||||
"filenameTemplateSuccess": "Modèle de nom de fichier appliqué avec succès pour {count} {type}",
|
||||
"filenameTemplatePartialSuccess": "Modèle de nom de fichier appliqué avec {success} renommés, {failures} échecs sur {total} modèles",
|
||||
"filenameTemplateFailed": "Échec de l'application du modèle de nom de fichier : {error}",
|
||||
"noModelsSelected": "Aucun modèle sélectionné"
|
||||
},
|
||||
"recipes": {
|
||||
@@ -2476,6 +2515,8 @@
|
||||
"mappingSaveFailed": "Échec de la sauvegarde des mappages de modèle de base : {message}",
|
||||
"downloadTemplatesUpdated": "Modèles de chemin de téléchargement mis à jour",
|
||||
"downloadTemplatesFailed": "Échec de la sauvegarde des modèles de chemin de téléchargement : {message}",
|
||||
"filenameTemplatesUpdated": "Modèles de nom de fichier mis à jour",
|
||||
"filenameTemplatesFailed": "Échec de la sauvegarde des modèles de nom de fichier : {message}",
|
||||
"recipesPathUpdated": "Chemin de stockage des Recipes mis à jour",
|
||||
"recipesPathSaveFailed": "Échec de la mise à jour du chemin de stockage des Recipes : {message}",
|
||||
"settingsUpdated": "Paramètres mis à jour : {setting}",
|
||||
|
||||
@@ -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\".",
|
||||
@@ -383,6 +389,7 @@
|
||||
"general": "כללי",
|
||||
"interface": "ממשק",
|
||||
"library": "ספרייה",
|
||||
"organization": "ארגון",
|
||||
"modelPaths": "נתיבי מודלים"
|
||||
},
|
||||
"search": {
|
||||
@@ -680,6 +687,22 @@
|
||||
"validTemplate": "תבנית תקינה"
|
||||
}
|
||||
},
|
||||
"filenameTemplates": {
|
||||
"title": "תבניות שמות קבצים",
|
||||
"help": "הגדר שמות קבצים למודלים שהורדו לפי סוג מודל. השאר ריק כדי לשמור על שמות הקבצים המקוריים בעת ההורדה; החלת תבנית ריקה משחזרת את שמות הקבצים המקוריים המתועדים של מודלים ששונה שמם בעבר. שם הקובץ המקורי תמיד נשמר במטא-נתונים של המודל.",
|
||||
"availablePlaceholders": "מצייני מקום זמינים:",
|
||||
"templatePlaceholder": "הזן תבנית שם קובץ (למשל, {base_model}-{model_name}-{version_name})",
|
||||
"applyButton": "החל על הספרייה כעת",
|
||||
"applyHelp": "משנה את שמות כל הקבצים הקיימים מסוג מודל זה בהתאם לתבנית; עם תבנית ריקה, משחזר במקום זאת את שמות הקבצים המקוריים המתועדים. אזהרה: שינוי שם משנה את הנתיב היחסי שרואים הטוענים של ComfyUI, ולכן workflows קיימים המפנים לשם הקובץ הישן עשויים לדרוש עדכון. שם הקובץ המקורי נשמר במטא-נתונים של כל מודל.",
|
||||
"confirmApply": "לשנות את שמות כל הקבצים הקיימים מסוג מודל זה בהתאם לתבנית שם הקובץ? פעולה זו משנה את הנתיב היחסי שרואים הטוענים של ComfyUI. שם הקובץ המקורי נשמר במטא-נתונים של כל מודל.",
|
||||
"confirmRevert": "לשחזר את שמות הקבצים המקוריים המתועדים של כל הקבצים ששונה שמם בעבר מסוג מודל זה? פעולה זו משנה את הנתיב היחסי שרואים הטוענים של ComfyUI. קבצים ללא שם קובץ מקורי מתועד ידולגו.",
|
||||
"validation": {
|
||||
"restoreOriginal": "תקין (תבנית ריקה משחזרת שמות קבצים מקוריים)",
|
||||
"invalidChars": "זוהו תווים לא חוקיים (שם קובץ אינו יכול להכיל / \\ < > : \" | ? *)",
|
||||
"invalidPlaceholder": "מציין מקום לא חוקי: {placeholder}",
|
||||
"validTemplate": "תבנית תקינה"
|
||||
}
|
||||
},
|
||||
"exampleImages": {
|
||||
"downloadLocation": "מיקום הורדה",
|
||||
"downloadLocationPlaceholder": "הזן נתיב תיקייה לתמונות דוגמה",
|
||||
@@ -912,6 +935,14 @@
|
||||
"complete": "ארגון אוטומטי הושלם",
|
||||
"error": "שגיאה: {error}"
|
||||
},
|
||||
"filenameTemplateProgress": {
|
||||
"initializing": "מאתחל החלת תבנית שם קובץ...",
|
||||
"starting": "מחיל תבנית שם קובץ על {type}...",
|
||||
"processing": "מעבד ({processed}/{total}) - {success} שונו שמותם, {skipped} דולגו, {failures} נכשלו",
|
||||
"completed": "הושלם: {success} שונו שמותם, {skipped} דולגו, {failures} נכשלו",
|
||||
"complete": "החלת תבנית שם הקובץ הושלמה",
|
||||
"error": "שגיאה: {error}"
|
||||
},
|
||||
"enrichHfAgent": "העשרת מטא-נתונים ב-AI"
|
||||
},
|
||||
"contextMenu": {
|
||||
@@ -1601,6 +1632,11 @@
|
||||
"tip": "רוצים לחלק למנות קטנות? עברו למצב בכמות גדולה, בחרו את המודלים הדרושים ואז השתמשו ב\"בדוק עדכונים לנבחרים\".",
|
||||
"action": "בדוק הכל"
|
||||
},
|
||||
"filenameTemplateConfirm": {
|
||||
"titleApply": "להחיל תבנית שם קובץ על הספרייה?",
|
||||
"titleRevert": "לשחזר שמות קבצים מקוריים?",
|
||||
"revertButton": "שחזר שמות קבצים מקוריים"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "הוסף תגיות למספר מודלים",
|
||||
"description": "הוסף תגיות ל-",
|
||||
@@ -2310,6 +2346,9 @@
|
||||
"autoOrganizeSuccess": "הארגון האוטומטי הושלם בהצלחה עבור {count} {type}",
|
||||
"autoOrganizePartialSuccess": "הארגון האוטומטי הושלם עם {success} שהועברו, {failures} שנכשלו מתוך {total} מודלים",
|
||||
"autoOrganizeFailed": "הארגון האוטומטי נכשל: {error}",
|
||||
"filenameTemplateSuccess": "תבנית שם הקובץ הוחלה בהצלחה עבור {count} {type}",
|
||||
"filenameTemplatePartialSuccess": "החלת תבנית שם הקובץ הושלמה עם {success} ששונה שמם, {failures} שנכשלו מתוך {total} מודלים",
|
||||
"filenameTemplateFailed": "החלת תבנית שם הקובץ נכשלה: {error}",
|
||||
"noModelsSelected": "לא נבחרו מודלים"
|
||||
},
|
||||
"recipes": {
|
||||
@@ -2476,6 +2515,8 @@
|
||||
"mappingSaveFailed": "שמירת מיפויי מודל בסיס נכשלה: {message}",
|
||||
"downloadTemplatesUpdated": "תבניות נתיב הורדה עודכנו",
|
||||
"downloadTemplatesFailed": "שמירת תבניות נתיב הורדה נכשלה: {message}",
|
||||
"filenameTemplatesUpdated": "תבניות שמות הקבצים עודכנו",
|
||||
"filenameTemplatesFailed": "שמירת תבניות שמות הקבצים נכשלה: {message}",
|
||||
"recipesPathUpdated": "נתיב אחסון המתכונים עודכן",
|
||||
"recipesPathSaveFailed": "עדכון נתיב אחסון המתכונים נכשל: {message}",
|
||||
"settingsUpdated": "הגדרות עודכנו: {setting}",
|
||||
|
||||
@@ -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 サイトを選択します。",
|
||||
@@ -383,6 +389,7 @@
|
||||
"general": "一般",
|
||||
"interface": "インターフェース",
|
||||
"library": "ライブラリ",
|
||||
"organization": "整理",
|
||||
"modelPaths": "モデルパス"
|
||||
},
|
||||
"search": {
|
||||
@@ -680,6 +687,22 @@
|
||||
"validTemplate": "有効なテンプレート"
|
||||
}
|
||||
},
|
||||
"filenameTemplates": {
|
||||
"title": "ファイル名テンプレート",
|
||||
"help": "ダウンロードしたモデルのファイル名をモデルタイプごとに設定します。空欄にするとダウンロード時は元のファイル名が保持され、空のテンプレートを適用すると以前にリネームされたモデルの記録済みの元のファイル名が復元されます。元のファイル名は常にモデルのメタデータに保持されます。",
|
||||
"availablePlaceholders": "利用可能なプレースホルダー:",
|
||||
"templatePlaceholder": "ファイル名テンプレートを入力(例:{base_model}-{model_name}-{version_name})",
|
||||
"applyButton": "ライブラリに今すぐ適用",
|
||||
"applyHelp": "このモデルタイプの既存のすべてのファイルをテンプレートに従ってリネームします。空のテンプレートの場合は、代わりに記録済みの元のファイル名を復元します。警告:リネームするとComfyUIローダーから見える相対パスが変わるため、古いファイル名を参照する既存のワークフローは更新が必要になる場合があります。元のファイル名は各モデルのメタデータに保持されます。",
|
||||
"confirmApply": "このモデルタイプの既存のすべてのファイルをファイル名テンプレートに従ってリネームしますか?ComfyUIローダーから見える相対パスが変わります。元のファイル名は各モデルのメタデータに保持されます。",
|
||||
"confirmRevert": "このモデルタイプの以前にリネームされたすべてのファイルについて、記録済みの元のファイル名を復元しますか?ComfyUIローダーから見える相対パスが変わります。記録済みの元のファイル名がないファイルはスキップされます。",
|
||||
"validation": {
|
||||
"restoreOriginal": "有効(空のテンプレートは元のファイル名を復元)",
|
||||
"invalidChars": "無効な文字が検出されました(ファイル名に / \\ < > : \" | ? * は使用できません)",
|
||||
"invalidPlaceholder": "無効なプレースホルダー:{placeholder}",
|
||||
"validTemplate": "有効なテンプレート"
|
||||
}
|
||||
},
|
||||
"exampleImages": {
|
||||
"downloadLocation": "ダウンロード場所",
|
||||
"downloadLocationPlaceholder": "例画像のフォルダパスを入力",
|
||||
@@ -912,6 +935,14 @@
|
||||
"complete": "自動整理が完了しました",
|
||||
"error": "エラー:{error}"
|
||||
},
|
||||
"filenameTemplateProgress": {
|
||||
"initializing": "ファイル名テンプレートの適用を初期化中...",
|
||||
"starting": "{type}にファイル名テンプレートを適用中...",
|
||||
"processing": "処理中({processed}/{total})- {success} リネーム、{skipped} スキップ、{failures} 失敗",
|
||||
"completed": "完了:{success} リネーム、{skipped} スキップ、{failures} 失敗",
|
||||
"complete": "ファイル名テンプレートの適用が完了しました",
|
||||
"error": "エラー:{error}"
|
||||
},
|
||||
"enrichHfAgent": "メタデータをAIで補完"
|
||||
},
|
||||
"contextMenu": {
|
||||
@@ -1601,6 +1632,11 @@
|
||||
"tip": "少しずつ確認したい場合は一括モードに切り替え、必要なモデルを選んで「選択項目の更新を確認」を使ってください。",
|
||||
"action": "すべて確認"
|
||||
},
|
||||
"filenameTemplateConfirm": {
|
||||
"titleApply": "ファイル名テンプレートをライブラリに適用しますか?",
|
||||
"titleRevert": "元のファイル名を復元しますか?",
|
||||
"revertButton": "元のファイル名を復元"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "複数モデルにタグを追加",
|
||||
"description": "タグを追加するモデル:",
|
||||
@@ -2310,6 +2346,9 @@
|
||||
"autoOrganizeSuccess": "{count} {type} の自動整理が正常に完了しました",
|
||||
"autoOrganizePartialSuccess": "自動整理が完了しました:{total} モデル中 {success} 移動、{failures} 失敗",
|
||||
"autoOrganizeFailed": "自動整理に失敗しました:{error}",
|
||||
"filenameTemplateSuccess": "{count} 件の{type}にファイル名テンプレートを正常に適用しました",
|
||||
"filenameTemplatePartialSuccess": "ファイル名テンプレートを適用しました:{total} 件中 {success} 件をリネーム、{failures} 件失敗",
|
||||
"filenameTemplateFailed": "ファイル名テンプレートの適用に失敗しました:{error}",
|
||||
"noModelsSelected": "モデルが選択されていません"
|
||||
},
|
||||
"recipes": {
|
||||
@@ -2476,6 +2515,8 @@
|
||||
"mappingSaveFailed": "ベースモデルマッピングの保存に失敗しました:{message}",
|
||||
"downloadTemplatesUpdated": "ダウンロードパステンプレートが更新されました",
|
||||
"downloadTemplatesFailed": "ダウンロードパステンプレートの保存に失敗しました:{message}",
|
||||
"filenameTemplatesUpdated": "ファイル名テンプレートを更新しました",
|
||||
"filenameTemplatesFailed": "ファイル名テンプレートの保存に失敗しました:{message}",
|
||||
"recipesPathUpdated": "レシピ保存先を更新しました",
|
||||
"recipesPathSaveFailed": "レシピ保存先の更新に失敗しました: {message}",
|
||||
"settingsUpdated": "設定が更新されました:{setting}",
|
||||
|
||||
@@ -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 사이트를 열지 선택합니다.",
|
||||
@@ -383,6 +389,7 @@
|
||||
"general": "일반",
|
||||
"interface": "인터페이스",
|
||||
"library": "라이브러리",
|
||||
"organization": "정리",
|
||||
"modelPaths": "모델 경로"
|
||||
},
|
||||
"search": {
|
||||
@@ -680,6 +687,22 @@
|
||||
"validTemplate": "유효한 템플릿"
|
||||
}
|
||||
},
|
||||
"filenameTemplates": {
|
||||
"title": "파일명 템플릿",
|
||||
"help": "모델 유형별로 다운로드되는 모델의 파일명을 구성합니다. 비워 두면 다운로드 시 원본 파일명을 유지하고, 빈 템플릿을 적용하면 이전에 이름이 변경된 모델의 기록된 원본 파일명이 복원됩니다. 원본 파일명은 항상 모델의 메타데이터에 보존됩니다.",
|
||||
"availablePlaceholders": "사용 가능한 플레이스홀더:",
|
||||
"templatePlaceholder": "파일명 템플릿 입력 (예: {base_model}-{model_name}-{version_name})",
|
||||
"applyButton": "지금 라이브러리에 적용",
|
||||
"applyHelp": "이 모델 유형의 기존 파일을 모두 템플릿에 따라 이름 변경합니다. 빈 템플릿이면 기록된 원본 파일명을 대신 복원합니다. 경고: 이름을 변경하면 ComfyUI 로더에서 보이는 상대 경로가 바뀌므로 이전 파일명을 참조하는 기존 워크플로를 업데이트해야 할 수 있습니다. 원본 파일명은 각 모델의 메타데이터에 보존됩니다.",
|
||||
"confirmApply": "이 모델 유형의 기존 파일을 모두 파일명 템플릿에 따라 이름 변경하시겠습니까? ComfyUI 로더에서 보이는 상대 경로가 변경됩니다. 원본 파일명은 각 모델의 메타데이터에 보존됩니다.",
|
||||
"confirmRevert": "이 모델 유형에서 이전에 이름이 변경된 모든 파일의 기록된 원본 파일명을 복원하시겠습니까? ComfyUI 로더에서 보이는 상대 경로가 변경됩니다. 기록된 원본 파일명이 없는 파일은 건너뜁니다.",
|
||||
"validation": {
|
||||
"restoreOriginal": "유효함 (빈 템플릿은 원본 파일명을 복원합니다)",
|
||||
"invalidChars": "잘못된 문자가 감지됨 (파일명에는 / \\ < > : \" | ? * 문자를 사용할 수 없습니다)",
|
||||
"invalidPlaceholder": "잘못된 플레이스홀더: {placeholder}",
|
||||
"validTemplate": "유효한 템플릿"
|
||||
}
|
||||
},
|
||||
"exampleImages": {
|
||||
"downloadLocation": "다운로드 위치",
|
||||
"downloadLocationPlaceholder": "예시 이미지 폴더 경로를 입력하세요",
|
||||
@@ -912,6 +935,14 @@
|
||||
"complete": "자동 정리 완료",
|
||||
"error": "오류: {error}"
|
||||
},
|
||||
"filenameTemplateProgress": {
|
||||
"initializing": "파일명 템플릿 적용 초기화 중...",
|
||||
"starting": "{type}에 파일명 템플릿 적용 중...",
|
||||
"processing": "처리 중 ({processed}/{total}) - {success}개 이름 변경, {skipped}개 건너뜀, {failures}개 실패",
|
||||
"completed": "완료: {success}개 이름 변경, {skipped}개 건너뜀, {failures}개 실패",
|
||||
"complete": "파일명 템플릿 적용 완료",
|
||||
"error": "오류: {error}"
|
||||
},
|
||||
"enrichHfAgent": "AI로 메타데이터 보강"
|
||||
},
|
||||
"contextMenu": {
|
||||
@@ -1601,6 +1632,11 @@
|
||||
"tip": "나눠서 진행하고 싶다면 일괄 모드로 전환해 필요한 모델만 선택한 뒤 \"선택 항목 업데이트 확인\"을 사용하세요.",
|
||||
"action": "전체 확인"
|
||||
},
|
||||
"filenameTemplateConfirm": {
|
||||
"titleApply": "라이브러리에 파일명 템플릿을 적용하시겠습니까?",
|
||||
"titleRevert": "원본 파일명을 복원하시겠습니까?",
|
||||
"revertButton": "원본 파일명 복원"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "여러 모델에 태그 추가",
|
||||
"description": "다음에 태그를 추가합니다:",
|
||||
@@ -2310,6 +2346,9 @@
|
||||
"autoOrganizeSuccess": "{count}개의 {type}에 대해 자동 정리가 성공적으로 완료되었습니다",
|
||||
"autoOrganizePartialSuccess": "자동 정리 완료: 전체 {total}개 중 {success}개 이동, {failures}개 실패",
|
||||
"autoOrganizeFailed": "자동 정리 실패: {error}",
|
||||
"filenameTemplateSuccess": "{count}개의 {type}에 파일명 템플릿이 성공적으로 적용되었습니다",
|
||||
"filenameTemplatePartialSuccess": "파일명 템플릿 적용 완료: 전체 {total}개 중 {success}개 이름 변경, {failures}개 실패",
|
||||
"filenameTemplateFailed": "파일명 템플릿 적용 실패: {error}",
|
||||
"noModelsSelected": "선택된 모델이 없습니다"
|
||||
},
|
||||
"recipes": {
|
||||
@@ -2476,6 +2515,8 @@
|
||||
"mappingSaveFailed": "베이스 모델 매핑 저장 실패: {message}",
|
||||
"downloadTemplatesUpdated": "다운로드 경로 템플릿이 업데이트되었습니다",
|
||||
"downloadTemplatesFailed": "다운로드 경로 템플릿 저장 실패: {message}",
|
||||
"filenameTemplatesUpdated": "파일명 템플릿이 업데이트되었습니다",
|
||||
"filenameTemplatesFailed": "파일명 템플릿 저장 실패: {message}",
|
||||
"recipesPathUpdated": "레시피 저장 경로가 업데이트되었습니다",
|
||||
"recipesPathSaveFailed": "레시피 저장 경로 업데이트 실패: {message}",
|
||||
"settingsUpdated": "설정 업데이트됨: {setting}",
|
||||
|
||||
@@ -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».",
|
||||
@@ -383,6 +389,7 @@
|
||||
"general": "Общее",
|
||||
"interface": "Интерфейс",
|
||||
"library": "Библиотека",
|
||||
"organization": "Организация",
|
||||
"modelPaths": "Пути к моделям"
|
||||
},
|
||||
"search": {
|
||||
@@ -680,6 +687,22 @@
|
||||
"validTemplate": "Действительный шаблон"
|
||||
}
|
||||
},
|
||||
"filenameTemplates": {
|
||||
"title": "Шаблоны имён файлов",
|
||||
"help": "Настройте имена файлов загружаемых моделей для каждого типа моделей. Оставьте пустым, чтобы сохранять исходные имена файлов при загрузке; применение пустого шаблона восстанавливает записанные исходные имена файлов ранее переименованных моделей. Исходное имя файла всегда сохраняется в метаданных модели.",
|
||||
"availablePlaceholders": "Доступные заполнители:",
|
||||
"templatePlaceholder": "Введите шаблон имени файла (например, {base_model}-{model_name}-{version_name})",
|
||||
"applyButton": "Применить к библиотеке сейчас",
|
||||
"applyHelp": "Переименовывает все существующие файлы этого типа моделей согласно шаблону; при пустом шаблоне вместо этого восстанавливает записанные исходные имена файлов. Предупреждение: переименование меняет относительный путь, который видят загрузчики ComfyUI, поэтому существующие workflow, ссылающиеся на старое имя файла, может потребоваться обновить. Исходное имя файла сохраняется в метаданных каждой модели.",
|
||||
"confirmApply": "Переименовать все существующие файлы этого типа моделей согласно шаблону имён файлов? Это меняет относительный путь, который видят загрузчики ComfyUI. Исходное имя файла сохраняется в метаданных каждой модели.",
|
||||
"confirmRevert": "Восстановить записанные исходные имена файлов всех ранее переименованных файлов этого типа моделей? Это меняет относительный путь, который видят загрузчики ComfyUI. Файлы без записанного исходного имени файла пропускаются.",
|
||||
"validation": {
|
||||
"restoreOriginal": "Действительный (пустой шаблон восстанавливает исходные имена файлов)",
|
||||
"invalidChars": "Обнаружены недопустимые символы (имя файла не может содержать / \\ < > : \" | ? *)",
|
||||
"invalidPlaceholder": "Недопустимый заполнитель: {placeholder}",
|
||||
"validTemplate": "Действительный шаблон"
|
||||
}
|
||||
},
|
||||
"exampleImages": {
|
||||
"downloadLocation": "Место загрузки",
|
||||
"downloadLocationPlaceholder": "Введите путь к папке для примеров изображений",
|
||||
@@ -912,6 +935,14 @@
|
||||
"complete": "Автоматическая организация завершена",
|
||||
"error": "Ошибка: {error}"
|
||||
},
|
||||
"filenameTemplateProgress": {
|
||||
"initializing": "Инициализация применения шаблона имён файлов...",
|
||||
"starting": "Применение шаблона имён файлов к {type}...",
|
||||
"processing": "Обработка ({processed}/{total}) — {success} переименовано, {skipped} пропущено, {failures} не удалось",
|
||||
"completed": "Завершено: {success} переименовано, {skipped} пропущено, {failures} не удалось",
|
||||
"complete": "Применение шаблона имён файлов завершено",
|
||||
"error": "Ошибка: {error}"
|
||||
},
|
||||
"enrichHfAgent": "Обогатить метаданные с помощью ИИ"
|
||||
},
|
||||
"contextMenu": {
|
||||
@@ -1601,6 +1632,11 @@
|
||||
"tip": "Хотите проверять по частям? Переключитесь в массовый режим, выберите нужные модели и используйте \"Проверить обновления для выбранных\".",
|
||||
"action": "Проверить всё"
|
||||
},
|
||||
"filenameTemplateConfirm": {
|
||||
"titleApply": "Применить шаблон имён файлов к библиотеке?",
|
||||
"titleRevert": "Восстановить исходные имена файлов?",
|
||||
"revertButton": "Восстановить исходные имена файлов"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Добавить теги к нескольким моделям",
|
||||
"description": "Добавить теги к",
|
||||
@@ -2310,6 +2346,9 @@
|
||||
"autoOrganizeSuccess": "Автоматическая организация успешно завершена для {count} {type}",
|
||||
"autoOrganizePartialSuccess": "Автоматическая организация завершена: перемещено {success}, не удалось {failures} из {total} моделей",
|
||||
"autoOrganizeFailed": "Ошибка автоматической организации: {error}",
|
||||
"filenameTemplateSuccess": "Шаблон имён файлов успешно применён для {count} {type}",
|
||||
"filenameTemplatePartialSuccess": "Шаблон имён файлов применён: переименовано {success}, не удалось {failures} из {total} моделей",
|
||||
"filenameTemplateFailed": "Не удалось применить шаблон имён файлов: {error}",
|
||||
"noModelsSelected": "Модели не выбраны"
|
||||
},
|
||||
"recipes": {
|
||||
@@ -2476,6 +2515,8 @@
|
||||
"mappingSaveFailed": "Не удалось сохранить сопоставления базовых моделей: {message}",
|
||||
"downloadTemplatesUpdated": "Шаблоны путей загрузки обновлены",
|
||||
"downloadTemplatesFailed": "Не удалось сохранить шаблоны путей загрузки: {message}",
|
||||
"filenameTemplatesUpdated": "Шаблоны имён файлов обновлены",
|
||||
"filenameTemplatesFailed": "Не удалось сохранить шаблоны имён файлов: {message}",
|
||||
"recipesPathUpdated": "Путь хранения рецептов обновлён",
|
||||
"recipesPathSaveFailed": "Не удалось обновить путь хранения рецептов: {message}",
|
||||
"settingsUpdated": "Настройки обновлены: {setting}",
|
||||
|
||||
@@ -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 站点。",
|
||||
@@ -383,6 +389,7 @@
|
||||
"general": "通用",
|
||||
"interface": "界面",
|
||||
"library": "库",
|
||||
"organization": "整理",
|
||||
"modelPaths": "模型路径"
|
||||
},
|
||||
"search": {
|
||||
@@ -680,6 +687,22 @@
|
||||
"validTemplate": "有效模板"
|
||||
}
|
||||
},
|
||||
"filenameTemplates": {
|
||||
"title": "文件名模板",
|
||||
"help": "按模型类型配置下载模型的文件名。留空则下载时保留原始文件名;应用空模板会恢复此前被重命名模型所记录的原始文件名。原始文件名始终保留在模型的元数据中。",
|
||||
"availablePlaceholders": "可用占位符:",
|
||||
"templatePlaceholder": "输入文件名模板(如:{base_model}-{model_name}-{version_name})",
|
||||
"applyButton": "立即应用到库",
|
||||
"applyHelp": "根据模板重命名此模型类型的所有现有文件;模板为空时则恢复已记录的原始文件名。警告:重命名会改变 ComfyUI 加载器所见的相对路径,因此引用旧文件名的现有工作流可能需要更新。原始文件名保留在每个模型的元数据中。",
|
||||
"confirmApply": "要根据文件名模板重命名此模型类型的所有现有文件吗?这会改变 ComfyUI 加载器所见的相对路径。原始文件名保留在每个模型的元数据中。",
|
||||
"confirmRevert": "要恢复此模型类型中所有此前被重命名文件所记录的原始文件名吗?这会改变 ComfyUI 加载器所见的相对路径。未记录原始文件名的文件将被跳过。",
|
||||
"validation": {
|
||||
"restoreOriginal": "有效(空模板将恢复原始文件名)",
|
||||
"invalidChars": "检测到无效字符(文件名不能包含 / \\ < > : \" | ? *)",
|
||||
"invalidPlaceholder": "无效占位符:{placeholder}",
|
||||
"validTemplate": "有效模板"
|
||||
}
|
||||
},
|
||||
"exampleImages": {
|
||||
"downloadLocation": "下载位置",
|
||||
"downloadLocationPlaceholder": "输入示例图片文件夹路径",
|
||||
@@ -912,6 +935,14 @@
|
||||
"complete": "自动整理已完成",
|
||||
"error": "错误:{error}"
|
||||
},
|
||||
"filenameTemplateProgress": {
|
||||
"initializing": "正在初始化应用文件名模板...",
|
||||
"starting": "正在为 {type} 应用文件名模板...",
|
||||
"processing": "处理中({processed}/{total})- 已重命名 {success} 个,跳过 {skipped} 个,失败 {failures} 个",
|
||||
"completed": "完成:已重命名 {success} 个,跳过 {skipped} 个,失败 {failures} 个",
|
||||
"complete": "文件名模板应用完成",
|
||||
"error": "错误:{error}"
|
||||
},
|
||||
"enrichHfAgent": "AI 元数据增强"
|
||||
},
|
||||
"contextMenu": {
|
||||
@@ -1601,6 +1632,11 @@
|
||||
"tip": "想分批进行?切换到批量模式,选中需要的模型,然后使用“检查所选更新”。",
|
||||
"action": "检查全部"
|
||||
},
|
||||
"filenameTemplateConfirm": {
|
||||
"titleApply": "将文件名模板应用到库?",
|
||||
"titleRevert": "恢复原始文件名?",
|
||||
"revertButton": "恢复原始文件名"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "批量添加标签",
|
||||
"description": "为多个模型添加标签",
|
||||
@@ -2310,6 +2346,9 @@
|
||||
"autoOrganizeSuccess": "自动整理已成功完成,共 {count} 个 {type}",
|
||||
"autoOrganizePartialSuccess": "自动整理完成:已移动 {success} 个,{failures} 个失败,共 {total} 个模型",
|
||||
"autoOrganizeFailed": "自动整理失败:{error}",
|
||||
"filenameTemplateSuccess": "文件名模板已成功应用,共 {count} 个 {type}",
|
||||
"filenameTemplatePartialSuccess": "文件名模板应用完成:已重命名 {success} 个,{failures} 个失败,共 {total} 个模型",
|
||||
"filenameTemplateFailed": "应用文件名模板失败:{error}",
|
||||
"noModelsSelected": "未选中模型"
|
||||
},
|
||||
"recipes": {
|
||||
@@ -2476,6 +2515,8 @@
|
||||
"mappingSaveFailed": "保存基础模型映射失败:{message}",
|
||||
"downloadTemplatesUpdated": "下载路径模板已更新",
|
||||
"downloadTemplatesFailed": "保存下载路径模板失败:{message}",
|
||||
"filenameTemplatesUpdated": "文件名模板已更新",
|
||||
"filenameTemplatesFailed": "保存文件名模板失败:{message}",
|
||||
"recipesPathUpdated": "配方存储路径已更新",
|
||||
"recipesPathSaveFailed": "更新配方存储路径失败:{message}",
|
||||
"settingsUpdated": "设置已更新:{setting}",
|
||||
|
||||
@@ -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 站點。",
|
||||
@@ -383,6 +389,7 @@
|
||||
"general": "通用",
|
||||
"interface": "介面",
|
||||
"library": "模型庫",
|
||||
"organization": "整理",
|
||||
"modelPaths": "模型路徑"
|
||||
},
|
||||
"search": {
|
||||
@@ -680,6 +687,22 @@
|
||||
"validTemplate": "範本有效"
|
||||
}
|
||||
},
|
||||
"filenameTemplates": {
|
||||
"title": "檔案名稱範本",
|
||||
"help": "依模型類型設定已下載模型的檔案名稱。留空則下載時保留原始檔案名稱;套用空範本會還原先前已重新命名模型所記錄的原始檔案名稱。原始檔案名稱一律會保存在模型的中繼資料中。",
|
||||
"availablePlaceholders": "可用佔位符:",
|
||||
"templatePlaceholder": "輸入檔案名稱範本(例如:{base_model}-{model_name}-{version_name})",
|
||||
"applyButton": "立即套用至模型庫",
|
||||
"applyHelp": "依範本重新命名此模型類型的所有現有檔案;若範本為空,則改為還原已記錄的原始檔案名稱。警告:重新命名會變更 ComfyUI 載入器所見的相對路徑,因此參照舊檔案名稱的現有工作流可能需要更新。原始檔案名稱會保存在每個模型的中繼資料中。",
|
||||
"confirmApply": "要依檔案名稱範本重新命名此模型類型的所有現有檔案嗎?這會變更 ComfyUI 載入器所見的相對路徑。原始檔案名稱會保存在每個模型的中繼資料中。",
|
||||
"confirmRevert": "要將此模型類型所有先前已重新命名的檔案還原為已記錄的原始檔案名稱嗎?這會變更 ComfyUI 載入器所見的相對路徑。沒有記錄原始檔案名稱的檔案將被略過。",
|
||||
"validation": {
|
||||
"restoreOriginal": "有效(空範本會還原原始檔案名稱)",
|
||||
"invalidChars": "偵測到無效字元(檔案名稱不能包含 / \\ < > : \" | ? *)",
|
||||
"invalidPlaceholder": "無效佔位符:{placeholder}",
|
||||
"validTemplate": "範本有效"
|
||||
}
|
||||
},
|
||||
"exampleImages": {
|
||||
"downloadLocation": "下載位置",
|
||||
"downloadLocationPlaceholder": "輸入範例圖片的資料夾路徑",
|
||||
@@ -912,6 +935,14 @@
|
||||
"complete": "自動整理完成",
|
||||
"error": "錯誤:{error}"
|
||||
},
|
||||
"filenameTemplateProgress": {
|
||||
"initializing": "正在初始化檔案名稱範本套用...",
|
||||
"starting": "正在將檔案名稱範本套用至 {type}...",
|
||||
"processing": "處理中({processed}/{total})- 已重新命名 {success},已略過 {skipped},失敗 {failures}",
|
||||
"completed": "完成:已重新命名 {success},已略過 {skipped},失敗 {failures}",
|
||||
"complete": "檔案名稱範本套用完成",
|
||||
"error": "錯誤:{error}"
|
||||
},
|
||||
"enrichHfAgent": "AI 中繼資料增強"
|
||||
},
|
||||
"contextMenu": {
|
||||
@@ -1601,6 +1632,11 @@
|
||||
"tip": "想分批處理?切換到批次模式,選擇需要的模型,然後使用「檢查所選更新」。",
|
||||
"action": "全部檢查"
|
||||
},
|
||||
"filenameTemplateConfirm": {
|
||||
"titleApply": "要將檔案名稱範本套用至模型庫嗎?",
|
||||
"titleRevert": "要還原原始檔案名稱嗎?",
|
||||
"revertButton": "還原原始檔案名稱"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "新增標籤到多個模型",
|
||||
"description": "新增標籤到",
|
||||
@@ -2310,6 +2346,9 @@
|
||||
"autoOrganizeSuccess": "自動整理已成功完成,共 {count} 個 {type} 已整理",
|
||||
"autoOrganizePartialSuccess": "自動整理完成:已移動 {success} 個,{failures} 個失敗,共 {total} 個模型",
|
||||
"autoOrganizeFailed": "自動整理失敗:{error}",
|
||||
"filenameTemplateSuccess": "已成功為 {count} 個 {type} 套用檔案名稱範本",
|
||||
"filenameTemplatePartialSuccess": "檔案名稱範本套用完成:已重新命名 {success} 個,{failures} 個失敗,共 {total} 個模型",
|
||||
"filenameTemplateFailed": "套用檔案名稱範本失敗:{error}",
|
||||
"noModelsSelected": "未選擇任何模型"
|
||||
},
|
||||
"recipes": {
|
||||
@@ -2476,6 +2515,8 @@
|
||||
"mappingSaveFailed": "儲存基礎模型對應失敗:{message}",
|
||||
"downloadTemplatesUpdated": "下載路徑範本已更新",
|
||||
"downloadTemplatesFailed": "儲存下載路徑範本失敗:{message}",
|
||||
"filenameTemplatesUpdated": "檔案名稱範本已更新",
|
||||
"filenameTemplatesFailed": "儲存檔案名稱範本失敗:{message}",
|
||||
"recipesPathUpdated": "配方儲存路徑已更新",
|
||||
"recipesPathSaveFailed": "更新配方儲存路徑失敗:{message}",
|
||||
"settingsUpdated": "設定已更新:{setting}",
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
"""Load an image and expose locally resolved generation settings."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import folder_paths # pyright: ignore[reportMissingImports]
|
||||
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
from ..utils.generation_metadata import (
|
||||
GenerationMetadata,
|
||||
MetadataError,
|
||||
extract_generation_metadata,
|
||||
finite_number,
|
||||
split_lora_tags,
|
||||
)
|
||||
from ..utils.utils import _format_model_name_for_comfyui
|
||||
from .checkpoint_loader import CheckpointLoaderLM
|
||||
|
||||
|
||||
DEFAULTS = {
|
||||
"positive": "", "negative": "", "seed": 0, "steps": 20, "cfg": 7.0,
|
||||
"sampler_name": "euler", "scheduler": "normal", "denoise": 1.0,
|
||||
}
|
||||
# An SDXL-sized starter preset inspired by ComfyUI's bottle example. These
|
||||
# values are explicitly synthetic, never presented as recovered metadata.
|
||||
EMPTY_IMAGE_DEFAULTS = {
|
||||
**DEFAULTS,
|
||||
"positive": "beautiful scenery inside a glass bottle, purple galaxy, intricate miniature landscape, highly detailed",
|
||||
"negative": "text, watermark",
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
}
|
||||
ALLOWED_OVERRIDES = set(DEFAULTS) | {"model_name", "checkpoint_name", "unet_name", "width", "height", "loras"}
|
||||
|
||||
|
||||
def parse_overrides(text: str) -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(text or "{}")
|
||||
except ValueError as exc:
|
||||
raise MetadataError(f"Invalid overrides_json: {exc}") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise MetadataError("overrides_json must be an object")
|
||||
unknown = set(value) - ALLOWED_OVERRIDES
|
||||
if unknown:
|
||||
raise MetadataError(f"Unknown override keys: {', '.join(sorted(unknown))}")
|
||||
model_keys = [key for key in ("model_name", "checkpoint_name", "unet_name") if key in value]
|
||||
if len(model_keys) > 1:
|
||||
raise MetadataError("Specify only one model_name override (checkpoint_name/unet_name are legacy aliases)")
|
||||
if model_keys:
|
||||
key = model_keys[0]
|
||||
name = value.pop(key)
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
raise MetadataError("model_name override must be nonempty text")
|
||||
value["model_name"] = name.strip()
|
||||
return value
|
||||
|
||||
|
||||
_MODEL_FILE_EXTENSIONS = (".safetensors", ".ckpt", ".pt", ".pth", ".bin", ".gguf")
|
||||
|
||||
|
||||
def _model_stem(name: str) -> str:
|
||||
"""Remove a known file extension, retaining dots in model/version names."""
|
||||
for extension in _MODEL_FILE_EXTENSIONS:
|
||||
if name.lower().endswith(extension):
|
||||
return name[:-len(extension)]
|
||||
return name
|
||||
|
||||
|
||||
def resolve_resource(name: str, resources: list[dict[str, Any]], roots: list[str]) -> dict[str, Any]:
|
||||
"""Match paths, filenames, then exact catalog aliases; never fuzzy-match."""
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
raise MetadataError("Missing model name")
|
||||
normalized = name.strip().replace("\\", "/")
|
||||
levels: list[list[dict[str, Any]]] = [[], [], [], []]
|
||||
for item in resources:
|
||||
file_path = item.get("file_path")
|
||||
if not file_path:
|
||||
continue
|
||||
path = file_path.replace("\\", "/")
|
||||
relative = _format_model_name_for_comfyui(file_path, roots).replace("\\", "/")
|
||||
exact = normalized in (path, relative, _model_stem(path), _model_stem(relative))
|
||||
basename = normalized.rsplit("/", 1)[-1] == path.rsplit("/", 1)[-1]
|
||||
stem = _model_stem(normalized.rsplit("/", 1)[-1]) == _model_stem(path.rsplit("/", 1)[-1])
|
||||
aliases = [item.get("file_name"), item.get("model_name")]
|
||||
alias = any(
|
||||
isinstance(value, str) and normalized in (value.strip(), _model_stem(value.strip()))
|
||||
for value in aliases
|
||||
)
|
||||
# Stat only plausible matches, not every file in a large library for
|
||||
# each LoRA. Missing cached files must never win a match.
|
||||
if not (exact or basename or stem or alias) or not os.path.isfile(file_path):
|
||||
continue
|
||||
if exact:
|
||||
levels[0].append(item)
|
||||
if basename:
|
||||
levels[1].append(item)
|
||||
if stem:
|
||||
levels[2].append(item)
|
||||
if alias:
|
||||
levels[3].append(item)
|
||||
for matches in levels:
|
||||
unique = {os.path.abspath(item["file_path"]): item for item in matches}
|
||||
if len(unique) == 1:
|
||||
return next(iter(unique.values()))
|
||||
if unique:
|
||||
raise MetadataError(f"Ambiguous local model '{name}': {', '.join(unique)}. Specify its relative path in overrides_json.")
|
||||
raise MetadataError(f"Model '{name}' could not be matched to an existing file in the local LoRA Manager catalog")
|
||||
|
||||
|
||||
class LoadImageMetadataLM:
|
||||
NAME = "Load Image Metadata (LoraManager)"
|
||||
CATEGORY = "Lora Manager/loaders"
|
||||
DESCRIPTION = (
|
||||
"Load an image and recover prompts, LoRAs and sampling settings from its metadata. "
|
||||
"Connect lora_stack to Lora Loader. Convert loader/sampler widgets to inputs for the other outputs. "
|
||||
"Extraction failures use starter defaults and are shown as ERROR messages in readable_report."
|
||||
)
|
||||
RETURN_TYPES = (
|
||||
"IMAGE", "MASK", "STRING", "STRING", "COMBO", "LORA_STACK", "STRING",
|
||||
"INT", "INT", "FLOAT", "COMBO", "COMBO", "INT", "INT", "FLOAT", "STRING", "STRING", "STRING",
|
||||
)
|
||||
RETURN_NAMES = (
|
||||
"image", "mask", "positive", "negative", "model_name", "lora_stack", "lora_stack_text",
|
||||
"seed", "steps", "cfg", "sampler_name", "scheduler", "width", "height", "denoise", "report", "readable_report", "missing_files",
|
||||
)
|
||||
FUNCTION = "load_metadata"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> dict[str, Any]:
|
||||
from nodes import LoadImage # pyright: ignore[reportMissingImports]
|
||||
|
||||
return {"required": {
|
||||
"image": LoadImage.INPUT_TYPES()["required"]["image"],
|
||||
"sampler_node_id": ("STRING", {"default": "", "tooltip": "Leave empty for a single sampler. Subgraphs: use the full API ID, e.g. 1481:1783 (or 1481/1783). A container or leaf ID works only when unique."}),
|
||||
"missing_settings": (["use_defaults", "strict"], {"tooltip": "Extraction errors always return defaults and an ERROR report, including for saved strict settings. Unresolved files are listed in missing_files."}),
|
||||
"overrides_json": ("STRING", {"default": "{}", "multiline": True, "dynamicPrompts": False, "tooltip": 'Explicit replacements, e.g. {"scheduler":"normal", "model_name":"folder/model.safetensors"}. Use "loras": [] to clear the recovered stack.'}),
|
||||
"prefer_saved_image_metadata": ("BOOLEAN", {"default": True, "tooltip": "Prefer saved A1111-style generation parameters. Disable to select an active workflow sampler; muted/bypassed samplers are excluded."}),
|
||||
}}
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, image: str, **kwargs: Any) -> bool | str:
|
||||
if not folder_paths.exists_annotated_filepath(image):
|
||||
return f"Invalid image file: {image}"
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, image: str, **kwargs: Any) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with open(folder_paths.get_annotated_filepath(image), "rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _source_diagnostics(path: str) -> str:
|
||||
"""Describe the actual selected file without including prompt contents."""
|
||||
from PIL import Image
|
||||
|
||||
try:
|
||||
with Image.open(path) as source:
|
||||
if source.format == "PNG":
|
||||
source.load()
|
||||
details = (
|
||||
f"File: {path}\nFormat: {source.format}; "
|
||||
f"size: {os.path.getsize(path)} bytes; "
|
||||
f"metadata keys: {', '.join(sorted(source.info)) or '(none)'}"
|
||||
)
|
||||
return details
|
||||
except (OSError, ValueError) as exc:
|
||||
return f"File: {path}\nCould not inspect image metadata: {exc}"
|
||||
|
||||
@staticmethod
|
||||
def _library() -> tuple[list[dict[str, Any]], list[str], list[dict[str, Any]], list[str]]:
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
|
||||
async def snapshot() -> tuple[list[dict[str, Any]], list[str], list[dict[str, Any]], list[str]]:
|
||||
models = await ServiceRegistry.get_checkpoint_scanner()
|
||||
loras = await ServiceRegistry.get_lora_scanner()
|
||||
model_cache = await models.get_cached_data()
|
||||
lora_cache = await loras.get_cached_data()
|
||||
return list(model_cache.raw_data), models.get_model_roots(), list(lora_cache.raw_data), loras.get_model_roots()
|
||||
|
||||
return CheckpointLoaderLM._run_async(snapshot)
|
||||
|
||||
def load_metadata(
|
||||
self, image: str, sampler_node_id: str = "", missing_settings: str = "use_defaults",
|
||||
overrides_json: str = "{}", prefer_saved_image_metadata: bool = True,
|
||||
) -> tuple[Any, ...]:
|
||||
import comfy.samplers # pyright: ignore[reportMissingImports]
|
||||
from nodes import LoadImage # pyright: ignore[reportMissingImports]
|
||||
|
||||
overrides = parse_overrides(overrides_json)
|
||||
if missing_settings not in ("strict", "use_defaults"):
|
||||
raise MetadataError("Invalid missing_settings policy")
|
||||
path = folder_paths.get_annotated_filepath(image)
|
||||
pixels, mask = LoadImage().load_image(image)
|
||||
fields = {}
|
||||
no_metadata = False
|
||||
try:
|
||||
fields = ExifUtils._load_structured_metadata(path)
|
||||
no_metadata = not any(fields.values())
|
||||
if no_metadata:
|
||||
extracted = GenerationMetadata(
|
||||
values=dict(EMPTY_IMAGE_DEFAULTS),
|
||||
notes=[
|
||||
"ERROR: No generation metadata found. Using the SDXL bottle starter preset; these settings were not extracted from the image.",
|
||||
self._source_diagnostics(path),
|
||||
],
|
||||
)
|
||||
else:
|
||||
extracted = extract_generation_metadata(fields, sampler_node_id, prefer_saved_image_metadata)
|
||||
except (ValueError, TypeError, KeyError, OSError, RecursionError) as exc:
|
||||
error = f"ERROR: Metadata extraction failed: {exc}"
|
||||
extracted = GenerationMetadata(issues={"source": str(exc)})
|
||||
# An unsupported API graph need not make valid saved generation
|
||||
# parameters unusable. Do not execute or infer custom graph nodes.
|
||||
if (fields.get("prompt") or fields.get("workflow")) and (fields.get("parameters") or fields.get("comment")):
|
||||
try:
|
||||
extracted = extract_generation_metadata({
|
||||
"parameters": fields.get("parameters"), "comment": fields.get("comment"),
|
||||
})
|
||||
extracted.notes.append(error + "; recovered saved generation parameters instead.")
|
||||
if sampler_node_id.strip():
|
||||
extracted.notes.append("ERROR: Global saved parameters cannot verify the requested sampler stage; they are an image-level fallback.")
|
||||
except (ValueError, TypeError, KeyError, RecursionError) as fallback_exc:
|
||||
extracted.notes.append(f"ERROR: Parameter fallback failed: {fallback_exc}")
|
||||
if "source" in extracted.issues:
|
||||
extracted.notes.extend([error, self._source_diagnostics(path)])
|
||||
source_resources = {"checkpoint_name": extracted.values.get("checkpoint_name"), "unet_name": extracted.values.get("unet_name"), "loras": list(extracted.loras), "resource_hints": extracted.resource_hints}
|
||||
values = extracted.values
|
||||
notes = extracted.notes
|
||||
for key, value in overrides.items():
|
||||
values[key] = value
|
||||
extracted.issues.pop(key, None)
|
||||
notes.append(f"Explicit override: {key}.")
|
||||
if "model_name" in overrides:
|
||||
extracted.issues.pop("model", None)
|
||||
values.pop("checkpoint_name", None)
|
||||
values.pop("unet_name", None)
|
||||
if "loras" in overrides:
|
||||
extracted.loras = self._override_loras(overrides["loras"])
|
||||
notes.extend(f"ERROR: {key}: {message}" for key, message in extracted.issues.items())
|
||||
# Discard incomplete graph results instead of outputting half a LoRA
|
||||
# chain or a prompt known to differ from its conditioning.
|
||||
for key in extracted.issues:
|
||||
if key not in overrides:
|
||||
values.pop(key, None)
|
||||
if "loras" in extracted.issues and "loras" not in overrides:
|
||||
extracted.loras = []
|
||||
if "model" in extracted.issues and "model_name" not in overrides:
|
||||
values.pop("checkpoint_name", None)
|
||||
values.pop("unet_name", None)
|
||||
# Extraction without a recognized latent source (e.g. img2img) leaves
|
||||
# width/height unset; the source image dimensions are the best
|
||||
# estimate then. The synthetic starter preset keeps its fixed size.
|
||||
image_fallback = not no_metadata and "source" not in extracted.issues
|
||||
try:
|
||||
image_height, image_width = int(pixels.shape[1]), int(pixels.shape[2])
|
||||
except (AttributeError, IndexError, TypeError, ValueError):
|
||||
image_fallback = False
|
||||
for key, default in EMPTY_IMAGE_DEFAULTS.items():
|
||||
if key in values:
|
||||
continue
|
||||
if image_fallback and key in ("width", "height"):
|
||||
values[key] = image_width if key == "width" else image_height
|
||||
notes.append(f"WARNING Missing {key}; using source image dimension {values[key]}.")
|
||||
else:
|
||||
values[key] = default
|
||||
notes.append(f"ERROR: Missing {key}; using default {default!r}.")
|
||||
# Validate independently so one invalid value cannot erase the other
|
||||
# successfully extracted settings. Invalid explicit overrides still
|
||||
# identify a user configuration error rather than an extraction error.
|
||||
for key in EMPTY_IMAGE_DEFAULTS:
|
||||
trial = {**EMPTY_IMAGE_DEFAULTS, key: values[key]}
|
||||
try:
|
||||
self._validate_values(trial, comfy.samplers.KSampler.SAMPLERS, comfy.samplers.KSampler.SCHEDULERS, True, [])
|
||||
values[key] = trial[key]
|
||||
except (ValueError, TypeError, OverflowError) as exc:
|
||||
if key in overrides:
|
||||
raise MetadataError(f"Invalid override {key}: {exc}") from exc
|
||||
values[key] = EMPTY_IMAGE_DEFAULTS[key]
|
||||
notes.append(f"ERROR: Invalid {key}: {exc}; using default {values[key]!r}.")
|
||||
# Only A1111 directives represent LoRA application. In ComfyUI graphs,
|
||||
# literal tags in encoder text are not executed by CLIPTextEncode.
|
||||
for key in ("positive", "negative"):
|
||||
try:
|
||||
clean, tags = split_lora_tags(values[key])
|
||||
except (ValueError, TypeError) as exc:
|
||||
if key in overrides:
|
||||
raise MetadataError(f"Invalid override {key}: {exc}") from exc
|
||||
values[key] = EMPTY_IMAGE_DEFAULTS[key]
|
||||
notes.append(f"ERROR: Invalid LoRA directive in {key}: {exc}; using starter prompt.")
|
||||
continue
|
||||
if tags:
|
||||
if notes and notes[0] == "A1111/Forge parameters.":
|
||||
if "loras" not in overrides:
|
||||
extracted.loras.extend(tags)
|
||||
values[key] = clean
|
||||
else:
|
||||
notes.append(f"Literal LoRA tags retained in {key}; the embedded ComfyUI graph determines the stack.")
|
||||
try:
|
||||
models, roots, loras, lora_roots = self._library()
|
||||
except Exception as exc:
|
||||
models, roots, loras, lora_roots = [], [], [], []
|
||||
notes.append(f"ERROR: Local library lookup failed: {exc}. Extracted names remain in source_resources.")
|
||||
if (no_metadata or "source" in extracted.issues) and "model_name" not in overrides:
|
||||
base_candidates = [
|
||||
item for item in models
|
||||
if item.get("sub_type") == "checkpoint"
|
||||
and os.path.basename(item.get("file_path", "")).lower() == "sd_xl_base_1.0.safetensors"
|
||||
and os.path.isfile(item["file_path"])
|
||||
]
|
||||
if len(base_candidates) == 1:
|
||||
values["model_name"] = _format_model_name_for_comfyui(base_candidates[0]["file_path"], roots)
|
||||
notes.append("Starter checkpoint: indexed sd_xl_base_1.0.safetensors.")
|
||||
else:
|
||||
notes.append("Select an SDXL checkpoint manually, or set model_name in overrides_json. No unambiguous SDXL base checkpoint was found.")
|
||||
missing_entries = []
|
||||
name = values.get("model_name") or values.get("checkpoint_name") or values.get("unet_name")
|
||||
values.pop("checkpoint_name", None)
|
||||
values.pop("unet_name", None)
|
||||
values["model_name"] = ""
|
||||
values["model_type"] = ""
|
||||
if name:
|
||||
try:
|
||||
# A1111's generic Model label can refer to either category.
|
||||
# Search both together so duplicate names remain ambiguous.
|
||||
available_models = [item for item in models if item.get("sub_type") in ("checkpoint", "diffusion_model")]
|
||||
item = resolve_resource(name, available_models, roots)
|
||||
values["model_name"] = _format_model_name_for_comfyui(item["file_path"], roots)
|
||||
values["model_type"] = item["sub_type"]
|
||||
notes.append(f"Resolved model_name: {values['model_name']} ({values['model_type']}).")
|
||||
except MetadataError as exc:
|
||||
missing_entries.append(f"Model: {name} — {exc}")
|
||||
notes.append(f"WARNING {exc}; model_name is empty.")
|
||||
if not values["model_name"]:
|
||||
notes.append("WARNING No model resolved. Select a model manually on your loader.")
|
||||
stack = []
|
||||
for name, model_strength, clip_strength in extracted.loras:
|
||||
try:
|
||||
item = resolve_resource(name, loras, lora_roots)
|
||||
stack.append((os.path.abspath(item["file_path"]), model_strength, clip_strength))
|
||||
except MetadataError as exc:
|
||||
missing_entries.append(f"LoRA: {name} | model weight: {model_strength:g} | CLIP weight: {clip_strength:g} — {exc}")
|
||||
notes.append(f"WARNING Skipped LoRA: {exc}.")
|
||||
notes.append(f"Resolved {len(stack)} LoRA entries; preserve stack order and avoid adding them again in the loader widget.")
|
||||
notes.append("Metadata settings do not restore VAE, text encoders, ControlNet, regional conditioning or the original latent pipeline.")
|
||||
lora_stack_text = "\n".join(
|
||||
f"{path} | model weight: {model_strength:g} | CLIP weight: {clip_strength:g}"
|
||||
for path, model_strength, clip_strength in stack
|
||||
)
|
||||
missing_files = "\n".join(missing_entries)
|
||||
report = "\n".join(notes) + "\n\n" + json.dumps({**values, "loras": stack, "lora_stack_text": lora_stack_text, "source_resources": source_resources, "missing_files": missing_files}, ensure_ascii=False, indent=2)
|
||||
readable_report = self._readable_report(image, values, extracted.loras, stack, source_resources, notes)
|
||||
return (pixels, mask, values["positive"], values["negative"], values["model_name"],
|
||||
stack, lora_stack_text, values["seed"], values["steps"],
|
||||
values["cfg"], values["sampler_name"], values["scheduler"], values["width"],
|
||||
values["height"], values["denoise"], report, readable_report, missing_files)
|
||||
|
||||
@staticmethod
|
||||
def _readable_report(
|
||||
image: str, values: dict[str, Any], requested_loras: list[tuple[str, float, float]],
|
||||
stack: list[tuple[str, float, float]], source: dict[str, Any], notes: list[str],
|
||||
) -> str:
|
||||
errors = [note for note in notes if note.startswith("ERROR")]
|
||||
lines = ["🖼️ IMAGE GENERATION SETTINGS", f"Image: {image}"]
|
||||
if errors:
|
||||
lines.extend(["", "❌ ERROR — RECOVERED SETTINGS / DEFAULTS", *errors])
|
||||
else:
|
||||
lines.append("✅ Metadata extracted")
|
||||
lines.extend(["", "📦 MODEL"])
|
||||
for key, label in (("checkpoint_name", "Checkpoint"), ("unet_name", "UNet")):
|
||||
if source.get(key):
|
||||
lines.append(f"{label} recorded in image: {source[key]}")
|
||||
if values["model_name"]:
|
||||
lines.append(f"Model resolved locally: {values['model_name']} ({values['model_type']})")
|
||||
else:
|
||||
lines.append("No local model resolved.")
|
||||
lines.extend([
|
||||
"", "⚙️ SAMPLING", f"Seed: {values['seed']}", f"Steps: {values['steps']}",
|
||||
f"CFG: {values['cfg']:g}", f"Sampler: {values['sampler_name']}",
|
||||
f"Scheduler: {values['scheduler']}", f"Size: {values['width']} × {values['height']}",
|
||||
f"Denoise: {values['denoise']:g}", "", "🧩 LORAS",
|
||||
])
|
||||
if requested_loras:
|
||||
for name, model_strength, clip_strength in requested_loras:
|
||||
lines.append(f"- {name} (model: {model_strength:g}, CLIP: {clip_strength:g})")
|
||||
else:
|
||||
lines.append("No LoRA entries extracted or selected.")
|
||||
for hint in source.get("resource_hints", []):
|
||||
if hint.get("name") not in {entry[0] for entry in requested_loras}:
|
||||
lines.append(f"- Recorded resource: {hint['name']} (strength unresolved)")
|
||||
lines.append(f"Resolved locally: {len(stack)} of {len(requested_loras)} requested entries.")
|
||||
lines.extend(["", "➕ POSITIVE PROMPT", values["positive"] or "(empty)",
|
||||
"", "➖ NEGATIVE PROMPT", values["negative"] or "(empty)",
|
||||
"", "📋 NOTES AND WARNINGS"])
|
||||
lines.extend(f"{'❌' if note.startswith('ERROR') else '⚠️' if note.startswith('WARNING') else 'ℹ️'} {note}" for note in notes)
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _override_loras(value: Any) -> list[tuple[str, float, float]]:
|
||||
if not isinstance(value, list):
|
||||
raise MetadataError("loras override must be a list of [name, model_strength, clip_strength]")
|
||||
entries = []
|
||||
for entry in value:
|
||||
if not isinstance(entry, list) or len(entry) != 3 or not isinstance(entry[0], str):
|
||||
raise MetadataError("Each LoRA override must be [name, model_strength, clip_strength]")
|
||||
entries.append((entry[0], finite_number(entry[1]), finite_number(entry[2])))
|
||||
return entries
|
||||
|
||||
@staticmethod
|
||||
def _validate_values(values: dict[str, Any], samplers: list[str], schedulers: list[str], strict: bool, notes: list[str]) -> None:
|
||||
for key in ("positive", "negative"):
|
||||
if not isinstance(values[key], str):
|
||||
raise MetadataError(f"{key} must be text")
|
||||
for key, low, high in (("seed", 0, 2**64 - 1), ("steps", 1, 10000), ("width", 1, 16384), ("height", 1, 16384)):
|
||||
raw = values[key]
|
||||
try:
|
||||
number = int(raw)
|
||||
if isinstance(raw, bool) or (isinstance(raw, float) and raw != number) or not low <= number <= high:
|
||||
raise ValueError()
|
||||
except (ValueError, TypeError, OverflowError) as exc:
|
||||
raise MetadataError(f"{key} must be an integer between {low} and {high}") from exc
|
||||
values[key] = number
|
||||
for key, low, high in (("cfg", 0, 100), ("denoise", 0, 1)):
|
||||
try:
|
||||
number = finite_number(values[key])
|
||||
if not low <= number <= high:
|
||||
raise ValueError()
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise MetadataError(f"{key} must be a finite number between {low} and {high}") from exc
|
||||
values[key] = number
|
||||
for key, choices in (("sampler_name", samplers), ("scheduler", schedulers)):
|
||||
if values[key] not in choices:
|
||||
if strict:
|
||||
raise MetadataError(f"Unsupported {key}: {values[key]!r}; set an explicit override")
|
||||
fallback = DEFAULTS[key]
|
||||
if fallback not in choices:
|
||||
raise MetadataError(f"Default {key} {fallback!r} is unavailable in this ComfyUI installation")
|
||||
notes.append(f"WARNING Replaced unsupported {key} {values[key]!r} with {fallback!r}.")
|
||||
values[key] = fallback
|
||||
@@ -1,5 +1,6 @@
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
|
||||
import comfy.sd # pyright: ignore[reportMissingImports]
|
||||
import comfy.utils # pyright: ignore[reportMissingImports]
|
||||
@@ -37,7 +38,9 @@ def _collect_stack_entries(lora_stack):
|
||||
|
||||
for lora_path, model_strength, clip_strength in lora_stack:
|
||||
lora_name = extract_lora_name(lora_path)
|
||||
absolute_lora_path, trigger_words = get_lora_info_absolute(lora_name)
|
||||
absolute_lora_path, trigger_words = get_lora_info_absolute(
|
||||
lora_path if os.path.isabs(lora_path) else lora_name
|
||||
)
|
||||
entries.append({
|
||||
"name": lora_name,
|
||||
"absolute_path": absolute_lora_path,
|
||||
|
||||
+15
-1
@@ -7,6 +7,7 @@ from ..services.wildcard_service import (
|
||||
contains_dynamic_syntax,
|
||||
get_wildcard_service,
|
||||
is_trigger_words_input,
|
||||
linked_text_requires_rerun,
|
||||
)
|
||||
|
||||
|
||||
@@ -85,6 +86,10 @@ class PromptLM:
|
||||
),
|
||||
},
|
||||
"optional": optional_inputs,
|
||||
"hidden": {
|
||||
"prompt": "PROMPT",
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("CONDITIONING", "STRING")
|
||||
@@ -100,10 +105,16 @@ class PromptLM:
|
||||
text: str,
|
||||
clip: Any | None = None,
|
||||
seed: int | None = None,
|
||||
prompt: dict | None = None,
|
||||
unique_id: str | None = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
del clip, kwargs
|
||||
if contains_dynamic_syntax(text) and seed is None:
|
||||
if seed is not None:
|
||||
return False
|
||||
if contains_dynamic_syntax(text):
|
||||
return float("NaN")
|
||||
if text is None and linked_text_requires_rerun(prompt, unique_id, "text"):
|
||||
return float("NaN")
|
||||
return False
|
||||
|
||||
@@ -112,8 +123,11 @@ class PromptLM:
|
||||
text: str,
|
||||
clip: Any,
|
||||
seed: int | None = None,
|
||||
prompt: dict | None = None,
|
||||
unique_id: str | None = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
del prompt, unique_id
|
||||
expanded_text = get_wildcard_service().expand_text(text, seed=seed)
|
||||
|
||||
trigger_words = []
|
||||
|
||||
+29
-4
@@ -1,6 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..services.wildcard_service import contains_dynamic_syntax, get_wildcard_service
|
||||
from ..services.wildcard_service import (
|
||||
contains_dynamic_syntax,
|
||||
get_wildcard_service,
|
||||
linked_text_requires_rerun,
|
||||
)
|
||||
|
||||
|
||||
class TextLM:
|
||||
@@ -34,6 +38,10 @@ class TextLM:
|
||||
},
|
||||
),
|
||||
},
|
||||
"hidden": {
|
||||
"prompt": "PROMPT",
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
@@ -42,10 +50,27 @@ class TextLM:
|
||||
FUNCTION = "process"
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, text: str, seed: int | None = None):
|
||||
if contains_dynamic_syntax(text) and seed is None:
|
||||
def IS_CHANGED(
|
||||
cls,
|
||||
text: str,
|
||||
seed: int | None = None,
|
||||
prompt: dict | None = None,
|
||||
unique_id: str | None = None,
|
||||
):
|
||||
if seed is not None:
|
||||
return False
|
||||
if contains_dynamic_syntax(text):
|
||||
return float("NaN")
|
||||
if text is None and linked_text_requires_rerun(prompt, unique_id, "text"):
|
||||
return float("NaN")
|
||||
return False
|
||||
|
||||
def process(self, text: str, seed: int | None = None):
|
||||
def process(
|
||||
self,
|
||||
text: str,
|
||||
seed: int | None = None,
|
||||
prompt: dict | None = None,
|
||||
unique_id: str | None = None,
|
||||
):
|
||||
del prompt, unique_id
|
||||
return (get_wildcard_service().expand_text(text, seed=seed),)
|
||||
|
||||
@@ -24,9 +24,11 @@ from ..services.use_cases import (
|
||||
AutoOrganizeUseCase,
|
||||
BulkMetadataRefreshUseCase,
|
||||
DownloadModelUseCase,
|
||||
FilenameTemplateUseCase,
|
||||
)
|
||||
from ..services.websocket_progress_callback import (
|
||||
WebSocketBroadcastCallback,
|
||||
WebSocketFilenameTemplateProgressCallback,
|
||||
WebSocketProgressCallback,
|
||||
)
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
@@ -37,6 +39,7 @@ from .handlers.model_handlers import (
|
||||
ModelAutoOrganizeHandler,
|
||||
ModelCivitaiHandler,
|
||||
ModelDownloadHandler,
|
||||
ModelFilenameTemplateHandler,
|
||||
ModelHandlerSet,
|
||||
ModelListingHandler,
|
||||
ModelManagementHandler,
|
||||
@@ -83,6 +86,9 @@ class BaseModelRoutes(ABC):
|
||||
self.model_lifecycle_service: ModelLifecycleService | None = None
|
||||
self.websocket_progress_callback = WebSocketProgressCallback()
|
||||
self.metadata_progress_callback = WebSocketBroadcastCallback()
|
||||
self.filename_template_progress_callback = (
|
||||
WebSocketFilenameTemplateProgressCallback()
|
||||
)
|
||||
|
||||
self._handler_set: ModelHandlerSet | None = None
|
||||
self._handler_mapping: Dict[str, Callable[[web.Request], Awaitable[web.Response]]] | None = None
|
||||
@@ -202,6 +208,17 @@ class BaseModelRoutes(ABC):
|
||||
ws_manager=self._ws_manager,
|
||||
logger=logger,
|
||||
)
|
||||
filename_template_use_case = FilenameTemplateUseCase(
|
||||
scanner=service.scanner,
|
||||
lifecycle_service=self._ensure_lifecycle_service(),
|
||||
lock_provider=self._ws_manager,
|
||||
model_type=service.model_type,
|
||||
)
|
||||
filename_template = ModelFilenameTemplateHandler(
|
||||
use_case=filename_template_use_case,
|
||||
progress_callback=self.filename_template_progress_callback,
|
||||
logger=logger,
|
||||
)
|
||||
updates = ModelUpdateHandler(
|
||||
service=service,
|
||||
update_service=update_service,
|
||||
@@ -218,6 +235,7 @@ class BaseModelRoutes(ABC):
|
||||
civitai=civitai,
|
||||
move=move,
|
||||
auto_organize=auto_organize,
|
||||
filename_template=filename_template,
|
||||
updates=updates,
|
||||
)
|
||||
|
||||
|
||||
@@ -1506,6 +1506,7 @@ class SettingsHandler:
|
||||
# Sensitive — never expose the actual value to the frontend;
|
||||
# frontend receives a boolean instead (*_set).
|
||||
"civitai_api_key",
|
||||
"huggingface_api_key",
|
||||
"llm_api_key",
|
||||
}
|
||||
)
|
||||
@@ -1564,6 +1565,8 @@ class SettingsHandler:
|
||||
# Sensitive fields: only expose a boolean indicating whether set
|
||||
raw_key = self._settings.get("civitai_api_key")
|
||||
response_data["civitai_api_key_set"] = bool(raw_key)
|
||||
raw_hf_key = self._settings.get("huggingface_api_key")
|
||||
response_data["huggingface_api_key_set"] = bool(raw_hf_key)
|
||||
raw_llm_key = self._settings.get("llm_api_key")
|
||||
response_data["llm_api_key_set"] = bool(raw_llm_key)
|
||||
# Derived capability flag (not persisted): whether the host exposes
|
||||
|
||||
@@ -37,15 +37,20 @@ from ...services.use_cases import (
|
||||
DownloadModelEarlyAccessError,
|
||||
DownloadModelUseCase,
|
||||
DownloadModelValidationError,
|
||||
FilenameTemplateUseCase,
|
||||
MetadataRefreshProgressReporter,
|
||||
)
|
||||
from ...services.websocket_manager import WebSocketManager
|
||||
from ...services.websocket_progress_callback import WebSocketProgressCallback
|
||||
from ...services.websocket_progress_callback import (
|
||||
WebSocketFilenameTemplateProgressCallback,
|
||||
WebSocketProgressCallback,
|
||||
)
|
||||
from ...services.download_queue_service import DownloadQueueService
|
||||
from ...services.errors import RateLimitError, ResourceNotFoundError
|
||||
from ...utils.civitai_utils import resolve_license_payload
|
||||
from ...utils.file_utils import calculate_sha256
|
||||
from ...utils.metadata_manager import MetadataManager
|
||||
from ...utils.url_utils import relative_root_prefix
|
||||
|
||||
LICENSE_FIELDS = (
|
||||
"allowNoCredit",
|
||||
@@ -200,6 +205,7 @@ class ModelPageView:
|
||||
"version": self._get_app_version(),
|
||||
"provider_presets_json": json.dumps(PROVIDER_PRESETS),
|
||||
"provider_models_json": "{}",
|
||||
"rel_prefix": relative_root_prefix(request.path),
|
||||
}
|
||||
|
||||
if not is_initializing:
|
||||
@@ -2692,6 +2698,71 @@ class ModelAutoOrganizeHandler:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
|
||||
class ModelFilenameTemplateHandler:
|
||||
"""Apply the configured filename template to existing library models."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
use_case: FilenameTemplateUseCase,
|
||||
progress_callback: WebSocketFilenameTemplateProgressCallback,
|
||||
logger: logging.Logger,
|
||||
) -> None:
|
||||
self._use_case = use_case
|
||||
self._progress_callback = progress_callback
|
||||
self._logger = logger
|
||||
|
||||
async def apply_filename_template(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
file_paths = None
|
||||
if request.method == "POST":
|
||||
try:
|
||||
data = await request.json()
|
||||
file_paths = data.get("file_paths")
|
||||
except Exception: # pragma: no cover - permissive path
|
||||
pass
|
||||
else:
|
||||
# GET variant (browser extension is GET-only): comma-separated
|
||||
# file_paths query parameter.
|
||||
raw_file_paths = request.query.get("file_paths")
|
||||
if raw_file_paths:
|
||||
file_paths = [
|
||||
path.strip()
|
||||
for path in raw_file_paths.split(",")
|
||||
if path.strip()
|
||||
]
|
||||
|
||||
result = await self._use_case.execute(
|
||||
file_paths=file_paths,
|
||||
progress_callback=self._progress_callback,
|
||||
)
|
||||
_broadcast_models_changed()
|
||||
return web.json_response(result.to_dict())
|
||||
except AutoOrganizeInProgressError:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Another library operation is already running. Please wait for it to complete.",
|
||||
},
|
||||
status=409,
|
||||
)
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error in apply_filename_template: %s", exc, exc_info=True
|
||||
)
|
||||
try:
|
||||
await self._progress_callback.on_progress(
|
||||
{
|
||||
"type": "filename_template_progress",
|
||||
"status": "error",
|
||||
"error": str(exc),
|
||||
}
|
||||
)
|
||||
except Exception: # pragma: no cover - defensive reporting
|
||||
pass
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
|
||||
class ModelUpdateHandler:
|
||||
"""Handle update tracking requests."""
|
||||
|
||||
@@ -3459,6 +3530,7 @@ class ModelHandlerSet:
|
||||
civitai: ModelCivitaiHandler
|
||||
move: ModelMoveHandler
|
||||
auto_organize: ModelAutoOrganizeHandler
|
||||
filename_template: ModelFilenameTemplateHandler
|
||||
updates: ModelUpdateHandler
|
||||
|
||||
def to_route_mapping(
|
||||
@@ -3523,6 +3595,7 @@ class ModelHandlerSet:
|
||||
"rename_folder": self.move.rename_folder,
|
||||
"auto_organize_models": self.auto_organize.auto_organize_models,
|
||||
"get_auto_organize_progress": self.auto_organize.get_auto_organize_progress,
|
||||
"apply_filename_template": self.filename_template.apply_filename_template,
|
||||
"get_model_notes": self.query.get_model_notes,
|
||||
"get_model_preview_url": self.query.get_model_preview_url,
|
||||
"get_model_civitai_url": self.query.get_model_civitai_url,
|
||||
|
||||
@@ -580,6 +580,10 @@ class ModelSourceHandler:
|
||||
get_settings_manager().get("download_backend", "default")
|
||||
)
|
||||
|
||||
# Site-specific credentials (e.g. a Hugging Face access token for
|
||||
# gated/private repositories); empty for anonymous downloads.
|
||||
auth_headers = source.auth_headers()
|
||||
|
||||
if download_backend == "aria2":
|
||||
aria2 = await Aria2Downloader.get_instance()
|
||||
aid = download_id or f"{source.platform}_{repo}_{filename}"
|
||||
@@ -589,6 +593,7 @@ class ModelSourceHandler:
|
||||
save_path=dest_path,
|
||||
download_id=aid,
|
||||
progress_callback=progress_callback,
|
||||
headers=auth_headers or None,
|
||||
)
|
||||
if ok:
|
||||
await _save_source_metadata(
|
||||
@@ -618,6 +623,7 @@ class ModelSourceHandler:
|
||||
use_auth=False,
|
||||
allow_resume=True,
|
||||
progress_callback=progress_callback,
|
||||
custom_headers=auth_headers or None,
|
||||
)
|
||||
if success:
|
||||
await _save_source_metadata(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -48,6 +48,12 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/{prefix}/auto-organize-progress", "get_auto_organize_progress"
|
||||
),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/{prefix}/apply-filename-template", "apply_filename_template"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/{prefix}/apply-filename-template", "apply_filename_template"
|
||||
),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/top-tags", "get_top_tags"),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/search-tags", "search_tags"),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/base-models", "get_base_models"),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -740,18 +740,14 @@ class BaseModelService(ABC):
|
||||
|
||||
return annotated
|
||||
|
||||
@staticmethod
|
||||
def _extract_hf_group_key(item: Dict[str, Any]) -> Optional[str]:
|
||||
"""Extract `hf:{owner}/{repo}` from item's ``hf_url``, or None."""
|
||||
key = BaseModelService._extract_source_group_key(item)
|
||||
return key if key and key.startswith("hf:") else None
|
||||
|
||||
@staticmethod
|
||||
def _extract_source_group_key(item: Dict[str, Any]) -> Optional[str]:
|
||||
"""Return the external-source group key for *item*, or None.
|
||||
|
||||
Hugging Face keeps the historical ``hf:{owner}/{repo}`` shape; other
|
||||
platforms use their own short prefix (``ms:`` / ``ta:``).
|
||||
Only sources with a site-native model identity yield a key:
|
||||
ModelScope groups by its published-model id (``ms:{id}``), TensorArt
|
||||
by its numeric model id (``ta:{id}``); Hugging Face models never
|
||||
group (see :meth:`ModelSource.group_key`).
|
||||
"""
|
||||
return source_group_key(item)
|
||||
|
||||
@@ -761,8 +757,8 @@ class BaseModelService(ABC):
|
||||
|
||||
Preference order:
|
||||
1. CivitAI ``modelId`` (int)
|
||||
2. External model source identity, e.g. ``hf:{owner}/{repo}``,
|
||||
``ms:{owner}/{repo}``, ``ta:{model_id}`` (str)
|
||||
2. External model source identity, e.g. ``ms:{model_id}``,
|
||||
``ta:{model_id}`` (str)
|
||||
3. ``None`` (no known grouping source)
|
||||
"""
|
||||
mid = BaseModelService._extract_model_id(item)
|
||||
|
||||
@@ -69,6 +69,8 @@ class CheckpointService(BaseModelService):
|
||||
"version_count": model_data.get("version_count"),
|
||||
"source_platform": model_data.get("source_platform", ""),
|
||||
"source_url": model_data.get("source_url", ""),
|
||||
"source_model_id": model_data.get("source_model_id", ""),
|
||||
"source_version_id": model_data.get("source_version_id", ""),
|
||||
"hf_url": model_data.get("hf_url", ""),
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ from ..utils.models import (
|
||||
)
|
||||
from ..utils.constants import (
|
||||
CARD_PREVIEW_WIDTH,
|
||||
MAX_FOLDER_NAME_LENGTH,
|
||||
MAX_PATH_TAG_LENGTH,
|
||||
MODEL_WEIGHT_FILE_TYPES,
|
||||
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
|
||||
VALID_LORA_TYPES,
|
||||
@@ -33,7 +35,7 @@ from ..utils.constants import (
|
||||
from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url
|
||||
from ..utils.file_utils import calculate_sha256, calculate_autov3
|
||||
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
|
||||
from ..utils.utils import sanitize_folder_name
|
||||
from ..utils.utils import calculate_filename_for_model, sanitize_folder_name
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from .service_registry import ServiceRegistry
|
||||
@@ -45,6 +47,7 @@ from .errors import RateLimitError
|
||||
from .aria2_downloader import Aria2Error, get_aria2_downloader
|
||||
from .aria2_transfer_state import Aria2TransferStateStore
|
||||
from .download_queue_service import DownloadQueueService
|
||||
from .model_lifecycle_service import ModelLifecycleService, load_local_metadata
|
||||
|
||||
# Download to temporary file first
|
||||
import tempfile
|
||||
@@ -2326,16 +2329,26 @@ class DownloadManager:
|
||||
if not first_tag:
|
||||
first_tag = "no tags" # Default if no tags available
|
||||
|
||||
# Tags come straight from CivitAI, so sanitize the value before it
|
||||
# becomes a path segment and cap its length (#1119).
|
||||
first_tag = sanitize_folder_name(first_tag, max_length=MAX_PATH_TAG_LENGTH)
|
||||
|
||||
# Format the template with available data
|
||||
formatted_path = path_template
|
||||
formatted_path = formatted_path.replace("{base_model}", mapped_base_model)
|
||||
formatted_path = formatted_path.replace("{first_tag}", first_tag)
|
||||
formatted_path = formatted_path.replace("{author}", author)
|
||||
formatted_path = formatted_path.replace(
|
||||
"{model_name}", sanitize_folder_name(model_info.get("name", ""))
|
||||
"{model_name}",
|
||||
sanitize_folder_name(
|
||||
model_info.get("name", ""), max_length=MAX_FOLDER_NAME_LENGTH
|
||||
),
|
||||
)
|
||||
formatted_path = formatted_path.replace(
|
||||
"{version_name}", sanitize_folder_name(version_info.get("name", ""))
|
||||
"{version_name}",
|
||||
sanitize_folder_name(
|
||||
version_info.get("name", ""), max_length=MAX_FOLDER_NAME_LENGTH
|
||||
),
|
||||
)
|
||||
|
||||
if model_type == "embedding":
|
||||
@@ -2746,6 +2759,7 @@ class DownloadManager:
|
||||
else None
|
||||
)
|
||||
|
||||
downloaded_metadata: List[Dict[str, Any]] = []
|
||||
for index, entry in enumerate(metadata_entries):
|
||||
file_path_for_adjust = getattr(
|
||||
entry, "file_path", actual_file_paths[index]
|
||||
@@ -2788,6 +2802,15 @@ class DownloadManager:
|
||||
if scanner is not None:
|
||||
await scanner.add_model_to_cache(metadata_dict, relative_path)
|
||||
|
||||
downloaded_metadata.append(metadata_dict)
|
||||
|
||||
await self._apply_download_filename_template(
|
||||
scanner=scanner,
|
||||
model_type=model_type,
|
||||
downloaded_metadata=downloaded_metadata,
|
||||
download_id=download_id,
|
||||
)
|
||||
|
||||
if transfer_backend == "aria2" and download_id:
|
||||
await self._aria2_state_store.remove(download_id)
|
||||
|
||||
@@ -2827,6 +2850,83 @@ class DownloadManager:
|
||||
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def _apply_download_filename_template(
|
||||
self,
|
||||
*,
|
||||
scanner,
|
||||
model_type: str,
|
||||
downloaded_metadata: List[Dict[str, Any]],
|
||||
download_id: Optional[str],
|
||||
) -> None:
|
||||
"""Rename freshly downloaded models according to the filename template.
|
||||
|
||||
Best-effort post-download step: any failure (including name conflicts)
|
||||
is logged and skipped so a successful download is never turned into a
|
||||
failure by a rename problem.
|
||||
"""
|
||||
try:
|
||||
if scanner is None or not downloaded_metadata:
|
||||
return
|
||||
|
||||
template = get_settings_manager().get_download_filename_template(
|
||||
model_type
|
||||
)
|
||||
if not template:
|
||||
return
|
||||
|
||||
lifecycle_service = ModelLifecycleService(
|
||||
scanner=scanner,
|
||||
metadata_manager=MetadataManager,
|
||||
metadata_loader=load_local_metadata,
|
||||
recipe_scanner_factory=ServiceRegistry.get_recipe_scanner,
|
||||
)
|
||||
|
||||
for metadata_dict in downloaded_metadata:
|
||||
file_path = metadata_dict.get("file_path")
|
||||
if not isinstance(file_path, str) or not file_path:
|
||||
continue
|
||||
|
||||
new_stem = calculate_filename_for_model(metadata_dict, model_type)
|
||||
if not new_stem:
|
||||
continue
|
||||
|
||||
current_stem = os.path.splitext(os.path.basename(file_path))[0]
|
||||
if new_stem == current_stem or os.path.normcase(
|
||||
new_stem
|
||||
) == os.path.normcase(current_stem):
|
||||
continue
|
||||
|
||||
try:
|
||||
result = await lifecycle_service.rename_model(
|
||||
file_path=file_path, new_file_name=new_stem
|
||||
)
|
||||
except ValueError as exc:
|
||||
logger.warning(
|
||||
"Keeping original filename for %s: %s", file_path, exc
|
||||
)
|
||||
continue
|
||||
|
||||
new_file_path = result.get("new_file_path")
|
||||
if download_id and isinstance(new_file_path, str):
|
||||
info = self._active_downloads.get(download_id)
|
||||
if info is None:
|
||||
continue
|
||||
if info.get("file_path") == file_path:
|
||||
info["file_path"] = new_file_path
|
||||
extracted = info.get("extracted_paths")
|
||||
if isinstance(extracted, list):
|
||||
info["extracted_paths"] = [
|
||||
new_file_path if path == file_path else path
|
||||
for path in extracted
|
||||
]
|
||||
except Exception as exc: # Rename phase must never fail the download
|
||||
logger.warning(
|
||||
"Filename template rename failed for %s download: %s",
|
||||
model_type,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def _get_supported_extensions_for_type(self, model_type: str) -> Set[str]:
|
||||
if model_type in ("checkpoint", "other"):
|
||||
return {
|
||||
|
||||
@@ -69,6 +69,8 @@ class EmbeddingService(BaseModelService):
|
||||
"version_count": model_data.get("version_count"),
|
||||
"source_platform": model_data.get("source_platform", ""),
|
||||
"source_url": model_data.get("source_url", ""),
|
||||
"source_model_id": model_data.get("source_model_id", ""),
|
||||
"source_version_id": model_data.get("source_version_id", ""),
|
||||
"hf_url": model_data.get("hf_url", ""),
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,8 @@ class LoraService(BaseModelService):
|
||||
"version_count": model_data.get("version_count"),
|
||||
"source_platform": model_data.get("source_platform", ""),
|
||||
"source_url": model_data.get("source_url", ""),
|
||||
"source_model_id": model_data.get("source_model_id", ""),
|
||||
"source_version_id": model_data.get("source_version_id", ""),
|
||||
"hf_url": model_data.get("hf_url", ""),
|
||||
}
|
||||
|
||||
@@ -714,12 +716,18 @@ class LoraService(BaseModelService):
|
||||
),
|
||||
)
|
||||
|
||||
# Return minimal data needed for cycling
|
||||
return [
|
||||
{
|
||||
# Return minimal data needed for cycling. usage_tips is only included
|
||||
# when non-empty so widget consumers (recommended strength range cues)
|
||||
# can build their lookup without inflating the payload.
|
||||
result = []
|
||||
for lora in available_loras:
|
||||
entry = {
|
||||
"file_name": f"{lora['folder']}/{lora['file_name']}" if lora.get("folder") else lora["file_name"],
|
||||
"model_name": lora.get("model_name", lora["file_name"]),
|
||||
"folder": lora.get("folder", ""),
|
||||
}
|
||||
for lora in available_loras
|
||||
]
|
||||
usage_tips = lora.get("usage_tips")
|
||||
if usage_tips:
|
||||
entry["usage_tips"] = usage_tips
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
@@ -43,10 +43,22 @@ class AutoOrganizeResult:
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert result to dictionary"""
|
||||
if self.operation_type == 'filename_template':
|
||||
message = (
|
||||
f'Filename template applied: {self.success_count} renamed, '
|
||||
f'{self.skipped_count} skipped, {self.failure_count} failed '
|
||||
f'out of {self.total} total'
|
||||
)
|
||||
else:
|
||||
message = (
|
||||
f'Auto-organize {self.operation_type} completed: '
|
||||
f'{self.success_count} moved, {self.skipped_count} skipped, '
|
||||
f'{self.failure_count} failed out of {self.total} total'
|
||||
)
|
||||
result: Dict[str, Any] = {
|
||||
'success': self.status != 'error',
|
||||
'status': self.status,
|
||||
'message': f'Auto-organize {self.operation_type} completed: {self.success_count} moved, {self.skipped_count} skipped, {self.failure_count} failed out of {self.total} total',
|
||||
'message': message,
|
||||
'summary': {
|
||||
'total': self.total,
|
||||
'success': self.success_count,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Optional, TYPE_CHECKING, cast
|
||||
@@ -17,6 +18,26 @@ if TYPE_CHECKING:
|
||||
from ..services.model_update_service import ModelUpdateService
|
||||
|
||||
|
||||
async def load_local_metadata(metadata_path: str) -> Dict[str, Any]:
|
||||
"""Load a metadata sidecar JSON, returning an empty dict when missing.
|
||||
|
||||
Thin equivalent of ``MetadataSyncService.load_local_metadata`` for callers
|
||||
(download manager, use cases) that do not hold a sync-service instance.
|
||||
"""
|
||||
|
||||
if not os.path.exists(metadata_path):
|
||||
return {}
|
||||
|
||||
try:
|
||||
with open(metadata_path, "r", encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to load metadata from %s: %s", metadata_path, exc)
|
||||
return {}
|
||||
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
|
||||
async def delete_model_artifacts(
|
||||
target_dir: str, file_name: str, main_extension: str | None = None
|
||||
) -> List[str]:
|
||||
@@ -404,6 +425,9 @@ class ModelLifecycleService:
|
||||
if metadata and new_metadata_path:
|
||||
metadata["file_name"] = new_file_name
|
||||
metadata["file_path"] = new_file_path
|
||||
# Preserve the pre-rename stem so the original download filename
|
||||
# stays recoverable after template-driven renames.
|
||||
metadata.setdefault("original_file_name", old_file_name)
|
||||
|
||||
if metadata.get("preview_url"):
|
||||
old_preview = str(metadata["preview_url"])
|
||||
|
||||
@@ -399,10 +399,14 @@ class ModelScanner:
|
||||
'skip_metadata_refresh': bool(get_value('skip_metadata_refresh', False)),
|
||||
# External model source (Hugging Face / ModelScope / TensorArt).
|
||||
# `source_url` + `source_platform` are canonical; `hf_url` stays in
|
||||
# sync as a legacy alias (normalised below).
|
||||
# sync as a legacy alias (normalised below). `source_model_id` /
|
||||
# `source_version_id` are the site-native identity ids version
|
||||
# grouping keys off (ModelScope; empty elsewhere).
|
||||
'source_platform': get_value('source_platform', '') or '',
|
||||
'source_url': get_value('source_url', '') or '',
|
||||
'hf_url': get_value('hf_url', '') or '',
|
||||
'source_model_id': get_value('source_model_id', '') or '',
|
||||
'source_version_id': get_value('source_version_id', '') or '',
|
||||
}
|
||||
normalize_metadata_source(entry)
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Iterable, Optional
|
||||
from typing import Any, Dict, Iterable, Mapping, Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
@@ -123,6 +123,20 @@ class ModelCardContext:
|
||||
trigger_words: list[str] = field(default_factory=list)
|
||||
"""Trigger words the site records for the requested model file."""
|
||||
|
||||
source_model_id: str = ""
|
||||
"""Site-native id of the *published model* the requested file belongs to.
|
||||
|
||||
Sites whose repository is not a model identity publish a separate,
|
||||
stable id per model (ModelScope's ``modelVersion.modelId`` — identical
|
||||
across every version of one published model, different between the
|
||||
models of a collection repository). It is the version-grouping key,
|
||||
persisted on the sidecar as ``source_model_id``.
|
||||
"""
|
||||
|
||||
source_version_id: str = ""
|
||||
"""Site-native id of the published version the requested file belongs to
|
||||
(ModelScope's ``modelVersion.id``), persisted as ``source_version_id``."""
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
"""Return ``True`` when the site contributed nothing extra."""
|
||||
|
||||
@@ -139,6 +153,8 @@ class ModelCardContext:
|
||||
self.official_tags,
|
||||
self.example_images,
|
||||
self.trigger_words,
|
||||
self.source_model_id,
|
||||
self.source_version_id,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -193,7 +209,9 @@ def is_valid_source_id(source_id: str) -> bool:
|
||||
)
|
||||
|
||||
|
||||
async def fetch_text(url: str, *, timeout: int = HTTP_TIMEOUT) -> str:
|
||||
async def fetch_text(
|
||||
url: str, *, timeout: int = HTTP_TIMEOUT, headers: Optional[Dict[str, str]] = None
|
||||
) -> str:
|
||||
"""Fetch *url* and return its body as text, or ``""`` on any failure.
|
||||
|
||||
Network problems are expected (offline installs, rate limits, dead
|
||||
@@ -202,8 +220,11 @@ async def fetch_text(url: str, *, timeout: int = HTTP_TIMEOUT) -> str:
|
||||
"""
|
||||
|
||||
try:
|
||||
request_headers = {"User-Agent": USER_AGENT}
|
||||
if headers:
|
||||
request_headers.update(headers)
|
||||
async with aiohttp.ClientSession(
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
headers=request_headers,
|
||||
timeout=aiohttp.ClientTimeout(total=timeout),
|
||||
) as session:
|
||||
async with session.get(url) as resp:
|
||||
@@ -216,7 +237,7 @@ async def fetch_text(url: str, *, timeout: int = HTTP_TIMEOUT) -> str:
|
||||
|
||||
|
||||
async def fetch_json(
|
||||
url: str, *, timeout: int = HTTP_TIMEOUT
|
||||
url: str, *, timeout: int = HTTP_TIMEOUT, headers: Optional[Dict[str, str]] = None
|
||||
) -> tuple[int, Any]:
|
||||
"""Fetch *url* and return ``(status, parsed_body)``.
|
||||
|
||||
@@ -227,8 +248,11 @@ async def fetch_json(
|
||||
"""
|
||||
|
||||
try:
|
||||
request_headers = {"User-Agent": USER_AGENT}
|
||||
if headers:
|
||||
request_headers.update(headers)
|
||||
async with aiohttp.ClientSession(
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
headers=request_headers,
|
||||
timeout=aiohttp.ClientTimeout(total=timeout),
|
||||
) as session:
|
||||
async with session.get(url) as resp:
|
||||
@@ -321,11 +345,20 @@ class ModelSource:
|
||||
|
||||
return ""
|
||||
|
||||
def group_key(self, source_id: str) -> str:
|
||||
"""Return the version-group key for *source_id*."""
|
||||
def group_key(self, ref: SourceRef, item: Mapping[str, Any]) -> Optional[str]:
|
||||
"""Return the version-group key for the model described by *item*.
|
||||
|
||||
The default groups by source id (``{prefix}:{owner}/{repo}``), which
|
||||
is only correct when the source id already identifies a single
|
||||
published model. Sources whose repository hosts many unrelated
|
||||
models override this: they either derive the key from a site-native
|
||||
model identity recorded in *item* (ModelScope's ``source_model_id``)
|
||||
or return ``None`` when the platform has no reliable model identity
|
||||
at all (Hugging Face), leaving the model ungrouped.
|
||||
"""
|
||||
|
||||
prefix = GROUP_PREFIXES.get(self.platform, self.platform)
|
||||
return f"{prefix}:{source_id}"
|
||||
return f"{prefix}:{ref.source_id}"
|
||||
|
||||
async def fetch_model_card(self, source_id: str) -> str:
|
||||
"""Fetch the raw model card (README) markdown for *source_id*."""
|
||||
@@ -381,6 +414,15 @@ class ModelSource:
|
||||
|
||||
return []
|
||||
|
||||
def auth_headers(self) -> Dict[str, str]:
|
||||
"""Extra request headers this site needs for API and file downloads.
|
||||
|
||||
Empty by default; sites with gated/private content (Hugging Face)
|
||||
override it to attach the user's access token when one is configured.
|
||||
"""
|
||||
|
||||
return {}
|
||||
|
||||
def file_download_url(
|
||||
self, source_id: str, filename: str, revision: str = ""
|
||||
) -> str:
|
||||
|
||||
@@ -4,10 +4,12 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Mapping, Optional
|
||||
|
||||
from .base import (
|
||||
ModelSource,
|
||||
ModelSourceError,
|
||||
SourceRef,
|
||||
fetch_json,
|
||||
fetch_text,
|
||||
filter_weight_files,
|
||||
@@ -27,6 +29,18 @@ _STRICT_URL_PATTERN = re.compile(
|
||||
)
|
||||
|
||||
|
||||
def _hf_token() -> str:
|
||||
"""Return the configured Hugging Face access token, or ``""``."""
|
||||
|
||||
try:
|
||||
from ..settings_manager import get_settings_manager
|
||||
|
||||
token = get_settings_manager().get("huggingface_api_key", "")
|
||||
except Exception: # pragma: no cover - settings must never break downloads
|
||||
return ""
|
||||
return token.strip() if isinstance(token, str) else ""
|
||||
|
||||
|
||||
class HuggingFaceSource(ModelSource):
|
||||
"""Hugging Face Hub (``huggingface.co``)."""
|
||||
|
||||
@@ -42,15 +56,33 @@ class HuggingFaceSource(ModelSource):
|
||||
def canonical_url(self, source_id: str) -> str:
|
||||
return f"https://huggingface.co/{source_id}"
|
||||
|
||||
def group_key(self, ref: SourceRef, item: Mapping[str, Any]) -> Optional[str]:
|
||||
"""Hugging Face models never auto-group.
|
||||
|
||||
A repository is not a model identity — collection repos host many
|
||||
unrelated models — and the Hub exposes no site-native published-model
|
||||
id, so there is no reliable key to group by.
|
||||
"""
|
||||
|
||||
return None
|
||||
|
||||
def asset_base_url(self, source_id: str, revision: str = "") -> str:
|
||||
return f"https://huggingface.co/{source_id}/resolve/{self.resolve_revision(revision)}"
|
||||
|
||||
def auth_headers(self) -> dict[str, str]:
|
||||
"""Bearer header for gated/private repositories, when a token is set."""
|
||||
|
||||
token = _hf_token()
|
||||
return {"Authorization": f"Bearer {token}"} if token else {}
|
||||
|
||||
async def fetch_model_card(self, source_id: str) -> str:
|
||||
"""Fetch ``README.md`` from Hugging Face (tries ``main``, then ``master``)."""
|
||||
|
||||
headers = self.auth_headers()
|
||||
for branch in ("main", "master"):
|
||||
text = await fetch_text(
|
||||
f"https://huggingface.co/{source_id}/raw/{branch}/README.md"
|
||||
f"https://huggingface.co/{source_id}/raw/{branch}/README.md",
|
||||
headers=headers,
|
||||
)
|
||||
if text:
|
||||
return text
|
||||
@@ -67,11 +99,26 @@ class HuggingFaceSource(ModelSource):
|
||||
|
||||
revision = self.resolve_revision(revision)
|
||||
status, payload = await fetch_json(
|
||||
f"https://huggingface.co/api/models/{source_id}/tree/{revision}"
|
||||
f"https://huggingface.co/api/models/{source_id}/tree/{revision}",
|
||||
headers=self.auth_headers(),
|
||||
)
|
||||
|
||||
if status == 404:
|
||||
raise ModelSourceError(f"Repository '{source_id}' not found", status=404)
|
||||
if status in (401, 403):
|
||||
if _hf_token():
|
||||
raise ModelSourceError(
|
||||
f"Access to '{source_id}' was denied (HTTP {status}). For a gated "
|
||||
"repository you must accept its terms on the Hugging Face page, "
|
||||
"and the configured token needs read permission for it.",
|
||||
status=403,
|
||||
)
|
||||
raise ModelSourceError(
|
||||
f"'{source_id}' requires a Hugging Face access token (gated or "
|
||||
"private repository). Configure one in Settings → Hugging Face "
|
||||
"Access Token, and accept the repository's terms on its page.",
|
||||
status=401,
|
||||
)
|
||||
if status != 200 or not isinstance(payload, list):
|
||||
raise ModelSourceError(
|
||||
f"Hugging Face API error while listing '{source_id}' (HTTP {status})"
|
||||
|
||||
@@ -44,12 +44,15 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any, Iterable, Optional
|
||||
from typing import TYPE_CHECKING, Any, Iterable, Mapping, Optional
|
||||
|
||||
from .base import (
|
||||
GROUP_PREFIXES,
|
||||
ModelCardContext,
|
||||
ModelSource,
|
||||
ModelSourceError,
|
||||
SourceRef,
|
||||
clean_source_url,
|
||||
fetch_json,
|
||||
fetch_text,
|
||||
filter_weight_files,
|
||||
@@ -111,6 +114,22 @@ class ModelScopeSource(ModelSource):
|
||||
def canonical_url(self, source_id: str) -> str:
|
||||
return f"{self.base_url}/models/{source_id}"
|
||||
|
||||
def group_key(self, ref: SourceRef, item: Mapping[str, Any]) -> Optional[str]:
|
||||
"""Group by ModelScope's published-model id, never by repository.
|
||||
|
||||
A collection repository hosts many unrelated published models, so
|
||||
the repo id is not a version-group identity. Only models whose
|
||||
metadata carries the site-native ``source_model_id`` (recorded at
|
||||
enrichment time from ``MuseInfo.versions[].modelVersion.modelId``)
|
||||
group together; unenriched models stay standalone.
|
||||
"""
|
||||
|
||||
model_id = clean_source_url(item.get("source_model_id"))
|
||||
if not model_id:
|
||||
return None
|
||||
prefix = GROUP_PREFIXES.get(self.platform, self.platform)
|
||||
return f"{prefix}:{model_id}"
|
||||
|
||||
def asset_base_url(self, source_id: str, revision: str = "") -> str:
|
||||
return (
|
||||
f"{self.base_url}/models/{source_id}/resolve/"
|
||||
@@ -350,9 +369,37 @@ def _build_card_context(
|
||||
context.version_name = _version_label(versions)
|
||||
context.example_images = _cover_image_urls(versions)
|
||||
context.trigger_words = _version_trigger_words(versions)
|
||||
context.source_model_id, context.source_version_id = _version_identity(
|
||||
versions
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
def _version_identity(versions: list[dict[str, Any]]) -> tuple[str, str]:
|
||||
"""Return the site-native ``(model id, version id)`` of the first match.
|
||||
|
||||
``modelVersion.modelId`` is identical across every version of one
|
||||
published model and differs between the models of a collection
|
||||
repository, which makes it the version-grouping identity;
|
||||
``modelVersion.id`` identifies the version itself. Both are ints in
|
||||
the payload and are stored as strings.
|
||||
"""
|
||||
|
||||
for version in versions:
|
||||
model_version = version.get("modelVersion")
|
||||
if not isinstance(model_version, dict):
|
||||
continue
|
||||
model_id = model_version.get("modelId")
|
||||
version_id = model_version.get("id")
|
||||
if model_id is None and version_id is None:
|
||||
continue
|
||||
return (
|
||||
str(model_id) if model_id is not None else "",
|
||||
str(version_id) if version_id is not None else "",
|
||||
)
|
||||
return "", ""
|
||||
|
||||
|
||||
def _base_model_aliases(data: dict[str, Any]) -> list[str]:
|
||||
"""Return the site's own names for the base model.
|
||||
|
||||
|
||||
@@ -196,8 +196,13 @@ def get_source_platform(item: Mapping[str, Any]) -> str:
|
||||
def source_group_key(item: Mapping[str, Any]) -> Optional[str]:
|
||||
"""Return the version-group key for *item*, or ``None``.
|
||||
|
||||
Hugging Face keeps the historical ``hf:{owner}/{repo}`` shape; other
|
||||
platforms use their own short prefix (see :data:`GROUP_PREFIXES`).
|
||||
Only sources with a site-native model identity yield a key: TensorArt
|
||||
groups by its numeric model id (``ta:<id>``) and ModelScope by the
|
||||
published-model id recorded at enrichment time (``ms:<id>`` /
|
||||
``msai:<id>``). Hugging Face yields no key at all — a repository is
|
||||
not a model identity — and unenriched ModelScope models stay
|
||||
standalone rather than collapsing a whole collection repository into
|
||||
one group.
|
||||
"""
|
||||
|
||||
ref = resolve_source_ref(item)
|
||||
@@ -206,7 +211,7 @@ def source_group_key(item: Mapping[str, Any]) -> Optional[str]:
|
||||
source = get_source(ref.platform)
|
||||
if source is None:
|
||||
return None
|
||||
return source.group_key(ref.source_id)
|
||||
return source.group_key(ref, item)
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
||||
@@ -69,6 +69,8 @@ class OtherModelService(BaseModelService):
|
||||
"version_count": model_data.get("version_count"),
|
||||
"source_platform": model_data.get("source_platform", ""),
|
||||
"source_url": model_data.get("source_url", ""),
|
||||
"source_model_id": model_data.get("source_model_id", ""),
|
||||
"source_version_id": model_data.get("source_version_id", ""),
|
||||
"hf_url": model_data.get("hf_url", ""),
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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": "",
|
||||
@@ -98,6 +101,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"recipes_path": "",
|
||||
"base_model_path_mappings": {},
|
||||
"download_path_templates": {},
|
||||
"download_filename_templates": {},
|
||||
"folder_paths": {},
|
||||
"extra_folder_paths": {},
|
||||
"example_images_path": "",
|
||||
@@ -1121,6 +1125,15 @@ class SettingsManager:
|
||||
self.settings["civitai_api_key"] = env_api_key
|
||||
self._save_settings()
|
||||
|
||||
# Hugging Face accepts either of its conventional variable names
|
||||
env_hf_token = os.environ.get("HF_TOKEN") or os.environ.get(
|
||||
"HUGGING_FACE_HUB_TOKEN"
|
||||
)
|
||||
if env_hf_token:
|
||||
logger.info("Found HF_TOKEN environment variable")
|
||||
self.settings["huggingface_api_key"] = env_hf_token
|
||||
self._save_settings()
|
||||
|
||||
# LLM provider overrides
|
||||
llm_env_map = {
|
||||
"LLM_API_KEY": "llm_api_key",
|
||||
@@ -1276,6 +1289,7 @@ class SettingsManager:
|
||||
defaults = copy.deepcopy(DEFAULT_SETTINGS)
|
||||
defaults["base_model_path_mappings"] = {}
|
||||
defaults["download_path_templates"] = {}
|
||||
defaults["download_filename_templates"] = {}
|
||||
defaults["priority_tags"] = DEFAULT_PRIORITY_TAG_CONFIG.copy()
|
||||
defaults.setdefault("folder_paths", {})
|
||||
defaults.setdefault("extra_folder_paths", {})
|
||||
@@ -1567,9 +1581,15 @@ class SettingsManager:
|
||||
if resolved:
|
||||
return resolved
|
||||
|
||||
# Fall back to the first tag that is usable as a folder name. The raw
|
||||
# tag list can contain keyword dumps that would become unusable folders
|
||||
# and break path length limits, and Civitai mixes in structural labels
|
||||
# like "base model" that mean nothing as a folder, so skip both (#1119).
|
||||
for tag in tags:
|
||||
if isinstance(tag, str) and tag:
|
||||
return tag
|
||||
if is_civitai_meta_tag(tag):
|
||||
continue
|
||||
if is_usable_path_tag(tag):
|
||||
return tag.strip()
|
||||
return ""
|
||||
|
||||
def get_priority_tag_suggestions(self) -> Dict[str, List[str]]:
|
||||
@@ -2424,6 +2444,49 @@ class SettingsManager:
|
||||
model_type, DEFAULT_DOWNLOAD_PATH_TEMPLATES.get(model_type, "")
|
||||
)
|
||||
|
||||
def get_download_filename_template(self, model_type: str) -> str:
|
||||
"""Get the download filename template for a specific model type.
|
||||
|
||||
Args:
|
||||
model_type: The type of model ('lora', 'checkpoint', 'embedding',
|
||||
'other')
|
||||
|
||||
Returns:
|
||||
Template string for the model type. Empty string (the default for
|
||||
every model type) means downloaded files keep their original
|
||||
filename.
|
||||
"""
|
||||
templates = self.settings.get("download_filename_templates", {})
|
||||
|
||||
# Handle edge case where templates might be stored as JSON string
|
||||
if isinstance(templates, str):
|
||||
try:
|
||||
parsed_templates = json.loads(templates)
|
||||
if isinstance(parsed_templates, dict):
|
||||
self.settings["download_filename_templates"] = parsed_templates
|
||||
self._save_settings()
|
||||
templates = parsed_templates
|
||||
logger.info(
|
||||
"Successfully parsed download_filename_templates from JSON string"
|
||||
)
|
||||
else:
|
||||
raise ValueError("Parsed JSON is not a dictionary")
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
logger.warning(
|
||||
f"Failed to parse download_filename_templates JSON string: {e}. Resetting to empty templates."
|
||||
)
|
||||
templates = {}
|
||||
self.settings["download_filename_templates"] = templates
|
||||
self._save_settings()
|
||||
|
||||
if not isinstance(templates, dict):
|
||||
templates = {}
|
||||
self.settings["download_filename_templates"] = templates
|
||||
self._save_settings()
|
||||
|
||||
template = templates.get(model_type, "")
|
||||
return template if isinstance(template, str) else ""
|
||||
|
||||
|
||||
_SETTINGS_MANAGER: Optional["SettingsManager"] = None
|
||||
_SETTINGS_MANAGER_LOCK = Lock()
|
||||
|
||||
@@ -20,6 +20,7 @@ from .example_images import (
|
||||
ImportExampleImagesUseCase,
|
||||
ImportExampleImagesValidationError,
|
||||
)
|
||||
from .filename_template_use_case import FilenameTemplateUseCase
|
||||
|
||||
__all__ = [
|
||||
"AutoOrganizeInProgressError",
|
||||
@@ -34,4 +35,5 @@ __all__ = [
|
||||
"DownloadExampleImagesUseCase",
|
||||
"ImportExampleImagesUseCase",
|
||||
"ImportExampleImagesValidationError",
|
||||
"FilenameTemplateUseCase",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Filename template use case: bulk-rename library models per the configured template.
|
||||
|
||||
An empty template reverts previously renamed models to the original filename
|
||||
recorded in their ``.metadata.json`` sidecar (``original_file_name``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional, Sequence
|
||||
|
||||
from ...utils.constants import AUTO_ORGANIZE_BATCH_SIZE
|
||||
from ...utils.utils import calculate_filename_for_model
|
||||
from ..model_file_service import AutoOrganizeResult, ProgressCallback
|
||||
from ..model_lifecycle_service import ModelLifecycleService, load_local_metadata
|
||||
from ..settings_manager import get_settings_manager
|
||||
from .auto_organize_use_case import (
|
||||
AutoOrganizeInProgressError,
|
||||
AutoOrganizeLockProvider,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PROGRESS_TYPE = "filename_template_progress"
|
||||
|
||||
|
||||
class FilenameTemplateUseCase:
|
||||
"""Apply the download filename template to existing library models.
|
||||
|
||||
An empty template restores the recorded original filename instead of
|
||||
rendering a template. Shares the auto-organize lock (and its in-progress
|
||||
error) so a bulk rename never runs concurrently with an auto-organize
|
||||
operation.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
scanner,
|
||||
lifecycle_service: ModelLifecycleService,
|
||||
lock_provider: AutoOrganizeLockProvider,
|
||||
model_type: str,
|
||||
metadata_loader: Callable[[str], Awaitable[Dict[str, Any]]] = load_local_metadata,
|
||||
) -> None:
|
||||
self._scanner = scanner
|
||||
self._lifecycle_service = lifecycle_service
|
||||
self._lock_provider = lock_provider
|
||||
self._model_type = model_type
|
||||
self._metadata_loader = metadata_loader
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
*,
|
||||
file_paths: Optional[Sequence[str]] = None,
|
||||
progress_callback: Optional[ProgressCallback] = None,
|
||||
) -> AutoOrganizeResult:
|
||||
"""Run the bulk rename guarded by the shared library-operation lock."""
|
||||
|
||||
is_running = getattr(self._lock_provider, "is_filename_template_running", None)
|
||||
if callable(is_running) and is_running():
|
||||
raise AutoOrganizeInProgressError(
|
||||
"A filename template operation is already running"
|
||||
)
|
||||
if self._lock_provider.is_auto_organize_running():
|
||||
raise AutoOrganizeInProgressError("Auto-organize is already running")
|
||||
|
||||
lock = await self._lock_provider.get_auto_organize_lock()
|
||||
if lock.locked():
|
||||
raise AutoOrganizeInProgressError(
|
||||
"Another library operation is already running"
|
||||
)
|
||||
|
||||
async with lock:
|
||||
return await self._run(
|
||||
file_paths=file_paths, progress_callback=progress_callback
|
||||
)
|
||||
|
||||
async def _run(
|
||||
self,
|
||||
*,
|
||||
file_paths: Optional[Sequence[str]],
|
||||
progress_callback: Optional[ProgressCallback],
|
||||
) -> AutoOrganizeResult:
|
||||
result = AutoOrganizeResult()
|
||||
result.operation_type = "filename_template"
|
||||
|
||||
self._scanner.reset_cancellation()
|
||||
|
||||
try:
|
||||
template = get_settings_manager().get_download_filename_template(
|
||||
self._model_type
|
||||
)
|
||||
|
||||
cache = await self._scanner.get_cached_data()
|
||||
models = list(cache.raw_data)
|
||||
if file_paths:
|
||||
wanted = set(file_paths)
|
||||
models = [
|
||||
model for model in models if model.get("file_path") in wanted
|
||||
]
|
||||
|
||||
result.total = len(models)
|
||||
|
||||
await self._emit_progress(progress_callback, result, "started")
|
||||
|
||||
for index in range(0, result.total, AUTO_ORGANIZE_BATCH_SIZE):
|
||||
if self._scanner.is_cancelled():
|
||||
logger.info(
|
||||
"Filename template apply cancelled for %s", self._model_type
|
||||
)
|
||||
break
|
||||
|
||||
batch = models[index : index + AUTO_ORGANIZE_BATCH_SIZE]
|
||||
for model in batch:
|
||||
if self._scanner.is_cancelled():
|
||||
break
|
||||
await self._process_model(model, template, result)
|
||||
result.processed += 1
|
||||
|
||||
await self._emit_progress(progress_callback, result, "processing")
|
||||
# Yield between batches so the server stays responsive.
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
if self._scanner.is_cancelled():
|
||||
result.status = "cancelled"
|
||||
await self._emit_progress(progress_callback, result, "cancelled")
|
||||
return result
|
||||
|
||||
await self._emit_progress(progress_callback, result, "completed")
|
||||
return result
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Error in filename template apply: %s", exc, exc_info=True)
|
||||
if progress_callback:
|
||||
await progress_callback.on_progress(
|
||||
{
|
||||
"type": _PROGRESS_TYPE,
|
||||
"status": "error",
|
||||
"error": str(exc),
|
||||
"operation_type": result.operation_type,
|
||||
}
|
||||
)
|
||||
raise
|
||||
|
||||
async def _process_model(
|
||||
self,
|
||||
model: Dict[str, Any],
|
||||
template: str,
|
||||
result: AutoOrganizeResult,
|
||||
) -> None:
|
||||
model_name = model.get("model_name", "Unknown")
|
||||
try:
|
||||
file_path = model.get("file_path")
|
||||
if not file_path:
|
||||
self._add_result(result, model_name, False, "No file path found")
|
||||
result.failure_count += 1
|
||||
return
|
||||
|
||||
if not template:
|
||||
# Empty template = revert to the original filename recorded
|
||||
# by the first rename; models without a record are skipped.
|
||||
new_stem = await self._resolve_recorded_original(file_path)
|
||||
else:
|
||||
new_stem = calculate_filename_for_model(model, self._model_type)
|
||||
if not new_stem:
|
||||
result.skipped_count += 1
|
||||
return
|
||||
|
||||
current_stem = os.path.splitext(os.path.basename(file_path))[0]
|
||||
if new_stem == current_stem or os.path.normcase(
|
||||
new_stem
|
||||
) == os.path.normcase(current_stem):
|
||||
result.skipped_count += 1
|
||||
return
|
||||
|
||||
await self._lifecycle_service.rename_model(
|
||||
file_path=file_path, new_file_name=new_stem
|
||||
)
|
||||
result.success_count += 1
|
||||
|
||||
except ValueError as exc:
|
||||
# Conflicts (e.g. target name already exists) count as failures
|
||||
# without aborting the batch.
|
||||
self._add_result(result, model_name, False, str(exc))
|
||||
result.failure_count += 1
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Error applying filename template to %s: %s", model_name, exc,
|
||||
exc_info=True,
|
||||
)
|
||||
self._add_result(result, model_name, False, f"Error: {exc}")
|
||||
result.failure_count += 1
|
||||
|
||||
async def _resolve_recorded_original(self, file_path: str) -> str:
|
||||
"""Return the original filename stem recorded at the first rename.
|
||||
|
||||
Reads the ``.metadata.json`` sidecar; returns an empty string when no
|
||||
sidecar or no ``original_file_name`` entry exists (models never
|
||||
renamed, or renamed before the recording shipped).
|
||||
"""
|
||||
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
|
||||
metadata = await self._metadata_loader(metadata_path)
|
||||
original = metadata.get("original_file_name")
|
||||
if not isinstance(original, str):
|
||||
return ""
|
||||
return original.strip()
|
||||
|
||||
async def _emit_progress(
|
||||
self,
|
||||
progress_callback: Optional[ProgressCallback],
|
||||
result: AutoOrganizeResult,
|
||||
status: str,
|
||||
) -> None:
|
||||
if not progress_callback:
|
||||
return
|
||||
await progress_callback.on_progress(
|
||||
{
|
||||
"type": _PROGRESS_TYPE,
|
||||
"status": status,
|
||||
"total": result.total,
|
||||
"processed": result.processed,
|
||||
"success": result.success_count,
|
||||
"failures": result.failure_count,
|
||||
"skipped": result.skipped_count,
|
||||
"operation_type": result.operation_type,
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _add_result(
|
||||
result: AutoOrganizeResult,
|
||||
model_name: str,
|
||||
success: bool,
|
||||
message: str,
|
||||
) -> None:
|
||||
"""Add a result entry if under the limit (mirrors ModelFileService)."""
|
||||
if len(result.results) < 100:
|
||||
result.results.append(
|
||||
{"model": model_name, "success": success, "message": message}
|
||||
)
|
||||
elif len(result.results) == 100:
|
||||
result.results_truncated = True
|
||||
result.sample_results = result.results[:50]
|
||||
@@ -20,6 +20,8 @@ class WebSocketManager:
|
||||
self._last_init_progress: Dict[str, Dict[str, Any]] = {}
|
||||
# Add auto-organize progress tracking
|
||||
self._auto_organize_progress: Optional[Dict[str, Any]] = None
|
||||
# Add filename template progress tracking
|
||||
self._filename_template_progress: Optional[Dict[str, Any]] = None
|
||||
# Add recipe rematch progress tracking
|
||||
self._recipe_rematch_progress: Optional[Dict[str, Any]] = None
|
||||
self._auto_organize_lock = asyncio.Lock()
|
||||
@@ -205,6 +207,26 @@ class WebSocketManager:
|
||||
def cleanup_auto_organize_progress(self):
|
||||
"""Clear auto-organize progress data"""
|
||||
self._auto_organize_progress = None
|
||||
|
||||
async def broadcast_filename_template_progress(self, data: Dict[str, Any]):
|
||||
"""Broadcast filename template progress to connected clients"""
|
||||
self._filename_template_progress = data
|
||||
await self.broadcast(data)
|
||||
|
||||
def get_filename_template_progress(self) -> Optional[Dict[str, Any]]:
|
||||
"""Get current filename template progress"""
|
||||
return self._filename_template_progress
|
||||
|
||||
def cleanup_filename_template_progress(self):
|
||||
"""Clear filename template progress data"""
|
||||
self._filename_template_progress = None
|
||||
|
||||
def is_filename_template_running(self) -> bool:
|
||||
"""Check if a filename template operation is currently running"""
|
||||
if not self._filename_template_progress:
|
||||
return False
|
||||
status = self._filename_template_progress.get('status')
|
||||
return status in ['started', 'processing']
|
||||
|
||||
async def broadcast_recipe_rematch_progress(self, data: Dict[str, Any]):
|
||||
"""Broadcast recipe rematch progress to connected clients"""
|
||||
|
||||
@@ -21,6 +21,14 @@ class WebSocketProgressCallback(ProgressCallback):
|
||||
await ws_manager.broadcast_auto_organize_progress(progress_data)
|
||||
|
||||
|
||||
class WebSocketFilenameTemplateProgressCallback(ProgressCallback):
|
||||
"""WebSocket progress callback for filename template operations."""
|
||||
|
||||
async def on_progress(self, progress_data: Dict[str, Any]) -> None:
|
||||
"""Send filename template progress via WebSocket."""
|
||||
await ws_manager.broadcast_filename_template_progress(progress_data)
|
||||
|
||||
|
||||
class WebSocketBroadcastCallback:
|
||||
"""Generic WebSocket progress callback broadcasting to all clients."""
|
||||
|
||||
|
||||
@@ -39,6 +39,51 @@ def contains_dynamic_syntax(text: str) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _is_prompt_link(value: Any) -> bool:
|
||||
"""Return True for ComfyUI prompt-graph links ([node_id, output_index])."""
|
||||
|
||||
return (
|
||||
isinstance(value, list)
|
||||
and len(value) == 2
|
||||
and isinstance(value[0], str)
|
||||
and isinstance(value[1], (int, float))
|
||||
)
|
||||
|
||||
|
||||
def linked_text_requires_rerun(prompt: Any, node_id: Any, input_name: str) -> bool:
|
||||
"""Decide if a linked text input forces re-execution for dynamic expansion.
|
||||
|
||||
IS_CHANGED only receives constant inputs, so a linked text arrives as None.
|
||||
This walks the prompt graph to the upstream node and returns False only
|
||||
when that node is fully constant and free of dynamic syntax. Dynamic
|
||||
syntax — or anything that cannot be statically resolved — returns True.
|
||||
"""
|
||||
|
||||
if not isinstance(prompt, dict) or node_id is None:
|
||||
return True
|
||||
node = prompt.get(str(node_id))
|
||||
if not isinstance(node, dict):
|
||||
return True
|
||||
inputs = node.get("inputs")
|
||||
if not isinstance(inputs, dict):
|
||||
return True
|
||||
value = inputs.get(input_name)
|
||||
if not _is_prompt_link(value):
|
||||
return contains_dynamic_syntax(value)
|
||||
upstream = prompt.get(value[0])
|
||||
if not isinstance(upstream, dict):
|
||||
return True
|
||||
upstream_inputs = upstream.get("inputs")
|
||||
if not isinstance(upstream_inputs, dict):
|
||||
return True
|
||||
for upstream_value in upstream_inputs.values():
|
||||
if _is_prompt_link(upstream_value):
|
||||
return True
|
||||
if contains_dynamic_syntax(upstream_value):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_wildcards_dir(create: bool = False) -> str:
|
||||
"""Return the managed wildcard directory inside the settings folder."""
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -5,6 +5,8 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Iterable, List, Optional, Sequence, Set
|
||||
|
||||
from .constants import CIVITAI_META_TAGS, MAX_PATH_TAG_LENGTH
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PriorityTagEntry:
|
||||
@@ -102,3 +104,43 @@ def collect_canonical_tags(entries: Iterable[PriorityTagEntry]) -> List[str]:
|
||||
"""Return the ordered list of canonical tags from the parsed entries."""
|
||||
|
||||
return [entry.canonical for entry in entries]
|
||||
|
||||
|
||||
def is_usable_path_tag(tag: object) -> bool:
|
||||
"""Return True when a tag is a sane single-concept folder-name candidate.
|
||||
|
||||
CivitAI tags are normally short labels ("character", "anime"), but some
|
||||
uploaders dump their whole keyword list into a single tag, e.g.
|
||||
``"lora, character, rosie, irish, ... face"``. Using such a tag as a folder
|
||||
name produces unwieldy and path-length-breaking directories (#1119), so
|
||||
tag-derived path segments only accept single-concept tags.
|
||||
"""
|
||||
|
||||
if not isinstance(tag, str):
|
||||
return False
|
||||
|
||||
candidate = tag.strip()
|
||||
if not candidate:
|
||||
return False
|
||||
|
||||
# Commas mean the tag is a keyword dump rather than one concept.
|
||||
if "," in candidate:
|
||||
return False
|
||||
|
||||
return len(candidate) <= MAX_PATH_TAG_LENGTH
|
||||
|
||||
|
||||
def is_civitai_meta_tag(tag: object) -> bool:
|
||||
"""Return True for Civitai labels that describe the listing, not content.
|
||||
|
||||
Civitai attaches structural tags such as "base model" to the same list as
|
||||
real content tags. They carry no organisational meaning, so the automatic
|
||||
fallback must not turn one into a folder name. A user who does want such a
|
||||
folder can still put the label in their priority tag list, because explicit
|
||||
priority matches bypass this check.
|
||||
"""
|
||||
|
||||
if not isinstance(tag, str):
|
||||
return False
|
||||
|
||||
return tag.strip().casefold() in CIVITAI_META_TAGS
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Helpers for generating URLs that survive reverse-proxy subpath mounts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def relative_root_prefix(request_path: str) -> str:
|
||||
"""Return the relative prefix ("", "../", ...) that takes a manager page
|
||||
back to the mount root.
|
||||
|
||||
Templates reference assets and pages with relative URLs (e.g.
|
||||
``{{ rel_prefix }}loras_static/...``) so the browser keeps whatever
|
||||
subpath a reverse proxy (llama-swap, SwarmUI, ...) mounted ComfyUI under.
|
||||
The backend only ever sees the stripped path, so the depth of the page
|
||||
route is all that matters: "/loras" -> "", "/loras/recipes" -> "../".
|
||||
"""
|
||||
|
||||
segments = [segment for segment in request_path.split("/") if segment]
|
||||
return "../" * max(len(segments) - 1, 0)
|
||||
+151
-3
@@ -1,12 +1,20 @@
|
||||
from difflib import SequenceMatcher
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..config import config
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
from .constants import (
|
||||
MAX_FILENAME_STEM_LENGTH,
|
||||
MAX_FOLDER_NAME_LENGTH,
|
||||
MAX_PATH_TAG_LENGTH,
|
||||
)
|
||||
import asyncio
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_lora_info(lora_name):
|
||||
"""Get the lora path and trigger words from cache"""
|
||||
@@ -111,6 +119,16 @@ def get_lora_info_absolute(lora_name):
|
||||
scanner = await ServiceRegistry.get_lora_scanner()
|
||||
cache = await scanner.get_cached_data()
|
||||
|
||||
# Stack producers can resolve an exact business path. Preserve it even
|
||||
# when several indexed LoRAs share the same basename.
|
||||
if os.path.isabs(lora_name):
|
||||
for item in cache.raw_data:
|
||||
file_path = item.get("file_path")
|
||||
if file_path and os.path.abspath(file_path) == os.path.abspath(lora_name):
|
||||
civitai = item.get("civitai") or {}
|
||||
return file_path, civitai.get("trainedWords", [])
|
||||
return lora_name, []
|
||||
|
||||
lora_name_normalized = lora_name.replace("\\", "/")
|
||||
lora_name_no_ext = lora_name_normalized
|
||||
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||
@@ -414,12 +432,17 @@ def fuzzy_match(text: str, pattern: str, threshold: float = 0.85) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def sanitize_folder_name(name: str, replacement: str = "_") -> str:
|
||||
def sanitize_folder_name(
|
||||
name: str, replacement: str = "_", max_length: Optional[int] = None
|
||||
) -> str:
|
||||
"""Sanitize a folder name by removing or replacing invalid characters.
|
||||
|
||||
Args:
|
||||
name: The original folder name.
|
||||
replacement: The character to use when replacing invalid characters.
|
||||
max_length: Optional maximum length for the resulting name. Longer
|
||||
names are truncated (and re-trimmed) so that a single untrusted
|
||||
value cannot blow past filesystem path limits.
|
||||
|
||||
Returns:
|
||||
A sanitized folder name safe to use across common filesystems.
|
||||
@@ -446,6 +469,15 @@ def sanitize_folder_name(name: str, replacement: str = "_") -> str:
|
||||
# If no replacement, just strip spaces and dots from right, spaces from left
|
||||
sanitized = sanitized.rstrip(" .").lstrip(" ")
|
||||
|
||||
if max_length is not None and max_length > 0 and len(sanitized) > max_length:
|
||||
sanitized = sanitized[:max_length]
|
||||
# Re-trim separators and spaces exposed by the cut so the truncated
|
||||
# name stays filesystem-safe.
|
||||
if replacement:
|
||||
sanitized = sanitized.rstrip(" ." + replacement).lstrip(" " + replacement)
|
||||
else:
|
||||
sanitized = sanitized.rstrip(" .").lstrip(" ")
|
||||
|
||||
if not sanitized:
|
||||
return "unnamed"
|
||||
|
||||
@@ -572,12 +604,20 @@ def calculate_relative_path_for_model(
|
||||
if not first_tag:
|
||||
first_tag = "no tags" # Default if no tags available
|
||||
|
||||
# Tags are user-generated on CivitAI, so sanitize the value before it
|
||||
# becomes a path segment and cap its length (#1119).
|
||||
first_tag = sanitize_folder_name(first_tag, max_length=MAX_PATH_TAG_LENGTH)
|
||||
|
||||
# Format the template with available data
|
||||
model_name = sanitize_folder_name(model_data.get("model_name", ""))
|
||||
model_name = sanitize_folder_name(
|
||||
model_data.get("model_name", ""), max_length=MAX_FOLDER_NAME_LENGTH
|
||||
)
|
||||
version_name = ""
|
||||
|
||||
if isinstance(civitai_data, dict):
|
||||
version_name = sanitize_folder_name(civitai_data.get("name") or "")
|
||||
version_name = sanitize_folder_name(
|
||||
civitai_data.get("name") or "", max_length=MAX_FOLDER_NAME_LENGTH
|
||||
)
|
||||
|
||||
formatted_path = path_template
|
||||
formatted_path = formatted_path.replace("{base_model}", mapped_base_model)
|
||||
@@ -598,6 +638,114 @@ def calculate_relative_path_for_model(
|
||||
return formatted_path
|
||||
|
||||
|
||||
def calculate_filename_for_model(
|
||||
model_data: Dict[str, Any], model_type: str = "lora"
|
||||
) -> str:
|
||||
"""Calculate the filename stem for a model using the filename template.
|
||||
|
||||
Mirrors the data extraction of :func:`calculate_relative_path_for_model`
|
||||
but renders a single filename (no path segments). Missing values resolve
|
||||
to empty segments instead of the path-oriented defaults ("Anonymous" /
|
||||
"no tags") so templates degrade gracefully.
|
||||
|
||||
Args:
|
||||
model_data: Model data from scanner cache
|
||||
model_type: Type of model ('lora', 'checkpoint', 'embedding')
|
||||
|
||||
Returns:
|
||||
Sanitized filename stem without extension, or an empty string when no
|
||||
template is configured, the template is invalid, or the rendered name
|
||||
is empty.
|
||||
"""
|
||||
settings_manager = get_settings_manager()
|
||||
template = settings_manager.get_download_filename_template(model_type)
|
||||
|
||||
if not template:
|
||||
return ""
|
||||
|
||||
# A filename template must render a single name, never folder segments.
|
||||
if "/" in template or "\\" in template:
|
||||
logger.warning(
|
||||
"Filename template for %s contains a path separator and is ignored: %r",
|
||||
model_type,
|
||||
template,
|
||||
)
|
||||
return ""
|
||||
|
||||
civitai_data = model_data.get("civitai", {})
|
||||
|
||||
author = ""
|
||||
if isinstance(civitai_data, dict) and civitai_data.get("id") is not None:
|
||||
creator_info = civitai_data.get("creator") or {}
|
||||
author = creator_info.get("username") or ""
|
||||
|
||||
base_model = model_data.get("base_model", "")
|
||||
base_model_mappings = settings_manager.get("base_model_path_mappings", {})
|
||||
mapped_base_model = base_model_mappings.get(base_model, base_model)
|
||||
|
||||
lowercase_tags = [
|
||||
tag.lower() for tag in model_data.get("tags", []) if isinstance(tag, str)
|
||||
]
|
||||
first_tag = settings_manager.resolve_priority_tag_for_model(
|
||||
lowercase_tags, model_type
|
||||
)
|
||||
|
||||
model_name = model_data.get("model_name", "")
|
||||
version_name = ""
|
||||
if isinstance(civitai_data, dict):
|
||||
version_name = civitai_data.get("name") or ""
|
||||
|
||||
sha256 = model_data.get("sha256") or ""
|
||||
hash_short = sha256[:10].lower() if isinstance(sha256, str) else ""
|
||||
|
||||
file_path = model_data.get("file_path") or ""
|
||||
if isinstance(file_path, str) and file_path:
|
||||
original_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
else:
|
||||
original_name = os.path.splitext(str(model_data.get("file_name", "")))[0]
|
||||
|
||||
def _sanitize_value(value: Any, max_length: Optional[int] = None) -> str:
|
||||
# sanitize_folder_name falls back to "unnamed" for empty input; for
|
||||
# templates an empty value must stay empty so segments collapse.
|
||||
text = str(value) if value else ""
|
||||
if not text:
|
||||
return ""
|
||||
return sanitize_folder_name(text, max_length=max_length)
|
||||
|
||||
replacements = {
|
||||
"{model_name}": _sanitize_value(model_name, MAX_FILENAME_STEM_LENGTH),
|
||||
"{version_name}": _sanitize_value(version_name, MAX_FILENAME_STEM_LENGTH),
|
||||
"{base_model}": _sanitize_value(mapped_base_model, MAX_FILENAME_STEM_LENGTH),
|
||||
"{author}": _sanitize_value(author, MAX_FILENAME_STEM_LENGTH),
|
||||
"{first_tag}": _sanitize_value(first_tag, MAX_PATH_TAG_LENGTH),
|
||||
"{hash_short}": hash_short,
|
||||
"{original_name}": _sanitize_value(original_name, MAX_FILENAME_STEM_LENGTH),
|
||||
}
|
||||
|
||||
result = template
|
||||
for placeholder, value in replacements.items():
|
||||
result = result.replace(placeholder, value)
|
||||
|
||||
if model_type == "embedding":
|
||||
result = result.replace(" ", "_")
|
||||
|
||||
# Strip characters that are illegal in filenames on common filesystems.
|
||||
result = re.sub(r'[:*?"<>|]', "", result)
|
||||
# Collapse runs of identical separators introduced by empty substitutions.
|
||||
result = re.sub(r"([-_. ])\1+", r"\1", result)
|
||||
# Drop separators left dangling next to each other ("- -" -> "-").
|
||||
result = re.sub(r" ?([-_.]) (?=[-_.])", r"\1", result)
|
||||
# A stem must not start or end with separators, spaces or dots.
|
||||
result = result.strip("-_. ")
|
||||
|
||||
# A template can concatenate several values, so cap the rendered stem as
|
||||
# well and re-trim the cut.
|
||||
if len(result) > MAX_FILENAME_STEM_LENGTH:
|
||||
result = result[:MAX_FILENAME_STEM_LENGTH].strip("-_. ")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def remove_empty_dirs(path):
|
||||
"""Recursively remove empty directories starting from the given path.
|
||||
|
||||
|
||||
+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",
|
||||
|
||||
@@ -27,6 +27,12 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Self-managed by SettingsManager: stacks above the settings modal like the
|
||||
directory picker (settings panels sit at 10000/10002). */
|
||||
#filenameTemplateConfirmModal {
|
||||
z-index: 10010;
|
||||
}
|
||||
|
||||
.delete-modal-content {
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
|
||||
@@ -122,6 +122,9 @@ export function getApiEndpoints(modelType) {
|
||||
autoOrganize: `/api/lm/${modelType}/auto-organize`,
|
||||
autoOrganizeProgress: `/api/lm/${modelType}/auto-organize-progress`,
|
||||
|
||||
// Filename template operations
|
||||
applyFilenameTemplate: `/api/lm/${modelType}/apply-filename-template`,
|
||||
|
||||
// Model-specific endpoints (will be merged with specific configs)
|
||||
specific: {}
|
||||
};
|
||||
|
||||
@@ -2175,6 +2175,135 @@ export class BaseModelApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the configured download filename template to models, renaming their files
|
||||
* @param {Array} filePaths - Optional array of file paths to rename. If not provided, applies to all models.
|
||||
* @returns {Promise} - Promise that resolves when the operation is complete
|
||||
*/
|
||||
async applyFilenameTemplate(filePaths = null) {
|
||||
let ws = null;
|
||||
|
||||
await state.loadingManager.showWithProgress(async (loading) => {
|
||||
loading.showCancelButton(() => this.cancelTask());
|
||||
try {
|
||||
// Connect to WebSocket for progress updates
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
ws = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`);
|
||||
|
||||
const operationComplete = new Promise((resolve, reject) => {
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type !== 'filename_template_progress') return;
|
||||
|
||||
switch (data.status) {
|
||||
case 'started':
|
||||
loading.setProgress(0);
|
||||
const operationType = data.operation_type === 'bulk' ? 'selected models' : 'all models';
|
||||
loading.setStatus(translate('loras.bulkOperations.filenameTemplateProgress.starting', { type: operationType }, `Applying filename template to ${operationType}...`));
|
||||
break;
|
||||
|
||||
case 'processing':
|
||||
const percent = data.total > 0 ? ((data.processed / data.total) * 90).toFixed(1) : 0;
|
||||
loading.setProgress(percent);
|
||||
loading.setStatus(
|
||||
translate('loras.bulkOperations.filenameTemplateProgress.processing', {
|
||||
processed: data.processed,
|
||||
total: data.total,
|
||||
success: data.success,
|
||||
failures: data.failures,
|
||||
skipped: data.skipped
|
||||
}, `Processing (${data.processed}/${data.total}) - ${data.success} renamed, ${data.skipped} skipped, ${data.failures} failed`)
|
||||
);
|
||||
break;
|
||||
|
||||
case 'completed':
|
||||
loading.setProgress(100);
|
||||
loading.setStatus(
|
||||
translate('loras.bulkOperations.filenameTemplateProgress.completed', {
|
||||
success: data.success,
|
||||
skipped: data.skipped,
|
||||
failures: data.failures,
|
||||
total: data.total
|
||||
}, `Completed: ${data.success} renamed, ${data.skipped} skipped, ${data.failures} failed`)
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
resolve(data);
|
||||
}, 1500);
|
||||
break;
|
||||
|
||||
case 'cancelled':
|
||||
loading.setStatus(translate('toast.api.operationCancelled', {}, 'Operation cancelled by user'));
|
||||
resolve(data);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
loading.setStatus(translate('loras.bulkOperations.filenameTemplateProgress.error', { error: data.error }, `Error: ${data.error}`));
|
||||
reject(new Error(data.error));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error during filename template apply:', error);
|
||||
reject(new Error('Connection error'));
|
||||
};
|
||||
});
|
||||
|
||||
// Start the filename template operation
|
||||
const endpoint = this.apiConfig.endpoints.applyFilenameTemplate;
|
||||
|
||||
const requestBody = {};
|
||||
if (filePaths) {
|
||||
requestBody.file_paths = filePaths;
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || 'Failed to start filename template operation');
|
||||
}
|
||||
|
||||
// Wait for the operation to complete via WebSocket
|
||||
const result = await operationComplete;
|
||||
|
||||
// Show appropriate success message based on results
|
||||
if (result.status === 'cancelled') {
|
||||
showToast('toast.api.operationCancelledPartial', { success: result.success, total: result.total }, 'info');
|
||||
} else if (result.failures === 0) {
|
||||
showToast('toast.loras.filenameTemplateSuccess', {
|
||||
count: result.success,
|
||||
type: result.operation_type === 'bulk' ? 'selected models' : 'all models'
|
||||
}, 'success');
|
||||
} else {
|
||||
showToast('toast.loras.filenameTemplatePartialSuccess', {
|
||||
success: result.success,
|
||||
failures: result.failures,
|
||||
total: result.total
|
||||
}, 'warning');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error applying filename template:', error);
|
||||
showToast('toast.loras.filenameTemplateFailed', { error: error.message }, 'error');
|
||||
throw error;
|
||||
} finally {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.close();
|
||||
}
|
||||
}
|
||||
}, {
|
||||
initialMessage: translate('loras.bulkOperations.filenameTemplateProgress.initializing', {}, 'Initializing filename template apply...'),
|
||||
completionMessage: translate('loras.bulkOperations.filenameTemplateProgress.complete', {}, 'Filename template apply complete')
|
||||
});
|
||||
}
|
||||
|
||||
async stopExampleImages() {
|
||||
try {
|
||||
const response = await fetch('/api/lm/stop-example-images', {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { moveManager } from '../../managers/MoveManager.js';
|
||||
import { rematchModalManager } from '../../managers/RematchModalManager.js';
|
||||
import { showRematchSummary } from '../RematchSummaryModal.js';
|
||||
import { probeExtension, delegateReimport, getCivitaiImageInfo } from '../../utils/extensionReimportBridge.js';
|
||||
import { withBasePath } from '../../utils/basePath.js';
|
||||
|
||||
export class RecipeContextMenu extends BaseContextMenu {
|
||||
constructor() {
|
||||
@@ -180,7 +181,7 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
setSessionItem('filterRecipeName', recipe.title);
|
||||
|
||||
// Navigate to the LoRAs page
|
||||
window.location.href = '/loras';
|
||||
window.location.href = withBasePath('/loras');
|
||||
} else {
|
||||
showToast('recipes.contextMenu.viewLoras.noLorasFound', {}, 'info');
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { state } from '../state/index.js';
|
||||
import { setSessionItem, removeSessionItem, getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||
import { fetchRecipeDetails, updateRecipeMetadata, sendRecipeWorkflow, extractRecipeId } from '../api/recipeApi.js';
|
||||
import { downloadManager } from '../managers/DownloadManager.js';
|
||||
import { withBasePath } from '../utils/basePath.js';
|
||||
import { MODEL_TYPES } from '../api/apiConfig.js';
|
||||
import { openMediaViewer } from './shared/MediaViewer.js';
|
||||
import { showRecipeDeleteConfirmation } from './RecipeCard.js';
|
||||
@@ -3231,7 +3232,7 @@ class RecipeModal {
|
||||
setSessionItem('filterCheckpointRecipeName', this.currentRecipe.title);
|
||||
}
|
||||
|
||||
window.location.href = '/checkpoints';
|
||||
window.location.href = withBasePath('/checkpoints');
|
||||
}
|
||||
|
||||
_getCheckpointHash(checkpoint) {
|
||||
@@ -3281,7 +3282,7 @@ class RecipeModal {
|
||||
}
|
||||
|
||||
// Navigate to the LoRAs page
|
||||
window.location.href = '/loras';
|
||||
window.location.href = withBasePath('/loras');
|
||||
}
|
||||
|
||||
// Only in-library LoRA items are row-navigable: the row opens the local
|
||||
|
||||
@@ -522,7 +522,7 @@ export function createModelCard(model, modelType) {
|
||||
card.dataset.modelId = modelId;
|
||||
} else {
|
||||
// For externally-sourced models, derive a group key from the source
|
||||
// URL for version grouping (hf:user/repo, ms:user/repo, ta:<id>).
|
||||
// identity for version grouping (ms:<model_id>, ta:<id>).
|
||||
const sourceGroupKey = getModelSourceGroupKey(model);
|
||||
if (sourceGroupKey) {
|
||||
card.dataset.modelId = sourceGroupKey;
|
||||
|
||||
@@ -994,9 +994,9 @@ export function initVersionsTab({
|
||||
renderErrorState(container, translate('modals.model.versions.missingModelId', {}, 'This model is missing a Civitai model id.'));
|
||||
return;
|
||||
}
|
||||
// External source group keys (e.g. "hf:user/repo", "ms:user/repo",
|
||||
// "ta:8278...") are not real CivitAI model IDs — skip the remote API
|
||||
// call and show a helpful message instead.
|
||||
// External source group keys (e.g. "ms:12345", "ta:8278...") are
|
||||
// not real CivitAI model IDs — skip the remote API call and show a
|
||||
// helpful message instead.
|
||||
const sourceGroup = parseModelSourceGroupKey(modelId);
|
||||
if (sourceGroup) {
|
||||
controller.isLoading = false;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
|
||||
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
|
||||
import { withBasePath } from '../../utils/basePath.js';
|
||||
|
||||
/**
|
||||
* Loads recipes that use the specified model and renders them in the tab.
|
||||
@@ -356,7 +357,7 @@ function navigateToRecipesPage({ modelKind, displayName, modelHash }) {
|
||||
}
|
||||
|
||||
// Directly navigate to recipes page
|
||||
window.location.href = '/loras/recipes';
|
||||
window.location.href = withBasePath('/loras/recipes');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -378,7 +379,7 @@ function navigateToRecipeDetails(recipeId) {
|
||||
setSessionItem('viewRecipeId', recipeId);
|
||||
|
||||
// Directly navigate to recipes page
|
||||
window.location.href = '/loras/recipes';
|
||||
window.location.href = withBasePath('/loras/recipes');
|
||||
}
|
||||
|
||||
function getRecipesEndpoint(modelKind) {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { modalManager } from './ModalManager.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { state, createDefaultSettings } from '../state/index.js';
|
||||
import { resetAndReload } from '../api/modelApiFactory.js';
|
||||
import { resetAndReload, getModelApiClient } from '../api/modelApiFactory.js';
|
||||
import {
|
||||
DOWNLOAD_PATH_TEMPLATES,
|
||||
MAPPABLE_BASE_MODELS,
|
||||
PATH_TEMPLATE_PLACEHOLDERS,
|
||||
DEFAULT_PATH_TEMPLATES,
|
||||
FILENAME_TEMPLATE_PLACEHOLDERS,
|
||||
DEFAULT_FILENAME_TEMPLATES,
|
||||
DEFAULT_PRIORITY_TAG_CONFIG,
|
||||
getMappableBaseModelsDynamic
|
||||
} from '../utils/constants.js';
|
||||
@@ -36,6 +38,13 @@ const OTHER_SUB_TYPE_LABEL_KEYS = {
|
||||
controlnet: 'settings.folderSettings.subTypeControlnet',
|
||||
};
|
||||
|
||||
// Singular filename-template model type -> plural API model type (MODEL_TYPES)
|
||||
const FILENAME_TEMPLATE_MODEL_TYPES = {
|
||||
lora: 'loras',
|
||||
checkpoint: 'checkpoints',
|
||||
embedding: 'embeddings',
|
||||
};
|
||||
|
||||
export class SettingsManager {
|
||||
constructor() {
|
||||
this.initialized = false;
|
||||
@@ -147,6 +156,25 @@ export class SettingsManager {
|
||||
|
||||
merged.download_path_templates = { ...DEFAULT_PATH_TEMPLATES, ...templates };
|
||||
|
||||
let filenameTemplates = backendSettings?.download_filename_templates;
|
||||
if (typeof filenameTemplates === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(filenameTemplates);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
filenameTemplates = parsed;
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.warn('Failed to parse download_filename_templates string from backend, using defaults');
|
||||
filenameTemplates = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!filenameTemplates || typeof filenameTemplates !== 'object' || Array.isArray(filenameTemplates)) {
|
||||
filenameTemplates = {};
|
||||
}
|
||||
|
||||
merged.download_filename_templates = { ...DEFAULT_FILENAME_TEMPLATES, ...filenameTemplates };
|
||||
|
||||
const priorityTags = backendSettings?.priority_tags;
|
||||
const normalizedPriority = { ...DEFAULT_PRIORITY_TAG_CONFIG };
|
||||
if (priorityTags && typeof priorityTags === 'object' && !Array.isArray(priorityTags)) {
|
||||
@@ -428,6 +456,30 @@ export class SettingsManager {
|
||||
}
|
||||
});
|
||||
|
||||
['lora', 'checkpoint', 'embedding'].forEach(modelType => {
|
||||
const filenameInput = document.getElementById(`${modelType}FilenameTemplate`);
|
||||
if (filenameInput) {
|
||||
filenameInput.addEventListener('input', (e) => {
|
||||
const template = e.target.value;
|
||||
settingsManager.validateFilenameTemplate(modelType, template);
|
||||
settingsManager.updateFilenamePreview(modelType, template);
|
||||
});
|
||||
|
||||
filenameInput.addEventListener('blur', (e) => {
|
||||
const template = e.target.value;
|
||||
if (settingsManager.validateFilenameTemplate(modelType, template)) {
|
||||
settingsManager.updateFilenameTemplate(modelType, template);
|
||||
}
|
||||
});
|
||||
|
||||
filenameInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.target.blur();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const autoOrganizeInput = document.getElementById('autoOrganizeExclusions');
|
||||
if (autoOrganizeInput) {
|
||||
autoOrganizeInput.addEventListener('keydown', (event) => {
|
||||
@@ -876,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 ──────────────────────────────────────
|
||||
@@ -1142,6 +1195,9 @@ export class SettingsManager {
|
||||
// Load download path templates
|
||||
this.loadDownloadPathTemplates();
|
||||
|
||||
// Load download filename templates
|
||||
this.loadFilenameTemplates();
|
||||
|
||||
// Load priority tag settings
|
||||
this.loadPriorityTagSettings();
|
||||
|
||||
@@ -2797,6 +2853,218 @@ export class SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
loadFilenameTemplates() {
|
||||
const templates = state.global.settings.download_filename_templates || DEFAULT_FILENAME_TEMPLATES;
|
||||
|
||||
['lora', 'checkpoint', 'embedding'].forEach(modelType => {
|
||||
const input = document.getElementById(`${modelType}FilenameTemplate`);
|
||||
if (!input) return;
|
||||
|
||||
const template = templates[modelType] || '';
|
||||
input.value = template;
|
||||
this.validateFilenameTemplate(modelType, template);
|
||||
this.updateFilenamePreview(modelType, template);
|
||||
});
|
||||
}
|
||||
|
||||
validateFilenameTemplate(modelType, template) {
|
||||
const validationElement = document.getElementById(`${modelType}FilenameValidation`);
|
||||
if (!validationElement) return true;
|
||||
|
||||
// Reset validation state
|
||||
validationElement.innerHTML = '';
|
||||
validationElement.className = 'template-validation';
|
||||
|
||||
if (!template) {
|
||||
validationElement.innerHTML = `<i class="fas fa-check"></i> ${translate('settings.filenameTemplates.validation.restoreOriginal', {}, 'Valid (empty template restores original filenames)')}`;
|
||||
validationElement.classList.add('valid');
|
||||
return true;
|
||||
}
|
||||
|
||||
// A filename stem cannot contain path separators or OS-illegal characters
|
||||
const invalidChars = /[/\\<>:"|?*]/;
|
||||
if (invalidChars.test(template)) {
|
||||
validationElement.innerHTML = `<i class="fas fa-times"></i> ${translate('settings.filenameTemplates.validation.invalidChars', {}, 'Invalid characters detected (a filename cannot contain / \\ < > : " | ? *)')}`;
|
||||
validationElement.classList.add('invalid');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract placeholders
|
||||
const placeholderRegex = /\{([^}]+)\}/g;
|
||||
const matches = template.match(placeholderRegex) || [];
|
||||
|
||||
// Check for invalid placeholders
|
||||
const invalidPlaceholders = matches.filter(match =>
|
||||
!FILENAME_TEMPLATE_PLACEHOLDERS.includes(match)
|
||||
);
|
||||
|
||||
if (invalidPlaceholders.length > 0) {
|
||||
validationElement.innerHTML = `<i class="fas fa-times"></i> ${translate('settings.filenameTemplates.validation.invalidPlaceholder', { placeholder: invalidPlaceholders[0] }, `Invalid placeholder: ${invalidPlaceholders[0]}`)}`;
|
||||
validationElement.classList.add('invalid');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Template is valid
|
||||
validationElement.innerHTML = `<i class="fas fa-check"></i> ${translate('settings.filenameTemplates.validation.validTemplate', {}, 'Valid template')}`;
|
||||
validationElement.classList.add('valid');
|
||||
return true;
|
||||
}
|
||||
|
||||
updateFilenameTemplate(modelType, template) {
|
||||
if (!this.validateFilenameTemplate(modelType, template)) {
|
||||
return; // Don't save invalid templates
|
||||
}
|
||||
|
||||
// Update state
|
||||
if (!state.global.settings.download_filename_templates) {
|
||||
state.global.settings.download_filename_templates = { ...DEFAULT_FILENAME_TEMPLATES };
|
||||
}
|
||||
state.global.settings.download_filename_templates[modelType] = template;
|
||||
|
||||
// Update preview
|
||||
this.updateFilenamePreview(modelType, template);
|
||||
|
||||
// Save settings
|
||||
this.saveFilenameTemplates();
|
||||
}
|
||||
|
||||
updateFilenamePreview(modelType, template) {
|
||||
const previewElement = document.getElementById(`${modelType}FilenamePreview`);
|
||||
if (!previewElement) return;
|
||||
|
||||
if (!template) {
|
||||
// Empty template restores the recorded original filename
|
||||
previewElement.textContent = 'V1.safetensors';
|
||||
} else {
|
||||
const exampleStem = template
|
||||
.replaceAll('{model_name}', 'model-name')
|
||||
.replaceAll('{version_name}', 'v3')
|
||||
.replaceAll('{base_model}', 'Flux.1 D')
|
||||
.replaceAll('{author}', 'authorname')
|
||||
.replaceAll('{first_tag}', 'style')
|
||||
.replaceAll('{hash_short}', 'a1b2c3d4e5')
|
||||
.replaceAll('{original_name}', 'V1');
|
||||
previewElement.textContent = `${exampleStem}.safetensors`;
|
||||
}
|
||||
previewElement.style.display = 'block';
|
||||
}
|
||||
|
||||
async saveFilenameTemplates() {
|
||||
try {
|
||||
// Save to backend using universal save method
|
||||
await this.saveSetting('download_filename_templates', state.global.settings.download_filename_templates);
|
||||
|
||||
showToast('toast.settings.filenameTemplatesUpdated', {}, 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error saving download filename templates:', error);
|
||||
showToast('toast.settings.filenameTemplatesFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the bulk apply/revert via a dedicated modal. Self-managed (NOT
|
||||
* through ModalManager): it stacks above the settings modal, and
|
||||
* ModalManager's "close current modal on open" behavior would kill the
|
||||
* settings modal underneath. Resolves true when the user confirms.
|
||||
*/
|
||||
confirmFilenameTemplateApply(isRevert) {
|
||||
const modalElement = document.getElementById('filenameTemplateConfirmModal');
|
||||
if (!modalElement) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
const titleElement = modalElement.querySelector('[data-role="title"]');
|
||||
if (titleElement) {
|
||||
titleElement.textContent = isRevert
|
||||
? translate('modals.filenameTemplateConfirm.titleRevert', {}, 'Restore original filenames?')
|
||||
: translate('modals.filenameTemplateConfirm.titleApply', {}, 'Apply filename template to library?');
|
||||
}
|
||||
|
||||
const messageElement = modalElement.querySelector('[data-role="message"]');
|
||||
if (messageElement) {
|
||||
messageElement.textContent = isRevert
|
||||
? translate('settings.filenameTemplates.confirmRevert', {}, 'Restore the recorded original filename for all previously renamed files of this model type? This changes the relative path seen by ComfyUI loaders. Files without a recorded original filename are skipped.')
|
||||
: translate('settings.filenameTemplates.confirmApply', {}, 'Rename all existing files of this model type according to the filename template? This changes the relative path seen by ComfyUI loaders. The original filename is preserved in each model\'s metadata.');
|
||||
}
|
||||
|
||||
const confirmButton = modalElement.querySelector('[data-action="confirm-filename-template"]');
|
||||
const cancelButton = modalElement.querySelector('[data-action="cancel-filename-template"]');
|
||||
if (!confirmButton || !cancelButton) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
confirmButton.textContent = isRevert
|
||||
? translate('modals.filenameTemplateConfirm.revertButton', {}, 'Restore Original Filenames')
|
||||
: translate('settings.filenameTemplates.applyButton', {}, 'Apply to Library 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 applyFilenameTemplate(modelType) {
|
||||
const template = state.global.settings.download_filename_templates?.[modelType] || '';
|
||||
// An empty template reverts renamed models to their recorded original
|
||||
// filename instead of rendering a template.
|
||||
const confirmed = await this.confirmFilenameTemplateApply(!template);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const apiClient = getModelApiClient(FILENAME_TEMPLATE_MODEL_TYPES[modelType]);
|
||||
await apiClient.applyFilenameTemplate();
|
||||
resetAndReload(true);
|
||||
} catch (error) {
|
||||
// The API client already surfaced a toast with the failure reason
|
||||
console.error('Error applying filename template:', error);
|
||||
}
|
||||
}
|
||||
|
||||
toggleSettings() {
|
||||
if (this.isOpen) {
|
||||
modalManager.closeModal('settingsModal');
|
||||
@@ -4083,6 +4351,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');
|
||||
@@ -4130,9 +4420,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4142,11 +4440,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');
|
||||
@@ -4154,13 +4457,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) {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// Create the new hierarchical state structure
|
||||
import { getStorageItem, getMapFromStorage } from '../utils/storageHelpers.js';
|
||||
import { MODEL_TYPES } from '../api/apiConfig.js';
|
||||
import { DEFAULT_PATH_TEMPLATES, DEFAULT_PRIORITY_TAG_CONFIG } from '../utils/constants.js';
|
||||
import { DEFAULT_PATH_TEMPLATES, DEFAULT_FILENAME_TEMPLATES, DEFAULT_PRIORITY_TAG_CONFIG } from '../utils/constants.js';
|
||||
|
||||
const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||
civitai_api_key: '',
|
||||
civitai_api_key_set: false,
|
||||
huggingface_api_key: '',
|
||||
huggingface_api_key_set: false,
|
||||
civitai_host: 'civitai.com',
|
||||
download_backend: 'python',
|
||||
aria2c_path: '',
|
||||
@@ -30,6 +32,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||
recipes_path: '',
|
||||
base_model_path_mappings: {},
|
||||
download_path_templates: {},
|
||||
download_filename_templates: {},
|
||||
example_images_path: '',
|
||||
example_images_open_mode: 'system',
|
||||
example_images_local_root: '',
|
||||
@@ -74,6 +77,7 @@ export function createDefaultSettings() {
|
||||
...DEFAULT_SETTINGS_BASE,
|
||||
base_model_path_mappings: {},
|
||||
download_path_templates: { ...DEFAULT_PATH_TEMPLATES },
|
||||
download_filename_templates: { ...DEFAULT_FILENAME_TEMPLATES },
|
||||
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
|
||||
default_other_roots: {},
|
||||
enabled_other_sub_types: ['vae', 'upscaler', 'text_encoder'],
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Base-path helpers for the manager pages.
|
||||
*
|
||||
* `window.LM_BASE_PATH` is set by the inline bootstrap in
|
||||
* templates/components/base_path_bootstrap.html: it is the reverse-proxy
|
||||
* subpath the manager is served under (e.g. "/comfyui"), or "" for normal
|
||||
* and standalone deployments.
|
||||
*/
|
||||
|
||||
export function getBasePath() {
|
||||
return window.LM_BASE_PATH || '';
|
||||
}
|
||||
|
||||
export function withBasePath(path) {
|
||||
return `${getBasePath()}${path}`;
|
||||
}
|
||||
@@ -360,6 +360,25 @@ export const DEFAULT_PATH_TEMPLATES = {
|
||||
other: ''
|
||||
};
|
||||
|
||||
// Valid placeholders for download filename templates (opt-in rename of
|
||||
// downloaded safetensors; the result is a filename stem, no path separators)
|
||||
export const FILENAME_TEMPLATE_PLACEHOLDERS = [
|
||||
'{model_name}',
|
||||
'{version_name}',
|
||||
'{base_model}',
|
||||
'{author}',
|
||||
'{first_tag}',
|
||||
'{hash_short}',
|
||||
'{original_name}'
|
||||
];
|
||||
|
||||
// Default filename templates per model type; empty string keeps the original filename
|
||||
export const DEFAULT_FILENAME_TEMPLATES = {
|
||||
lora: '',
|
||||
checkpoint: '',
|
||||
embedding: ''
|
||||
};
|
||||
|
||||
// Model type labels for UI
|
||||
export const MODEL_TYPE_LABELS = {
|
||||
lora: 'LoRA Models',
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
* support AI metadata enrichment.
|
||||
*
|
||||
* Models loaded from an older cache may only carry the legacy `hf_url`
|
||||
* field; every helper here falls back to it, and to the legacy
|
||||
* `hf:user/repo` group key shape.
|
||||
* field; every helper here falls back to it.
|
||||
*/
|
||||
|
||||
import { translate } from './i18nHelpers.js';
|
||||
@@ -17,6 +16,9 @@ export const MODEL_SOURCES = [
|
||||
platform: 'huggingface',
|
||||
label: 'Hugging Face',
|
||||
groupPrefix: 'hf',
|
||||
// A repository hosts many unrelated models and the site exposes no
|
||||
// model-level identity, so HF models never auto-group.
|
||||
groupKey: 'none',
|
||||
supportsEnrichment: true,
|
||||
supportsDownload: true,
|
||||
defaultRevision: 'main',
|
||||
@@ -36,6 +38,9 @@ export const MODEL_SOURCES = [
|
||||
platform: 'modelscope',
|
||||
label: 'ModelScope',
|
||||
groupPrefix: 'ms',
|
||||
// Group by the site-native published-model id (`source_model_id`),
|
||||
// recorded by enrichment — the repo id is not a model identity.
|
||||
groupKey: 'modelId',
|
||||
supportsEnrichment: true,
|
||||
supportsDownload: true,
|
||||
defaultRevision: 'master',
|
||||
@@ -56,6 +61,7 @@ export const MODEL_SOURCES = [
|
||||
platform: 'modelscope-ai',
|
||||
label: 'ModelScope (International)',
|
||||
groupPrefix: 'msai',
|
||||
groupKey: 'modelId',
|
||||
supportsEnrichment: true,
|
||||
supportsDownload: true,
|
||||
defaultRevision: 'master',
|
||||
@@ -73,6 +79,8 @@ export const MODEL_SOURCES = [
|
||||
platform: 'tensorart',
|
||||
label: 'TensorArt',
|
||||
groupPrefix: 'ta',
|
||||
// The numeric id in a TensorArt URL already identifies a single model.
|
||||
groupKey: 'repo',
|
||||
supportsEnrichment: false,
|
||||
supportsDownload: false,
|
||||
defaultRevision: '',
|
||||
@@ -152,11 +160,21 @@ export function getModelSourceInfo(model) {
|
||||
|
||||
/**
|
||||
* Version-group key for a model, matching the backend's `_extract_group_key`.
|
||||
* Returns `''` when the model has no external source.
|
||||
* Returns `''` when the model has no external source, or when its source has
|
||||
* no reliable model identity (Hugging Face, or a ModelScope model that has
|
||||
* not been enriched with the site-native `source_model_id` yet).
|
||||
*/
|
||||
export function getModelSourceGroupKey(model) {
|
||||
const info = getModelSourceInfo(model);
|
||||
if (!info || !info.sourceId) return '';
|
||||
if (!info) return '';
|
||||
const strategy = info.groupKey || 'repo';
|
||||
if (strategy === 'none') return '';
|
||||
if (strategy === 'modelId') {
|
||||
const modelId =
|
||||
model && typeof model.source_model_id === 'string' ? model.source_model_id.trim() : '';
|
||||
return modelId ? `${info.groupPrefix}:${modelId}` : '';
|
||||
}
|
||||
if (!info.sourceId) return '';
|
||||
return `${info.groupPrefix}:${info.sourceId}`;
|
||||
}
|
||||
|
||||
|
||||
+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">
|
||||
|
||||
@@ -82,6 +82,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filename Template Apply/Revert Confirmation Modal
|
||||
Self-managed by SettingsManager (NOT registered with ModalManager): it
|
||||
stacks above the settings modal, like the directory picker. -->
|
||||
<div id="filenameTemplateConfirmModal" class="modal delete-modal">
|
||||
<div class="modal-content delete-modal-content">
|
||||
<h2 data-role="title"></h2>
|
||||
<p class="delete-message" data-role="message"></p>
|
||||
<div class="modal-actions">
|
||||
<button class="cancel-btn" data-action="cancel-filename-template">{{ t('common.actions.cancel') }}</button>
|
||||
<button class="primary-btn" data-action="confirm-filename-template"></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'),
|
||||
|
||||
@@ -1,15 +1,4 @@
|
||||
{% import 'components/modals/settings/_macros.html' as sm with context %}
|
||||
{% set template_preset_options = [
|
||||
('', 'settings.downloadPathTemplates.templateOptions.flatStructure'),
|
||||
('{base_model}', 'settings.downloadPathTemplates.templateOptions.byBaseModel'),
|
||||
('{author}', 'settings.downloadPathTemplates.templateOptions.byAuthor'),
|
||||
('{first_tag}', 'settings.downloadPathTemplates.templateOptions.byFirstTag'),
|
||||
('{base_model}/{first_tag}', 'settings.downloadPathTemplates.templateOptions.baseModelFirstTag'),
|
||||
('{base_model}/{author}', 'settings.downloadPathTemplates.templateOptions.baseModelAuthor'),
|
||||
('{author}/{first_tag}', 'settings.downloadPathTemplates.templateOptions.authorFirstTag'),
|
||||
('{base_model}/{author}/{first_tag}', 'settings.downloadPathTemplates.templateOptions.baseModelAuthorFirstTag'),
|
||||
('custom', 'settings.downloadPathTemplates.templateOptions.customTemplate'),
|
||||
] %}
|
||||
<!-- Section 3: Library -->
|
||||
<div id="section-library" class="settings-section" data-section="library">
|
||||
<!-- Folder Settings -->
|
||||
@@ -187,192 +176,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Download Path Templates -->
|
||||
<div class="settings-subsection">
|
||||
<div class="settings-subsection-header">
|
||||
<h4>
|
||||
{{ t('settings.downloadPathTemplates.title') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadPathTemplates.help') }}"></i>
|
||||
</h4>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="input-help">
|
||||
<div class="placeholder-info">
|
||||
<strong>{{ t('settings.downloadPathTemplates.availablePlaceholders') }}</strong>
|
||||
<span class="placeholder-tag">{base_model}</span>
|
||||
<span class="placeholder-tag">{author}</span>
|
||||
<span class="placeholder-tag">{first_tag}</span>
|
||||
<span class="placeholder-tag">{model_name}</span>
|
||||
<span class="placeholder-tag">{version_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="loraTemplatePreset">{{ t('settings.downloadPathTemplates.modelTypes.lora') }}</label>
|
||||
</div>
|
||||
<div class="setting-control select-control">
|
||||
<select id="loraTemplatePreset" onchange="settingsManager.updateTemplatePreset('lora', this.value)">
|
||||
{% for value, option_label in template_preset_options %}
|
||||
<option value="{{ value }}">{{ t(option_label) }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="template-custom-row" id="loraCustomRow" style="display: none;">
|
||||
<input type="text" id="loraCustomTemplate" class="template-custom-input" placeholder="{{ t('settings.downloadPathTemplates.customTemplatePlaceholder') }}" />
|
||||
<div class="template-validation" id="loraValidation"></div>
|
||||
</div>
|
||||
<div class="template-preview" id="loraPreview"></div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="checkpointTemplatePreset">{{ t('settings.downloadPathTemplates.modelTypes.checkpoint') }}</label>
|
||||
</div>
|
||||
<div class="setting-control select-control">
|
||||
<select id="checkpointTemplatePreset" onchange="settingsManager.updateTemplatePreset('checkpoint', this.value)">
|
||||
{% for value, option_label in template_preset_options %}
|
||||
<option value="{{ value }}">{{ t(option_label) }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="template-custom-row" id="checkpointCustomRow" style="display: none;">
|
||||
<input type="text" id="checkpointCustomTemplate" class="template-custom-input" placeholder="{{ t('settings.downloadPathTemplates.customTemplatePlaceholder') }}" />
|
||||
<div class="template-validation" id="checkpointValidation"></div>
|
||||
</div>
|
||||
<div class="template-preview" id="checkpointPreview"></div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="embeddingTemplatePreset">{{ t('settings.downloadPathTemplates.modelTypes.embedding') }}</label>
|
||||
</div>
|
||||
<div class="setting-control select-control">
|
||||
<select id="embeddingTemplatePreset" onchange="settingsManager.updateTemplatePreset('embedding', this.value)">
|
||||
{% for value, option_label in template_preset_options %}
|
||||
<option value="{{ value }}">{{ t(option_label) }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="template-custom-row" id="embeddingCustomRow" style="display: none;">
|
||||
<input type="text" id="embeddingCustomTemplate" class="template-custom-input" placeholder="{{ t('settings.downloadPathTemplates.customTemplatePlaceholder') }}" />
|
||||
<div class="template-validation" id="embeddingValidation"></div>
|
||||
</div>
|
||||
<div class="template-preview" id="embeddingPreview"></div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label>
|
||||
{{ t('settings.downloadPathTemplates.baseModelPathMappings') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadPathTemplates.baseModelPathMappingsHelp') }}"></i>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<button type="button" class="add-mapping-btn" onclick="settingsManager.addMappingRow()">
|
||||
<i class="fas fa-plus"></i>
|
||||
<span>{{ t('settings.downloadPathTemplates.addMapping') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mappings-container">
|
||||
<div id="baseModelMappingsContainer">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ sm.setting_toggle('skipPreviouslyDownloadedModelVersions', 'skip_previously_downloaded_model_versions', 'settings.skipPreviouslyDownloadedModelVersions.label', 'settings.skipPreviouslyDownloadedModelVersions.help') }}
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="downloadSkipBaseModelsToggle">
|
||||
{{ t('settings.downloadSkipBaseModels.label') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadSkipBaseModels.help') }}"></i>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<button
|
||||
type="button"
|
||||
id="downloadSkipBaseModelsToggle"
|
||||
class="secondary-btn base-model-skip-toggle"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<span id="downloadSkipBaseModelsSummary">{{ t('settings.downloadSkipBaseModels.summary.none') }}</span>
|
||||
<span class="base-model-skip-toggle-label">{{ t('settings.downloadSkipBaseModels.actions.edit') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="downloadSkipBaseModelsPanel" class="base-model-skip-panel" hidden>
|
||||
<div class="base-model-skip-toolbar">
|
||||
<input
|
||||
type="text"
|
||||
id="downloadSkipBaseModelsSearch"
|
||||
class="base-model-skip-search"
|
||||
placeholder="{{ t('settings.downloadSkipBaseModels.searchPlaceholder') }}"
|
||||
/>
|
||||
<button type="button" class="text-btn base-model-skip-clear" id="downloadSkipBaseModelsClear">
|
||||
{{ t('settings.downloadSkipBaseModels.actions.clear') }}
|
||||
</button>
|
||||
</div>
|
||||
<div id="downloadSkipBaseModelsContainer" class="base-model-skip-list"></div>
|
||||
<div id="downloadSkipBaseModelsEmpty" class="base-model-skip-empty" hidden>
|
||||
{{ t('settings.downloadSkipBaseModels.empty') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-input-error-message" id="downloadSkipBaseModelsError"></div>
|
||||
</div>
|
||||
|
||||
<!-- Priority Tags -->
|
||||
<div class="setting-item priority-tags-item">
|
||||
<div class="setting-row priority-tags-header-row">
|
||||
<div class="setting-info priority-tags-header">
|
||||
<label>
|
||||
{{ t('settings.priorityTags.title') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.priorityTags.description') }}"></i>
|
||||
</label>
|
||||
<a class="settings-action-link priority-tags-help-link" href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Priority-Tags-Configuration-Guide" target="_blank" rel="noopener" aria-label="{{ t('settings.priorityTags.helpLinkLabel') }}" title="{{ t('settings.priorityTags.helpLinkLabel') }}">
|
||||
<i class="fas fa-question-circle" aria-hidden="true"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="priority-tags-tabs">
|
||||
<input type="radio" id="priority-tags-tab-lora" name="priority-tags-tab" class="priority-tags-tab-input" checked>
|
||||
<input type="radio" id="priority-tags-tab-checkpoint" name="priority-tags-tab" class="priority-tags-tab-input">
|
||||
<input type="radio" id="priority-tags-tab-embedding" name="priority-tags-tab" class="priority-tags-tab-input">
|
||||
|
||||
<div class="priority-tags-tablist">
|
||||
<label class="priority-tags-tab-label" for="priority-tags-tab-lora" id="priority-tags-tab-lora-label">{{ t('settings.priorityTags.modelTypes.lora') }}</label>
|
||||
<label class="priority-tags-tab-label" for="priority-tags-tab-checkpoint" id="priority-tags-tab-checkpoint-label">{{ t('settings.priorityTags.modelTypes.checkpoint') }}</label>
|
||||
<label class="priority-tags-tab-label" for="priority-tags-tab-embedding" id="priority-tags-tab-embedding-label">{{ t('settings.priorityTags.modelTypes.embedding') }}</label>
|
||||
</div>
|
||||
|
||||
<div class="priority-tags-panels">
|
||||
<div class="priority-tags-panel" id="priority-tags-panel-lora">
|
||||
<textarea id="loraPriorityTagsInput" class="priority-tags-input" placeholder="{{ t('settings.priorityTags.placeholder') }}"></textarea>
|
||||
<div class="settings-input-error-message" id="loraPriorityTagsError"></div>
|
||||
</div>
|
||||
<div class="priority-tags-panel" id="priority-tags-panel-checkpoint">
|
||||
<textarea id="checkpointPriorityTagsInput" class="priority-tags-input" placeholder="{{ t('settings.priorityTags.placeholder') }}"></textarea>
|
||||
<div class="settings-input-error-message" id="checkpointPriorityTagsError"></div>
|
||||
</div>
|
||||
<div class="priority-tags-panel" id="priority-tags-panel-embedding">
|
||||
<textarea id="embeddingPriorityTagsInput" class="priority-tags-input" placeholder="{{ t('settings.priorityTags.placeholder') }}"></textarea>
|
||||
<div class="settings-input-error-message" id="embeddingPriorityTagsError"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Version Scope -->
|
||||
<div class="settings-subsection">
|
||||
{{ sm.subsection_header('settings.sections.versionScope') }}
|
||||
@@ -463,25 +266,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auto-organize -->
|
||||
<div class="settings-subsection">
|
||||
{{ sm.subsection_header('settings.sections.autoOrganize') }}
|
||||
|
||||
<!-- Auto-organize Exclusions -->
|
||||
<div class="setting-item auto-organize-exclusions-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="autoOrganizeExclusions">
|
||||
{{ t('settings.autoOrganizeExclusions.label') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.autoOrganizeExclusions.help') }}"></i>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<textarea id="autoOrganizeExclusions" class="priority-tags-input auto-organize-exclusions-input" placeholder="{{ t('settings.autoOrganizeExclusions.placeholder') }}"></textarea>
|
||||
<div class="settings-input-error-message" id="autoOrganizeExclusionsError"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metadata -->
|
||||
<div class="settings-subsection">
|
||||
{{ sm.subsection_header('settings.sections.metadata') }}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
{% import 'components/modals/settings/_macros.html' as sm with context %}
|
||||
{% set template_preset_options = [
|
||||
('', 'settings.downloadPathTemplates.templateOptions.flatStructure'),
|
||||
('{base_model}', 'settings.downloadPathTemplates.templateOptions.byBaseModel'),
|
||||
('{author}', 'settings.downloadPathTemplates.templateOptions.byAuthor'),
|
||||
('{first_tag}', 'settings.downloadPathTemplates.templateOptions.byFirstTag'),
|
||||
('{base_model}/{first_tag}', 'settings.downloadPathTemplates.templateOptions.baseModelFirstTag'),
|
||||
('{base_model}/{author}', 'settings.downloadPathTemplates.templateOptions.baseModelAuthor'),
|
||||
('{author}/{first_tag}', 'settings.downloadPathTemplates.templateOptions.authorFirstTag'),
|
||||
('{base_model}/{author}/{first_tag}', 'settings.downloadPathTemplates.templateOptions.baseModelAuthorFirstTag'),
|
||||
('custom', 'settings.downloadPathTemplates.templateOptions.customTemplate'),
|
||||
] %}
|
||||
<!-- Section 4: Organization -->
|
||||
<div id="section-organization" class="settings-section" data-section="organization">
|
||||
<!-- Download Path Templates -->
|
||||
<div class="settings-subsection">
|
||||
<div class="settings-subsection-header">
|
||||
<h4>
|
||||
{{ t('settings.downloadPathTemplates.title') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadPathTemplates.help') }}"></i>
|
||||
</h4>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="input-help">
|
||||
<div class="placeholder-info">
|
||||
<strong>{{ t('settings.downloadPathTemplates.availablePlaceholders') }}</strong>
|
||||
<span class="placeholder-tag">{base_model}</span>
|
||||
<span class="placeholder-tag">{author}</span>
|
||||
<span class="placeholder-tag">{first_tag}</span>
|
||||
<span class="placeholder-tag">{model_name}</span>
|
||||
<span class="placeholder-tag">{version_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="loraTemplatePreset">{{ t('settings.downloadPathTemplates.modelTypes.lora') }}</label>
|
||||
</div>
|
||||
<div class="setting-control select-control">
|
||||
<select id="loraTemplatePreset" onchange="settingsManager.updateTemplatePreset('lora', this.value)">
|
||||
{% for value, option_label in template_preset_options %}
|
||||
<option value="{{ value }}">{{ t(option_label) }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="template-custom-row" id="loraCustomRow" style="display: none;">
|
||||
<input type="text" id="loraCustomTemplate" class="template-custom-input" placeholder="{{ t('settings.downloadPathTemplates.customTemplatePlaceholder') }}" />
|
||||
<div class="template-validation" id="loraValidation"></div>
|
||||
</div>
|
||||
<div class="template-preview" id="loraPreview"></div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="checkpointTemplatePreset">{{ t('settings.downloadPathTemplates.modelTypes.checkpoint') }}</label>
|
||||
</div>
|
||||
<div class="setting-control select-control">
|
||||
<select id="checkpointTemplatePreset" onchange="settingsManager.updateTemplatePreset('checkpoint', this.value)">
|
||||
{% for value, option_label in template_preset_options %}
|
||||
<option value="{{ value }}">{{ t(option_label) }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="template-custom-row" id="checkpointCustomRow" style="display: none;">
|
||||
<input type="text" id="checkpointCustomTemplate" class="template-custom-input" placeholder="{{ t('settings.downloadPathTemplates.customTemplatePlaceholder') }}" />
|
||||
<div class="template-validation" id="checkpointValidation"></div>
|
||||
</div>
|
||||
<div class="template-preview" id="checkpointPreview"></div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="embeddingTemplatePreset">{{ t('settings.downloadPathTemplates.modelTypes.embedding') }}</label>
|
||||
</div>
|
||||
<div class="setting-control select-control">
|
||||
<select id="embeddingTemplatePreset" onchange="settingsManager.updateTemplatePreset('embedding', this.value)">
|
||||
{% for value, option_label in template_preset_options %}
|
||||
<option value="{{ value }}">{{ t(option_label) }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="template-custom-row" id="embeddingCustomRow" style="display: none;">
|
||||
<input type="text" id="embeddingCustomTemplate" class="template-custom-input" placeholder="{{ t('settings.downloadPathTemplates.customTemplatePlaceholder') }}" />
|
||||
<div class="template-validation" id="embeddingValidation"></div>
|
||||
</div>
|
||||
<div class="template-preview" id="embeddingPreview"></div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label>
|
||||
{{ t('settings.downloadPathTemplates.baseModelPathMappings') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadPathTemplates.baseModelPathMappingsHelp') }}"></i>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<button type="button" class="add-mapping-btn" onclick="settingsManager.addMappingRow()">
|
||||
<i class="fas fa-plus"></i>
|
||||
<span>{{ t('settings.downloadPathTemplates.addMapping') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mappings-container">
|
||||
<div id="baseModelMappingsContainer">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ sm.setting_toggle('skipPreviouslyDownloadedModelVersions', 'skip_previously_downloaded_model_versions', 'settings.skipPreviouslyDownloadedModelVersions.label', 'settings.skipPreviouslyDownloadedModelVersions.help') }}
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="downloadSkipBaseModelsToggle">
|
||||
{{ t('settings.downloadSkipBaseModels.label') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadSkipBaseModels.help') }}"></i>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<button
|
||||
type="button"
|
||||
id="downloadSkipBaseModelsToggle"
|
||||
class="secondary-btn base-model-skip-toggle"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<span id="downloadSkipBaseModelsSummary">{{ t('settings.downloadSkipBaseModels.summary.none') }}</span>
|
||||
<span class="base-model-skip-toggle-label">{{ t('settings.downloadSkipBaseModels.actions.edit') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="downloadSkipBaseModelsPanel" class="base-model-skip-panel" hidden>
|
||||
<div class="base-model-skip-toolbar">
|
||||
<input
|
||||
type="text"
|
||||
id="downloadSkipBaseModelsSearch"
|
||||
class="base-model-skip-search"
|
||||
placeholder="{{ t('settings.downloadSkipBaseModels.searchPlaceholder') }}"
|
||||
/>
|
||||
<button type="button" class="text-btn base-model-skip-clear" id="downloadSkipBaseModelsClear">
|
||||
{{ t('settings.downloadSkipBaseModels.actions.clear') }}
|
||||
</button>
|
||||
</div>
|
||||
<div id="downloadSkipBaseModelsContainer" class="base-model-skip-list"></div>
|
||||
<div id="downloadSkipBaseModelsEmpty" class="base-model-skip-empty" hidden>
|
||||
{{ t('settings.downloadSkipBaseModels.empty') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-input-error-message" id="downloadSkipBaseModelsError"></div>
|
||||
</div>
|
||||
|
||||
<!-- Priority Tags -->
|
||||
<div class="setting-item priority-tags-item">
|
||||
<div class="setting-row priority-tags-header-row">
|
||||
<div class="setting-info priority-tags-header">
|
||||
<label>
|
||||
{{ t('settings.priorityTags.title') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.priorityTags.description') }}"></i>
|
||||
</label>
|
||||
<a class="settings-action-link priority-tags-help-link" href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Priority-Tags-Configuration-Guide" target="_blank" rel="noopener" aria-label="{{ t('settings.priorityTags.helpLinkLabel') }}" title="{{ t('settings.priorityTags.helpLinkLabel') }}">
|
||||
<i class="fas fa-question-circle" aria-hidden="true"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="priority-tags-tabs">
|
||||
<input type="radio" id="priority-tags-tab-lora" name="priority-tags-tab" class="priority-tags-tab-input" checked>
|
||||
<input type="radio" id="priority-tags-tab-checkpoint" name="priority-tags-tab" class="priority-tags-tab-input">
|
||||
<input type="radio" id="priority-tags-tab-embedding" name="priority-tags-tab" class="priority-tags-tab-input">
|
||||
|
||||
<div class="priority-tags-tablist">
|
||||
<label class="priority-tags-tab-label" for="priority-tags-tab-lora" id="priority-tags-tab-lora-label">{{ t('settings.priorityTags.modelTypes.lora') }}</label>
|
||||
<label class="priority-tags-tab-label" for="priority-tags-tab-checkpoint" id="priority-tags-tab-checkpoint-label">{{ t('settings.priorityTags.modelTypes.checkpoint') }}</label>
|
||||
<label class="priority-tags-tab-label" for="priority-tags-tab-embedding" id="priority-tags-tab-embedding-label">{{ t('settings.priorityTags.modelTypes.embedding') }}</label>
|
||||
</div>
|
||||
|
||||
<div class="priority-tags-panels">
|
||||
<div class="priority-tags-panel" id="priority-tags-panel-lora">
|
||||
<textarea id="loraPriorityTagsInput" class="priority-tags-input" placeholder="{{ t('settings.priorityTags.placeholder') }}"></textarea>
|
||||
<div class="settings-input-error-message" id="loraPriorityTagsError"></div>
|
||||
</div>
|
||||
<div class="priority-tags-panel" id="priority-tags-panel-checkpoint">
|
||||
<textarea id="checkpointPriorityTagsInput" class="priority-tags-input" placeholder="{{ t('settings.priorityTags.placeholder') }}"></textarea>
|
||||
<div class="settings-input-error-message" id="checkpointPriorityTagsError"></div>
|
||||
</div>
|
||||
<div class="priority-tags-panel" id="priority-tags-panel-embedding">
|
||||
<textarea id="embeddingPriorityTagsInput" class="priority-tags-input" placeholder="{{ t('settings.priorityTags.placeholder') }}"></textarea>
|
||||
<div class="settings-input-error-message" id="embeddingPriorityTagsError"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filename Templates -->
|
||||
<div class="settings-subsection">
|
||||
<div class="settings-subsection-header">
|
||||
<h4>
|
||||
{{ t('settings.filenameTemplates.title') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.filenameTemplates.help') }}"></i>
|
||||
</h4>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="input-help">
|
||||
<div class="placeholder-info">
|
||||
<strong>{{ t('settings.filenameTemplates.availablePlaceholders') }}</strong>
|
||||
<span class="placeholder-tag">{model_name}</span>
|
||||
<span class="placeholder-tag">{version_name}</span>
|
||||
<span class="placeholder-tag">{base_model}</span>
|
||||
<span class="placeholder-tag">{author}</span>
|
||||
<span class="placeholder-tag">{first_tag}</span>
|
||||
<span class="placeholder-tag">{hash_short}</span>
|
||||
<span class="placeholder-tag">{original_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="loraFilenameTemplate">{{ t('settings.downloadPathTemplates.modelTypes.lora') }}</label>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<button type="button" id="loraApplyFilenameTemplate" class="primary-btn" onclick="settingsManager.applyFilenameTemplate('lora')">
|
||||
{{ t('settings.filenameTemplates.applyButton') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="text" id="loraFilenameTemplate" class="template-custom-input" placeholder="{{ t('settings.filenameTemplates.templatePlaceholder') }}" />
|
||||
<div class="template-validation" id="loraFilenameValidation"></div>
|
||||
<div class="template-preview" id="loraFilenamePreview"></div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="checkpointFilenameTemplate">{{ t('settings.downloadPathTemplates.modelTypes.checkpoint') }}</label>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<button type="button" id="checkpointApplyFilenameTemplate" class="primary-btn" onclick="settingsManager.applyFilenameTemplate('checkpoint')">
|
||||
{{ t('settings.filenameTemplates.applyButton') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="text" id="checkpointFilenameTemplate" class="template-custom-input" placeholder="{{ t('settings.filenameTemplates.templatePlaceholder') }}" />
|
||||
<div class="template-validation" id="checkpointFilenameValidation"></div>
|
||||
<div class="template-preview" id="checkpointFilenamePreview"></div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="embeddingFilenameTemplate">{{ t('settings.downloadPathTemplates.modelTypes.embedding') }}</label>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<button type="button" id="embeddingApplyFilenameTemplate" class="primary-btn" onclick="settingsManager.applyFilenameTemplate('embedding')">
|
||||
{{ t('settings.filenameTemplates.applyButton') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="text" id="embeddingFilenameTemplate" class="template-custom-input" placeholder="{{ t('settings.filenameTemplates.templatePlaceholder') }}" />
|
||||
<div class="template-validation" id="embeddingFilenameValidation"></div>
|
||||
<div class="template-preview" id="embeddingFilenamePreview"></div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="input-help">{{ t('settings.filenameTemplates.applyHelp') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auto-organize -->
|
||||
<div class="settings-subsection">
|
||||
{{ sm.subsection_header('settings.sections.autoOrganize') }}
|
||||
|
||||
<!-- Auto-organize Exclusions -->
|
||||
<div class="setting-item auto-organize-exclusions-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="autoOrganizeExclusions">
|
||||
{{ t('settings.autoOrganizeExclusions.label') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.autoOrganizeExclusions.help') }}"></i>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<textarea id="autoOrganizeExclusions" class="priority-tags-input auto-organize-exclusions-input" placeholder="{{ t('settings.autoOrganizeExclusions.placeholder') }}"></textarea>
|
||||
<div class="settings-input-error-message" id="autoOrganizeExclusionsError"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -36,6 +36,7 @@
|
||||
<button type="button" class="settings-nav-item active" data-section="general">{{ t('settings.nav.general') }}</button>
|
||||
<button type="button" class="settings-nav-item" data-section="interface">{{ t('settings.nav.interface') }}</button>
|
||||
<button type="button" class="settings-nav-item" data-section="library">{{ t('settings.nav.library') }}</button>
|
||||
<button type="button" class="settings-nav-item" data-section="organization">{{ t('settings.nav.organization') }}</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
@@ -46,6 +47,7 @@
|
||||
{% include 'components/modals/settings/general.html' %}
|
||||
{% include 'components/modals/settings/interface.html' %}
|
||||
{% include 'components/modals/settings/library.html' %}
|
||||
{% include 'components/modals/settings/organization.html' %}
|
||||
</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,
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
APP_MODULE,
|
||||
API_MODULE,
|
||||
} = vi.hoisted(() => ({
|
||||
APP_MODULE: new URL('../../../scripts/app.js', import.meta.url).pathname,
|
||||
API_MODULE: new URL('../../../scripts/api.js', import.meta.url).pathname,
|
||||
}));
|
||||
|
||||
vi.mock(APP_MODULE, () => ({
|
||||
app: { graph: {} },
|
||||
}));
|
||||
|
||||
const { fetchApiMock } = vi.hoisted(() => ({ fetchApiMock: vi.fn() }));
|
||||
vi.mock(API_MODULE, () => ({
|
||||
api: { fetchApi: fetchApiMock },
|
||||
}));
|
||||
|
||||
import {
|
||||
parseStrengthRange,
|
||||
describeStrengthRangeViolation,
|
||||
applyStrengthRangeCue,
|
||||
buildStrengthRangeMap,
|
||||
getLoraStrengthRange,
|
||||
getAvailableLoras,
|
||||
resetAvailableLorasCache,
|
||||
} from '../../../web/comfyui/loras_widget_utils.js';
|
||||
|
||||
describe('parseStrengthRange', () => {
|
||||
it('parses explicit strength_min/strength_max keys', () => {
|
||||
expect(parseStrengthRange('{"strength_min": 0.4, "strength_max": 0.8}')).toEqual({
|
||||
min: 0.4,
|
||||
max: 0.8,
|
||||
recommended: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses the strength_range shorthand', () => {
|
||||
expect(parseStrengthRange('{"strength_range": "0.4-0.8"}')).toEqual({
|
||||
min: 0.4,
|
||||
max: 0.8,
|
||||
recommended: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts camelCase variants', () => {
|
||||
expect(parseStrengthRange('{"strengthMin": 0.2, "strengthMax": 1.5}')).toEqual({
|
||||
min: 0.2,
|
||||
max: 1.5,
|
||||
recommended: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers explicit min/max over the range string per side', () => {
|
||||
expect(
|
||||
parseStrengthRange('{"strength_min": 0.1, "strength_range": "0.4-0.8"}')
|
||||
).toEqual({ min: 0.1, max: 0.8, recommended: null });
|
||||
});
|
||||
|
||||
it('supports open-ended ranges', () => {
|
||||
expect(parseStrengthRange('{"strength_max": 1.0}')).toEqual({
|
||||
min: null,
|
||||
max: 1.0,
|
||||
recommended: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('captures the recommended strength when present', () => {
|
||||
expect(
|
||||
parseStrengthRange('{"strength": 0.6, "strength_min": 0.4, "strength_max": 0.8}')
|
||||
).toEqual({ min: 0.4, max: 0.8, recommended: 0.6 });
|
||||
});
|
||||
|
||||
it('accepts numeric strings for bounds', () => {
|
||||
expect(parseStrengthRange('{"strength_min": "0.4", "strength_max": "0.8"}')).toEqual({
|
||||
min: 0.4,
|
||||
max: 0.8,
|
||||
recommended: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses negative bounds in range strings', () => {
|
||||
expect(parseStrengthRange('{"strength_range": "-0.5-0.8"}')).toEqual({
|
||||
min: -0.5,
|
||||
max: 0.8,
|
||||
recommended: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when no range is configured', () => {
|
||||
expect(parseStrengthRange('{"strength": 0.6}')).toBeNull();
|
||||
expect(parseStrengthRange('{}')).toBeNull();
|
||||
expect(parseStrengthRange('')).toBeNull();
|
||||
expect(parseStrengthRange(null)).toBeNull();
|
||||
expect(parseStrengthRange(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for malformed JSON', () => {
|
||||
expect(parseStrengthRange('{invalid')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for inverted ranges', () => {
|
||||
expect(parseStrengthRange('{"strength_min": 0.9, "strength_max": 0.2}')).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts already-parsed objects', () => {
|
||||
expect(parseStrengthRange({ strength_min: 0.3 })).toEqual({
|
||||
min: 0.3,
|
||||
max: null,
|
||||
recommended: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeStrengthRangeViolation', () => {
|
||||
const range = { min: 0.4, max: 0.8, recommended: 0.6 };
|
||||
|
||||
it('returns null when the value is inside the range', () => {
|
||||
expect(describeStrengthRangeViolation(0.6, range)).toBeNull();
|
||||
expect(describeStrengthRangeViolation(0.4, range)).toBeNull();
|
||||
expect(describeStrengthRangeViolation(0.8, range)).toBeNull();
|
||||
});
|
||||
|
||||
it('describes values below the range', () => {
|
||||
expect(describeStrengthRangeViolation(0.2, range)).toBe(
|
||||
'Below recommended strength range (0.40\u20130.80); recommended: 0.60'
|
||||
);
|
||||
});
|
||||
|
||||
it('describes values above the range', () => {
|
||||
expect(describeStrengthRangeViolation('1.0', range)).toBe(
|
||||
'Above recommended strength range (0.40\u20130.80); recommended: 0.60'
|
||||
);
|
||||
});
|
||||
|
||||
it('omits the recommended part when not configured', () => {
|
||||
expect(describeStrengthRangeViolation(0.1, { min: 0.4, max: null, recommended: null })).toBe(
|
||||
'Below recommended strength range (\u2265 0.40)'
|
||||
);
|
||||
expect(describeStrengthRangeViolation(1.5, { min: null, max: 1.0, recommended: null })).toBe(
|
||||
'Above recommended strength range (\u2264 1.00)'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null without a range or with a non-numeric value', () => {
|
||||
expect(describeStrengthRangeViolation(0.1, null)).toBeNull();
|
||||
expect(describeStrengthRangeViolation('abc', range)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyStrengthRangeCue', () => {
|
||||
const range = { min: 0.4, max: 0.8, recommended: 0.6 };
|
||||
|
||||
it('adds the cue class and tooltip for out-of-range values', () => {
|
||||
const input = document.createElement('input');
|
||||
applyStrengthRangeCue(input, 1.5, range);
|
||||
expect(input.classList.contains('lm-strength-out-of-range')).toBe(true);
|
||||
expect(input.title).toContain('Above recommended strength range');
|
||||
});
|
||||
|
||||
it('clears the cue for in-range values', () => {
|
||||
const input = document.createElement('input');
|
||||
applyStrengthRangeCue(input, 1.5, range);
|
||||
applyStrengthRangeCue(input, 0.6, range);
|
||||
expect(input.classList.contains('lm-strength-out-of-range')).toBe(false);
|
||||
expect(input.hasAttribute('title')).toBe(false);
|
||||
});
|
||||
|
||||
it('clears the cue when no range is configured', () => {
|
||||
const input = document.createElement('input');
|
||||
input.classList.add('lm-strength-out-of-range');
|
||||
input.title = 'stale';
|
||||
applyStrengthRangeCue(input, 99, null);
|
||||
expect(input.classList.contains('lm-strength-out-of-range')).toBe(false);
|
||||
expect(input.hasAttribute('title')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildStrengthRangeMap', () => {
|
||||
it('keys ranges by normalized path and basename', () => {
|
||||
const map = buildStrengthRangeMap([
|
||||
{ file_name: 'sub/a', usage_tips: '{"strength_min": 0.4, "strength_max": 0.8}' },
|
||||
]);
|
||||
expect(map.get('sub/a')).toEqual({ min: 0.4, max: 0.8, recommended: null });
|
||||
expect(map.get('a')).toEqual({ min: 0.4, max: 0.8, recommended: null });
|
||||
});
|
||||
|
||||
it('strips extensions from keys', () => {
|
||||
const map = buildStrengthRangeMap([
|
||||
{ file_name: 'sub/a.safetensors', usage_tips: '{"strength_max": 1.0}' },
|
||||
]);
|
||||
expect(map.get('sub/a')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('skips entries without a valid range', () => {
|
||||
const map = buildStrengthRangeMap([
|
||||
{ file_name: 'a', usage_tips: '' },
|
||||
{ file_name: 'b' },
|
||||
{ file_name: 'c', usage_tips: '{"strength": 0.6}' },
|
||||
null,
|
||||
]);
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLoraStrengthRange', () => {
|
||||
beforeEach(() => {
|
||||
fetchApiMock.mockReset();
|
||||
resetAvailableLorasCache();
|
||||
});
|
||||
|
||||
it('returns null while the cache is not loaded', () => {
|
||||
expect(getLoraStrengthRange('a')).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves ranges from the cached cycler list', async () => {
|
||||
fetchApiMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
loras: [
|
||||
{
|
||||
file_name: 'sub/a.safetensors',
|
||||
usage_tips: '{"strength": 0.6, "strength_min": 0.4, "strength_max": 0.8}',
|
||||
},
|
||||
{ file_name: 'b.safetensors' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
await getAvailableLoras();
|
||||
expect(getLoraStrengthRange('sub/a.safetensors')).toEqual({
|
||||
min: 0.4,
|
||||
max: 0.8,
|
||||
recommended: 0.6,
|
||||
});
|
||||
// Extension-free and basename forms resolve to the same entry.
|
||||
expect(getLoraStrengthRange('sub/a')).toEqual({
|
||||
min: 0.4,
|
||||
max: 0.8,
|
||||
recommended: 0.6,
|
||||
});
|
||||
expect(getLoraStrengthRange('a')).toEqual({
|
||||
min: 0.4,
|
||||
max: 0.8,
|
||||
recommended: 0.6,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the basename for folder-qualified names', async () => {
|
||||
fetchApiMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
loras: [
|
||||
{ file_name: 'sub/a.safetensors', usage_tips: '{"strength_max": 1.0}' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
await getAvailableLoras();
|
||||
expect(getLoraStrengthRange('any/folder/a.safetensors')).toEqual({
|
||||
min: null,
|
||||
max: 1.0,
|
||||
recommended: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for loras without a configured range', async () => {
|
||||
fetchApiMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
loras: [{ file_name: 'b.safetensors' }],
|
||||
}),
|
||||
});
|
||||
|
||||
await getAvailableLoras();
|
||||
expect(getLoraStrengthRange('b')).toBeNull();
|
||||
expect(getLoraStrengthRange('missing')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for absolute paths', async () => {
|
||||
fetchApiMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
loras: [
|
||||
{ file_name: 'a.safetensors', usage_tips: '{"strength_max": 1.0}' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
await getAvailableLoras();
|
||||
expect(getLoraStrengthRange('/abs/path/a.safetensors')).toBeNull();
|
||||
expect(getLoraStrengthRange('C:/abs/path/a.safetensors')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,8 @@ vi.mock('../../../static/js/utils/constants.js', () => ({
|
||||
DEFAULT_PATH_TEMPLATES: {},
|
||||
MAPPABLE_BASE_MODELS: ['Flux.1 D', 'Pony', 'SDXL 1.0', 'Other'],
|
||||
PATH_TEMPLATE_PLACEHOLDERS: {},
|
||||
FILENAME_TEMPLATE_PLACEHOLDERS: [],
|
||||
DEFAULT_FILENAME_TEMPLATES: { lora: '', checkpoint: '', embedding: '' },
|
||||
DEFAULT_PRIORITY_TAG_CONFIG: {
|
||||
lora: 'character, style',
|
||||
checkpoint: 'base, guide',
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import { describe, it, expect, beforeEach, 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,
|
||||
},
|
||||
},
|
||||
createDefaultSettings: () => ({
|
||||
language: 'en',
|
||||
download_filename_templates: { lora: '', checkpoint: '', embedding: '' },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
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: [
|
||||
'{model_name}',
|
||||
'{version_name}',
|
||||
'{base_model}',
|
||||
'{author}',
|
||||
'{first_tag}',
|
||||
'{hash_short}',
|
||||
'{original_name}',
|
||||
],
|
||||
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) => {
|
||||
if (params && fallback) {
|
||||
return fallback.replace(/\{(\w+)\}/g, (match, name) => params[name] ?? match);
|
||||
}
|
||||
return 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(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/managers/BannerService.js', () => ({
|
||||
bannerService: {
|
||||
registerBanner: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { SettingsManager } from '../../../static/js/managers/SettingsManager.js';
|
||||
import { state } from '../../../static/js/state/index.js';
|
||||
import { resetAndReload, getModelApiClient } from '../../../static/js/api/modelApiFactory.js';
|
||||
|
||||
const createManager = () => {
|
||||
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 appendFilenameTemplateUi = (modelType = 'lora') => {
|
||||
document.body.innerHTML = `
|
||||
<input id="${modelType}FilenameTemplate" />
|
||||
<div id="${modelType}FilenameValidation"></div>
|
||||
<div id="${modelType}FilenamePreview"></div>
|
||||
<button id="${modelType}ApplyFilenameTemplate" type="button"></button>
|
||||
`;
|
||||
};
|
||||
|
||||
const appendConfirmModal = () => {
|
||||
document.body.insertAdjacentHTML('beforeend', `
|
||||
<div id="filenameTemplateConfirmModal" class="modal delete-modal">
|
||||
<h2 data-role="title"></h2>
|
||||
<p data-role="message"></p>
|
||||
<button data-action="cancel-filename-template"></button>
|
||||
<button data-action="confirm-filename-template"></button>
|
||||
</div>
|
||||
`);
|
||||
};
|
||||
|
||||
describe('SettingsManager filename templates', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.clearAllMocks();
|
||||
state.global.settings = {
|
||||
download_filename_templates: { lora: '', checkpoint: '', embedding: '' },
|
||||
};
|
||||
});
|
||||
|
||||
it('treats an empty template as valid (restores original filenames)', () => {
|
||||
appendFilenameTemplateUi();
|
||||
const manager = createManager();
|
||||
|
||||
expect(manager.validateFilenameTemplate('lora', '')).toBe(true);
|
||||
|
||||
const validation = document.getElementById('loraFilenameValidation');
|
||||
expect(validation.classList.contains('valid')).toBe(true);
|
||||
expect(validation.textContent).toContain('restores original filenames');
|
||||
});
|
||||
|
||||
it('rejects templates with path separators or OS-illegal characters', () => {
|
||||
appendFilenameTemplateUi();
|
||||
const manager = createManager();
|
||||
|
||||
expect(manager.validateFilenameTemplate('lora', '{base_model}/{model_name}')).toBe(false);
|
||||
expect(manager.validateFilenameTemplate('lora', 'a:b')).toBe(false);
|
||||
|
||||
const validation = document.getElementById('loraFilenameValidation');
|
||||
expect(validation.classList.contains('invalid')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects unknown placeholders', () => {
|
||||
appendFilenameTemplateUi();
|
||||
const manager = createManager();
|
||||
|
||||
expect(manager.validateFilenameTemplate('lora', '{bogus}-{model_name}')).toBe(false);
|
||||
|
||||
const validation = document.getElementById('loraFilenameValidation');
|
||||
expect(validation.textContent).toContain('{bogus}');
|
||||
});
|
||||
|
||||
it('accepts a template using only known placeholders', () => {
|
||||
appendFilenameTemplateUi();
|
||||
const manager = createManager();
|
||||
|
||||
const template = '{base_model}-{model_name}-{version_name}-{hash_short}';
|
||||
expect(manager.validateFilenameTemplate('lora', template)).toBe(true);
|
||||
expect(document.getElementById('loraFilenameValidation').classList.contains('valid')).toBe(true);
|
||||
});
|
||||
|
||||
it('saves a valid template via saveSetting with the merged dict', () => {
|
||||
appendFilenameTemplateUi();
|
||||
const manager = createManager();
|
||||
manager.saveSetting = vi.fn().mockResolvedValue();
|
||||
|
||||
manager.updateFilenameTemplate('lora', '{model_name}');
|
||||
|
||||
expect(state.global.settings.download_filename_templates.lora).toBe('{model_name}');
|
||||
expect(manager.saveSetting).toHaveBeenCalledWith(
|
||||
'download_filename_templates',
|
||||
{ lora: '{model_name}', checkpoint: '', embedding: '' },
|
||||
);
|
||||
});
|
||||
|
||||
it('does not save an invalid template', () => {
|
||||
appendFilenameTemplateUi();
|
||||
const manager = createManager();
|
||||
manager.saveSetting = vi.fn().mockResolvedValue();
|
||||
|
||||
manager.updateFilenameTemplate('lora', '{unknown_placeholder}');
|
||||
|
||||
expect(state.global.settings.download_filename_templates.lora).toBe('');
|
||||
expect(manager.saveSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('previews the recorded original filename when the template is empty', () => {
|
||||
appendFilenameTemplateUi();
|
||||
const manager = createManager();
|
||||
|
||||
manager.updateFilenamePreview('lora', '');
|
||||
|
||||
expect(document.getElementById('loraFilenamePreview').textContent).toBe('V1.safetensors');
|
||||
});
|
||||
|
||||
it('renders a preview with example placeholder values', () => {
|
||||
appendFilenameTemplateUi();
|
||||
const manager = createManager();
|
||||
|
||||
manager.updateFilenamePreview('lora', '{base_model}-{model_name}-{version_name}-{hash_short}');
|
||||
|
||||
expect(document.getElementById('loraFilenamePreview').textContent)
|
||||
.toBe('Flux.1 D-model-name-v3-a1b2c3d4e5.safetensors');
|
||||
});
|
||||
|
||||
it('applies an empty template as a revert after modal confirmation', async () => {
|
||||
appendFilenameTemplateUi();
|
||||
appendConfirmModal();
|
||||
const manager = createManager();
|
||||
const apiClient = { applyFilenameTemplate: vi.fn().mockResolvedValue() };
|
||||
getModelApiClient.mockReturnValue(apiClient);
|
||||
|
||||
const applyPromise = manager.applyFilenameTemplate('lora');
|
||||
|
||||
const modal = document.getElementById('filenameTemplateConfirmModal');
|
||||
expect(modal.classList.contains('show')).toBe(true);
|
||||
expect(modal.querySelector('[data-role="title"]').textContent)
|
||||
.toBe('Restore original filenames?');
|
||||
expect(modal.querySelector('[data-role="message"]').textContent)
|
||||
.toContain('Restore the recorded original filename');
|
||||
expect(modal.querySelector('[data-action="confirm-filename-template"]').textContent)
|
||||
.toBe('Restore Original Filenames');
|
||||
|
||||
modal.querySelector('[data-action="confirm-filename-template"]').click();
|
||||
await applyPromise;
|
||||
|
||||
expect(getModelApiClient).toHaveBeenCalledWith('loras');
|
||||
expect(apiClient.applyFilenameTemplate).toHaveBeenCalledWith();
|
||||
expect(resetAndReload).toHaveBeenCalledWith(true);
|
||||
expect(modal.classList.contains('show')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not revert when the modal is cancelled', async () => {
|
||||
appendFilenameTemplateUi();
|
||||
appendConfirmModal();
|
||||
const manager = createManager();
|
||||
|
||||
const applyPromise = manager.applyFilenameTemplate('lora');
|
||||
document.querySelector('[data-action="cancel-filename-template"]').click();
|
||||
await applyPromise;
|
||||
|
||||
expect(getModelApiClient).not.toHaveBeenCalled();
|
||||
expect(resetAndReload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('merges backend download_filename_templates over defaults', () => {
|
||||
const manager = createManager();
|
||||
|
||||
const merged = manager.mergeSettingsWithDefaults({
|
||||
download_filename_templates: { lora: '{model_name}' },
|
||||
});
|
||||
expect(merged.download_filename_templates).toEqual({
|
||||
lora: '{model_name}',
|
||||
checkpoint: '',
|
||||
embedding: '',
|
||||
});
|
||||
|
||||
const fromString = manager.mergeSettingsWithDefaults({
|
||||
download_filename_templates: '{"checkpoint":"{hash_short}"}',
|
||||
});
|
||||
expect(fromString.download_filename_templates).toEqual({
|
||||
lora: '',
|
||||
checkpoint: '{hash_short}',
|
||||
embedding: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('applies the template through the model API client and reloads', async () => {
|
||||
appendFilenameTemplateUi();
|
||||
state.global.settings.download_filename_templates.lora = '{model_name}';
|
||||
const manager = createManager();
|
||||
const apiClient = { applyFilenameTemplate: vi.fn().mockResolvedValue() };
|
||||
getModelApiClient.mockReturnValue(apiClient);
|
||||
|
||||
await manager.applyFilenameTemplate('lora');
|
||||
|
||||
expect(getModelApiClient).toHaveBeenCalledWith('loras');
|
||||
expect(apiClient.applyFilenameTemplate).toHaveBeenCalledWith();
|
||||
expect(resetAndReload).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('shows the apply wording for a non-empty template and honours cancellation', async () => {
|
||||
appendFilenameTemplateUi();
|
||||
appendConfirmModal();
|
||||
state.global.settings.download_filename_templates.lora = '{model_name}';
|
||||
const manager = createManager();
|
||||
|
||||
const applyPromise = manager.applyFilenameTemplate('lora');
|
||||
|
||||
const modal = document.getElementById('filenameTemplateConfirmModal');
|
||||
expect(modal.classList.contains('show')).toBe(true);
|
||||
expect(modal.querySelector('[data-role="title"]').textContent)
|
||||
.toBe('Apply filename template to library?');
|
||||
expect(modal.querySelector('[data-role="message"]').textContent)
|
||||
.toContain('Rename all existing files');
|
||||
|
||||
modal.querySelector('[data-action="cancel-filename-template"]').click();
|
||||
await applyPromise;
|
||||
|
||||
expect(getModelApiClient).not.toHaveBeenCalled();
|
||||
expect(resetAndReload).not.toHaveBeenCalled();
|
||||
expect(modal.classList.contains('show')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,8 @@ vi.mock('../../../static/js/utils/constants.js', () => ({
|
||||
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',
|
||||
|
||||
@@ -36,6 +36,8 @@ vi.mock('../../../static/js/utils/constants.js', () => ({
|
||||
DEFAULT_PATH_TEMPLATES: {},
|
||||
MAPPABLE_BASE_MODELS: [],
|
||||
PATH_TEMPLATE_PLACEHOLDERS: {},
|
||||
FILENAME_TEMPLATE_PLACEHOLDERS: [],
|
||||
DEFAULT_FILENAME_TEMPLATES: { lora: '', checkpoint: '', embedding: '' },
|
||||
DEFAULT_PRIORITY_TAG_CONFIG: {},
|
||||
getMappableBaseModelsDynamic: () => [],
|
||||
}));
|
||||
|
||||
@@ -34,6 +34,8 @@ vi.mock('../../../static/js/utils/constants.js', () => ({
|
||||
DEFAULT_PATH_TEMPLATES: {},
|
||||
MAPPABLE_BASE_MODELS: [],
|
||||
PATH_TEMPLATE_PLACEHOLDERS: {},
|
||||
FILENAME_TEMPLATE_PLACEHOLDERS: [],
|
||||
DEFAULT_FILENAME_TEMPLATES: { lora: '', checkpoint: '', embedding: '' },
|
||||
DEFAULT_PRIORITY_TAG_CONFIG: {},
|
||||
getMappableBaseModelsDynamic: () => [],
|
||||
}));
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
|
||||
const extractBootstrapScript = () => {
|
||||
const html = readFileSync(
|
||||
resolve(repoRoot, 'templates/components/base_path_bootstrap.html'),
|
||||
'utf8',
|
||||
);
|
||||
const match = html.match(/<script>([\s\S]*?)<\/script>/);
|
||||
if (!match) {
|
||||
throw new Error('bootstrap <script> block not found');
|
||||
}
|
||||
return match[1];
|
||||
};
|
||||
|
||||
const PATCHED_PROTOS = () => [
|
||||
[Element.prototype, ['innerHTML', 'outerHTML']],
|
||||
[HTMLImageElement.prototype, ['src']],
|
||||
[HTMLMediaElement.prototype, ['src']],
|
||||
[HTMLSourceElement.prototype, ['src']],
|
||||
[HTMLVideoElement.prototype, ['poster']],
|
||||
[HTMLAnchorElement.prototype, ['href']],
|
||||
[HTMLScriptElement.prototype, ['src']],
|
||||
[HTMLLinkElement.prototype, ['href']],
|
||||
];
|
||||
|
||||
describe('base_path_bootstrap.html', () => {
|
||||
let savedGlobals;
|
||||
let savedDescriptors;
|
||||
let savedMethods;
|
||||
|
||||
beforeEach(() => {
|
||||
savedGlobals = {
|
||||
fetch: window.fetch,
|
||||
WebSocket: window.WebSocket,
|
||||
};
|
||||
savedDescriptors = PATCHED_PROTOS().flatMap(([proto, props]) =>
|
||||
props.map((prop) => [proto, prop, Object.getOwnPropertyDescriptor(proto, prop)]),
|
||||
);
|
||||
savedMethods = {
|
||||
xhrOpen: XMLHttpRequest.prototype.open,
|
||||
insertAdjacentHTML: Element.prototype.insertAdjacentHTML,
|
||||
setAttribute: Element.prototype.setAttribute,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.fetch = savedGlobals.fetch;
|
||||
window.WebSocket = savedGlobals.WebSocket;
|
||||
for (const [proto, prop, desc] of savedDescriptors) {
|
||||
if (desc) {
|
||||
Object.defineProperty(proto, prop, desc);
|
||||
}
|
||||
}
|
||||
XMLHttpRequest.prototype.open = savedMethods.xhrOpen;
|
||||
Element.prototype.insertAdjacentHTML = savedMethods.insertAdjacentHTML;
|
||||
Element.prototype.setAttribute = savedMethods.setAttribute;
|
||||
delete window.LM_BASE_PATH;
|
||||
delete window.lmWithBasePath;
|
||||
window.history.replaceState({}, '', '/');
|
||||
});
|
||||
|
||||
const runBootstrap = (pathname) => {
|
||||
window.history.replaceState({}, '', pathname);
|
||||
(0, eval)(extractBootstrapScript());
|
||||
};
|
||||
|
||||
it('detects no prefix for root-mounted pages and patches nothing', () => {
|
||||
const nativeFetch = window.fetch;
|
||||
runBootstrap('/loras');
|
||||
expect(window.LM_BASE_PATH).toBe('');
|
||||
expect(window.fetch).toBe(nativeFetch);
|
||||
|
||||
runBootstrap('/');
|
||||
expect(window.LM_BASE_PATH).toBe('');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/comfyui/loras', '/comfyui'],
|
||||
['/comfyui/loras/', '/comfyui'],
|
||||
['/comfyui/loras/recipes', '/comfyui'],
|
||||
['/comfyui/checkpoints', '/comfyui'],
|
||||
['/comfyui/embeddings', '/comfyui'],
|
||||
['/comfyui/other', '/comfyui'],
|
||||
['/comfyui/statistics', '/comfyui'],
|
||||
['/ComfyBackendDirect/loras', '/ComfyBackendDirect'],
|
||||
['/proxy/nested/loras', '/proxy/nested'],
|
||||
])('detects prefix for %s', (pathname, expected) => {
|
||||
runBootstrap(pathname);
|
||||
expect(window.LM_BASE_PATH).toBe(expected);
|
||||
});
|
||||
|
||||
it('does not mistake similar paths for manager pages', () => {
|
||||
runBootstrap('/comfyui/lorasgallery');
|
||||
expect(window.LM_BASE_PATH).toBe('');
|
||||
runBootstrap('/comfyui/foo-loras');
|
||||
expect(window.LM_BASE_PATH).toBe('');
|
||||
});
|
||||
|
||||
it('prefixes root-absolute fetch URLs only', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue({ ok: true });
|
||||
window.fetch = fetchSpy;
|
||||
runBootstrap('/comfyui/loras');
|
||||
|
||||
await window.fetch('/api/lm/loras/list');
|
||||
await window.fetch('/loras_static/images/no-preview.png');
|
||||
await window.fetch('https://civitai.com/api/v1/models');
|
||||
await window.fetch('//cdn.example.com/x.js');
|
||||
await window.fetch('relative/path');
|
||||
|
||||
expect(fetchSpy.mock.calls.map((call) => call[0])).toEqual([
|
||||
'/comfyui/api/lm/loras/list',
|
||||
'/comfyui/loras_static/images/no-preview.png',
|
||||
'https://civitai.com/api/v1/models',
|
||||
'//cdn.example.com/x.js',
|
||||
'relative/path',
|
||||
]);
|
||||
});
|
||||
|
||||
it('prefixes same-origin absolute fetch URLs', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue({ ok: true });
|
||||
window.fetch = fetchSpy;
|
||||
runBootstrap('/comfyui/loras');
|
||||
|
||||
const absolute = `${window.location.origin}/api/lm/init-status`;
|
||||
await window.fetch(absolute);
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
`${window.location.origin}/comfyui/api/lm/init-status`,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('prefixes fetch() called with a URL object', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue({ ok: true });
|
||||
window.fetch = fetchSpy;
|
||||
runBootstrap('/comfyui/loras');
|
||||
|
||||
await window.fetch(new URL('/api/lm/base-models', window.location.origin));
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
`${window.location.origin}/comfyui/api/lm/base-models`,
|
||||
undefined,
|
||||
);
|
||||
|
||||
await window.fetch(new URL('https://civitai.com/api/v1/models'));
|
||||
expect(fetchSpy).toHaveBeenLastCalledWith('https://civitai.com/api/v1/models', undefined);
|
||||
});
|
||||
|
||||
it('prefixes WebSocket URLs', () => {
|
||||
const constructed = [];
|
||||
class FakeWebSocket {
|
||||
constructor(url, protocols) {
|
||||
constructed.push([url, protocols]);
|
||||
}
|
||||
}
|
||||
FakeWebSocket.CONNECTING = 0;
|
||||
FakeWebSocket.OPEN = 1;
|
||||
FakeWebSocket.CLOSING = 2;
|
||||
FakeWebSocket.CLOSED = 3;
|
||||
window.WebSocket = FakeWebSocket;
|
||||
|
||||
runBootstrap('/comfyui/loras');
|
||||
|
||||
new window.WebSocket('/ws/fetch-progress');
|
||||
new window.WebSocket('wss://other.example.com/socket', ['a']);
|
||||
expect(constructed).toEqual([
|
||||
['/comfyui/ws/fetch-progress', undefined],
|
||||
['wss://other.example.com/socket', ['a']],
|
||||
]);
|
||||
});
|
||||
|
||||
it('rewrites root-absolute URLs inside innerHTML markup', () => {
|
||||
runBootstrap('/comfyui/loras');
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = `<img src="/api/lm/previews?path=x" onerror="this.src='/loras_static/images/no-preview.png'">`
|
||||
+ `<a href="/api/lm/download-model/1">dl</a>`
|
||||
+ `<video poster="/loras_static/p.png"><source src="/example_images_static/a/b.mp4"></video>`;
|
||||
|
||||
const html = container.innerHTML;
|
||||
expect(html).toContain('src="/comfyui/api/lm/previews?path=x"');
|
||||
expect(html).toContain("this.src='/comfyui/loras_static/images/no-preview.png'");
|
||||
expect(html).toContain('href="/comfyui/api/lm/download-model/1"');
|
||||
expect(html).toContain('poster="/comfyui/loras_static/p.png"');
|
||||
expect(html).toContain('src="/comfyui/example_images_static/a/b.mp4"');
|
||||
});
|
||||
|
||||
it('does not double-prefix markup that already carries the prefix', () => {
|
||||
runBootstrap('/comfyui/loras');
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = '<img src="/api/lm/previews?path=x">';
|
||||
const once = container.innerHTML;
|
||||
container.innerHTML = once;
|
||||
expect(container.innerHTML).toBe(once);
|
||||
expect(once).not.toContain('/comfyui/comfyui/');
|
||||
});
|
||||
|
||||
it('prefixes direct DOM URL assignments', () => {
|
||||
runBootstrap('/comfyui/loras');
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = '/loras_static/images/no-preview.png';
|
||||
expect(img.getAttribute('src')).toBe('/comfyui/loras_static/images/no-preview.png');
|
||||
|
||||
const anchor = document.createElement('a');
|
||||
anchor.setAttribute('href', '/api/lm/download-model/1');
|
||||
expect(anchor.getAttribute('href')).toBe('/comfyui/api/lm/download-model/1');
|
||||
|
||||
const video = document.createElement('video');
|
||||
video.poster = '/loras_static/p.png';
|
||||
expect(video.getAttribute('poster')).toBe('/comfyui/loras_static/p.png');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
|
||||
import { getComfyUIBasePath, lmUrl } from '../../../web/comfyui/base_path.js';
|
||||
|
||||
describe('web/comfyui/base_path.js', () => {
|
||||
afterEach(() => {
|
||||
window.history.replaceState({}, '', '/');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/', ''],
|
||||
['/comfyui/', '/comfyui'],
|
||||
['/comfyui', '/comfyui'],
|
||||
['/ComfyBackendDirect/', '/ComfyBackendDirect'],
|
||||
])('maps %s to base path %s', (pathname, expected) => {
|
||||
window.history.replaceState({}, '', pathname);
|
||||
expect(getComfyUIBasePath()).toBe(expected);
|
||||
});
|
||||
|
||||
it('builds prefixed URLs', () => {
|
||||
window.history.replaceState({}, '', '/comfyui/');
|
||||
expect(lmUrl('/api/lm/version-info')).toBe('/comfyui/api/lm/version-info');
|
||||
expect(lmUrl('/loras')).toBe('/comfyui/loras');
|
||||
|
||||
window.history.replaceState({}, '', '/');
|
||||
expect(lmUrl('/api/lm/version-info')).toBe('/api/lm/version-info');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { APP_MODULE, UTILS_MODULE } = vi.hoisted(() => ({
|
||||
APP_MODULE: new URL("../../../scripts/app.js", import.meta.url).pathname,
|
||||
UTILS_MODULE: new URL("../../../web/comfyui/utils.js", import.meta.url).pathname,
|
||||
}));
|
||||
|
||||
vi.mock(APP_MODULE, () => ({
|
||||
app: {
|
||||
graph: null,
|
||||
registerExtension: vi.fn(),
|
||||
ui: {
|
||||
settings: {
|
||||
getSettingValue: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("interceptModeChange", () => {
|
||||
let interceptModeChange;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ interceptModeChange } = await import(UTILS_MODULE));
|
||||
});
|
||||
|
||||
describe("legacy frontend (mode as plain data property)", () => {
|
||||
it("reads and writes the mode through the installed accessor", () => {
|
||||
const node = { mode: 0 };
|
||||
interceptModeChange(node, vi.fn());
|
||||
|
||||
node.mode = 4;
|
||||
expect(node.mode).toBe(4);
|
||||
});
|
||||
|
||||
it("invokes the callback only when the mode actually changes", () => {
|
||||
const node = { mode: 0 };
|
||||
const onModeChange = vi.fn();
|
||||
interceptModeChange(node, onModeChange);
|
||||
|
||||
node.mode = 0;
|
||||
expect(onModeChange).not.toHaveBeenCalled();
|
||||
|
||||
node.mode = 4;
|
||||
expect(onModeChange).toHaveBeenCalledWith(4, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ECS frontend (mode as prototype accessor backed by shell state)", () => {
|
||||
function createEcsNode() {
|
||||
class LGraphNode {
|
||||
constructor() {
|
||||
this._state = { mode: 0 };
|
||||
}
|
||||
get mode() {
|
||||
return this._state.mode;
|
||||
}
|
||||
set mode(value) {
|
||||
this._state.mode = value;
|
||||
}
|
||||
}
|
||||
return new LGraphNode();
|
||||
}
|
||||
|
||||
it("keeps writes flowing into the shell state so serialization stays correct", () => {
|
||||
const node = createEcsNode();
|
||||
interceptModeChange(node, vi.fn());
|
||||
|
||||
node.mode = 4;
|
||||
|
||||
expect(node._state.mode).toBe(4);
|
||||
expect(node.mode).toBe(4);
|
||||
});
|
||||
|
||||
it("invokes the callback with new and old mode on change", () => {
|
||||
const node = createEcsNode();
|
||||
const onModeChange = vi.fn();
|
||||
interceptModeChange(node, onModeChange);
|
||||
|
||||
node.mode = 4;
|
||||
expect(onModeChange).toHaveBeenCalledWith(4, 0);
|
||||
|
||||
node.mode = 4;
|
||||
expect(onModeChange).toHaveBeenCalledTimes(1);
|
||||
|
||||
node.mode = 0;
|
||||
expect(onModeChange).toHaveBeenCalledWith(0, 4);
|
||||
});
|
||||
|
||||
it("keeps the installed accessor configurable so it can be redefined", () => {
|
||||
const node = createEcsNode();
|
||||
interceptModeChange(node, vi.fn());
|
||||
|
||||
const descriptor = Object.getOwnPropertyDescriptor(node, "mode");
|
||||
expect(descriptor.configurable).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -105,13 +105,39 @@ describe('modelSourceHelpers', () => {
|
||||
|
||||
describe('getModelSourceGroupKey', () => {
|
||||
it('matches the backend group-key shapes', () => {
|
||||
expect(getModelSourceGroupKey({ hf_url: 'https://huggingface.co/u/r' })).toBe('hf:u/r');
|
||||
expect(
|
||||
getModelSourceGroupKey({ source_url: 'https://modelscope.cn/models/u/r' })
|
||||
).toBe('ms:u/r');
|
||||
// TensorArt's numeric id already identifies a single model.
|
||||
expect(getModelSourceGroupKey({ source_url: 'https://tensor.art/models/123' })).toBe(
|
||||
'ta:123'
|
||||
);
|
||||
// ModelScope groups by the site-native published-model id.
|
||||
expect(
|
||||
getModelSourceGroupKey({
|
||||
source_url: 'https://modelscope.cn/models/u/r',
|
||||
source_model_id: '555',
|
||||
})
|
||||
).toBe('ms:555');
|
||||
expect(
|
||||
getModelSourceGroupKey({
|
||||
source_url: 'https://www.modelscope.ai/models/u/r',
|
||||
source_model_id: '678',
|
||||
})
|
||||
).toBe('msai:678');
|
||||
});
|
||||
|
||||
it('returns an empty string for sources without a model identity', () => {
|
||||
// Hugging Face repos are not a model identity: never grouped.
|
||||
expect(getModelSourceGroupKey({ hf_url: 'https://huggingface.co/u/r' })).toBe('');
|
||||
// Unenriched ModelScope models stay standalone rather than collapsing
|
||||
// a whole collection repo into one group.
|
||||
expect(
|
||||
getModelSourceGroupKey({ source_url: 'https://modelscope.cn/models/u/r' })
|
||||
).toBe('');
|
||||
expect(
|
||||
getModelSourceGroupKey({
|
||||
source_url: 'https://modelscope.cn/models/u/r',
|
||||
source_model_id: ' ',
|
||||
})
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
it('returns an empty string without a source', () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user