mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-26 13:34:08 -03:00
Compare commits
14
Commits
521531111a
...
v1.2.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3555ddb588 | ||
|
|
ede15032ce | ||
|
|
c48feeddb6 | ||
|
|
8a80f82d93 | ||
|
|
c4676183b5 | ||
|
|
8b7ba59263 | ||
|
|
067e605e75 | ||
|
|
dae18b3d1d | ||
|
|
2f9bd3ee7d | ||
|
|
755e1a5bca | ||
|
|
c202654d49 | ||
|
|
74736f7560 | ||
|
|
0ada32d0c7 | ||
|
|
e9aff35957 |
@@ -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
|
||||
}
|
||||
@@ -365,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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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\".",
|
||||
|
||||
@@ -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 ».",
|
||||
|
||||
@@ -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\".",
|
||||
|
||||
@@ -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 サイトを選択します。",
|
||||
|
||||
@@ -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 사이트를 열지 선택합니다.",
|
||||
|
||||
@@ -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».",
|
||||
|
||||
@@ -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 站点。",
|
||||
|
||||
@@ -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 站點。",
|
||||
|
||||
@@ -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),)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -50,6 +50,7 @@ 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",
|
||||
@@ -204,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
@@ -2327,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":
|
||||
|
||||
@@ -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", ""),
|
||||
}
|
||||
|
||||
|
||||
@@ -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": "",
|
||||
@@ -1122,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",
|
||||
@@ -1569,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]]:
|
||||
|
||||
@@ -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)
|
||||
+55
-11
@@ -6,6 +6,11 @@ from typing import Any, Dict, List, Optional
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..config import config
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
from .constants import (
|
||||
MAX_FILENAME_STEM_LENGTH,
|
||||
MAX_FOLDER_NAME_LENGTH,
|
||||
MAX_PATH_TAG_LENGTH,
|
||||
)
|
||||
import asyncio
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -114,6 +119,16 @@ def get_lora_info_absolute(lora_name):
|
||||
scanner = await ServiceRegistry.get_lora_scanner()
|
||||
cache = await scanner.get_cached_data()
|
||||
|
||||
# Stack producers can resolve an exact business path. Preserve it even
|
||||
# when several indexed LoRAs share the same basename.
|
||||
if os.path.isabs(lora_name):
|
||||
for item in cache.raw_data:
|
||||
file_path = item.get("file_path")
|
||||
if file_path and os.path.abspath(file_path) == os.path.abspath(lora_name):
|
||||
civitai = item.get("civitai") or {}
|
||||
return file_path, civitai.get("trainedWords", [])
|
||||
return lora_name, []
|
||||
|
||||
lora_name_normalized = lora_name.replace("\\", "/")
|
||||
lora_name_no_ext = lora_name_normalized
|
||||
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||
@@ -417,12 +432,17 @@ def fuzzy_match(text: str, pattern: str, threshold: float = 0.85) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def sanitize_folder_name(name: str, replacement: str = "_") -> str:
|
||||
def sanitize_folder_name(
|
||||
name: str, replacement: str = "_", max_length: Optional[int] = None
|
||||
) -> str:
|
||||
"""Sanitize a folder name by removing or replacing invalid characters.
|
||||
|
||||
Args:
|
||||
name: The original folder name.
|
||||
replacement: The character to use when replacing invalid characters.
|
||||
max_length: Optional maximum length for the resulting name. Longer
|
||||
names are truncated (and re-trimmed) so that a single untrusted
|
||||
value cannot blow past filesystem path limits.
|
||||
|
||||
Returns:
|
||||
A sanitized folder name safe to use across common filesystems.
|
||||
@@ -449,6 +469,15 @@ def sanitize_folder_name(name: str, replacement: str = "_") -> str:
|
||||
# If no replacement, just strip spaces and dots from right, spaces from left
|
||||
sanitized = sanitized.rstrip(" .").lstrip(" ")
|
||||
|
||||
if max_length is not None and max_length > 0 and len(sanitized) > max_length:
|
||||
sanitized = sanitized[:max_length]
|
||||
# Re-trim separators and spaces exposed by the cut so the truncated
|
||||
# name stays filesystem-safe.
|
||||
if replacement:
|
||||
sanitized = sanitized.rstrip(" ." + replacement).lstrip(" " + replacement)
|
||||
else:
|
||||
sanitized = sanitized.rstrip(" .").lstrip(" ")
|
||||
|
||||
if not sanitized:
|
||||
return "unnamed"
|
||||
|
||||
@@ -575,12 +604,20 @@ def calculate_relative_path_for_model(
|
||||
if not first_tag:
|
||||
first_tag = "no tags" # Default if no tags available
|
||||
|
||||
# Tags are user-generated on CivitAI, so sanitize the value before it
|
||||
# becomes a path segment and cap its length (#1119).
|
||||
first_tag = sanitize_folder_name(first_tag, max_length=MAX_PATH_TAG_LENGTH)
|
||||
|
||||
# Format the template with available data
|
||||
model_name = sanitize_folder_name(model_data.get("model_name", ""))
|
||||
model_name = sanitize_folder_name(
|
||||
model_data.get("model_name", ""), max_length=MAX_FOLDER_NAME_LENGTH
|
||||
)
|
||||
version_name = ""
|
||||
|
||||
if isinstance(civitai_data, dict):
|
||||
version_name = sanitize_folder_name(civitai_data.get("name") or "")
|
||||
version_name = sanitize_folder_name(
|
||||
civitai_data.get("name") or "", max_length=MAX_FOLDER_NAME_LENGTH
|
||||
)
|
||||
|
||||
formatted_path = path_template
|
||||
formatted_path = formatted_path.replace("{base_model}", mapped_base_model)
|
||||
@@ -667,20 +704,22 @@ def calculate_filename_for_model(
|
||||
else:
|
||||
original_name = os.path.splitext(str(model_data.get("file_name", "")))[0]
|
||||
|
||||
def _sanitize_value(value: Any) -> str:
|
||||
def _sanitize_value(value: Any, max_length: Optional[int] = None) -> str:
|
||||
# sanitize_folder_name falls back to "unnamed" for empty input; for
|
||||
# templates an empty value must stay empty so segments collapse.
|
||||
text = str(value) if value else ""
|
||||
return sanitize_folder_name(text) if text else ""
|
||||
if not text:
|
||||
return ""
|
||||
return sanitize_folder_name(text, max_length=max_length)
|
||||
|
||||
replacements = {
|
||||
"{model_name}": _sanitize_value(model_name),
|
||||
"{version_name}": _sanitize_value(version_name),
|
||||
"{base_model}": _sanitize_value(mapped_base_model),
|
||||
"{author}": _sanitize_value(author),
|
||||
"{first_tag}": _sanitize_value(first_tag),
|
||||
"{model_name}": _sanitize_value(model_name, MAX_FILENAME_STEM_LENGTH),
|
||||
"{version_name}": _sanitize_value(version_name, MAX_FILENAME_STEM_LENGTH),
|
||||
"{base_model}": _sanitize_value(mapped_base_model, MAX_FILENAME_STEM_LENGTH),
|
||||
"{author}": _sanitize_value(author, MAX_FILENAME_STEM_LENGTH),
|
||||
"{first_tag}": _sanitize_value(first_tag, MAX_PATH_TAG_LENGTH),
|
||||
"{hash_short}": hash_short,
|
||||
"{original_name}": _sanitize_value(original_name),
|
||||
"{original_name}": _sanitize_value(original_name, MAX_FILENAME_STEM_LENGTH),
|
||||
}
|
||||
|
||||
result = template
|
||||
@@ -699,6 +738,11 @@ def calculate_filename_for_model(
|
||||
# A stem must not start or end with separators, spaces or dots.
|
||||
result = result.strip("-_. ")
|
||||
|
||||
# A template can concatenate several values, so cap the rendered stem as
|
||||
# well and re-trim the cut.
|
||||
if len(result) > MAX_FILENAME_STEM_LENGTH:
|
||||
result = result[:MAX_FILENAME_STEM_LENGTH].strip("-_. ")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-lora-manager"
|
||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||
version = "1.2.3"
|
||||
version = "1.2.4"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -928,6 +928,7 @@ export class SettingsManager {
|
||||
|
||||
// Update API key status display (do NOT pre-fill the input)
|
||||
this.updateApiKeyStatus();
|
||||
this.updateHfApiKeyStatus();
|
||||
this.updateLlmApiKeyStatus();
|
||||
|
||||
// ── AI Provider settings ──────────────────────────────────────
|
||||
@@ -4350,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');
|
||||
@@ -4397,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4409,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');
|
||||
@@ -4421,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) {
|
||||
|
||||
@@ -6,6 +6,8 @@ import { DEFAULT_PATH_TEMPLATES, DEFAULT_FILENAME_TEMPLATES, DEFAULT_PRIORITY_TA
|
||||
const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||
civitai_api_key: '',
|
||||
civitai_api_key_set: false,
|
||||
huggingface_api_key: '',
|
||||
huggingface_api_key_set: false,
|
||||
civitai_host: 'civitai.com',
|
||||
download_backend: 'python',
|
||||
aria2c_path: '',
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Base-path helpers for the manager pages.
|
||||
*
|
||||
* `window.LM_BASE_PATH` is set by the inline bootstrap in
|
||||
* templates/components/base_path_bootstrap.html: it is the reverse-proxy
|
||||
* subpath the manager is served under (e.g. "/comfyui"), or "" for normal
|
||||
* and standalone deployments.
|
||||
*/
|
||||
|
||||
export function getBasePath() {
|
||||
return window.LM_BASE_PATH || '';
|
||||
}
|
||||
|
||||
export function withBasePath(path) {
|
||||
return `${getBasePath()}${path}`;
|
||||
}
|
||||
@@ -6,8 +6,7 @@
|
||||
* support AI metadata enrichment.
|
||||
*
|
||||
* Models loaded from an older cache may only carry the legacy `hf_url`
|
||||
* field; every helper here falls back to it, and to the legacy
|
||||
* `hf:user/repo` group key shape.
|
||||
* field; every helper here falls back to it.
|
||||
*/
|
||||
|
||||
import { translate } from './i18nHelpers.js';
|
||||
@@ -17,6 +16,9 @@ export const MODEL_SOURCES = [
|
||||
platform: 'huggingface',
|
||||
label: 'Hugging Face',
|
||||
groupPrefix: 'hf',
|
||||
// A repository hosts many unrelated models and the site exposes no
|
||||
// model-level identity, so HF models never auto-group.
|
||||
groupKey: 'none',
|
||||
supportsEnrichment: true,
|
||||
supportsDownload: true,
|
||||
defaultRevision: 'main',
|
||||
@@ -36,6 +38,9 @@ export const MODEL_SOURCES = [
|
||||
platform: 'modelscope',
|
||||
label: 'ModelScope',
|
||||
groupPrefix: 'ms',
|
||||
// Group by the site-native published-model id (`source_model_id`),
|
||||
// recorded by enrichment — the repo id is not a model identity.
|
||||
groupKey: 'modelId',
|
||||
supportsEnrichment: true,
|
||||
supportsDownload: true,
|
||||
defaultRevision: 'master',
|
||||
@@ -56,6 +61,7 @@ export const MODEL_SOURCES = [
|
||||
platform: 'modelscope-ai',
|
||||
label: 'ModelScope (International)',
|
||||
groupPrefix: 'msai',
|
||||
groupKey: 'modelId',
|
||||
supportsEnrichment: true,
|
||||
supportsDownload: true,
|
||||
defaultRevision: 'master',
|
||||
@@ -73,6 +79,8 @@ export const MODEL_SOURCES = [
|
||||
platform: 'tensorart',
|
||||
label: 'TensorArt',
|
||||
groupPrefix: 'ta',
|
||||
// The numeric id in a TensorArt URL already identifies a single model.
|
||||
groupKey: 'repo',
|
||||
supportsEnrichment: false,
|
||||
supportsDownload: false,
|
||||
defaultRevision: '',
|
||||
@@ -152,11 +160,21 @@ export function getModelSourceInfo(model) {
|
||||
|
||||
/**
|
||||
* Version-group key for a model, matching the backend's `_extract_group_key`.
|
||||
* Returns `''` when the model has no external source.
|
||||
* Returns `''` when the model has no external source, or when its source has
|
||||
* no reliable model identity (Hugging Face, or a ModelScope model that has
|
||||
* not been enriched with the site-native `source_model_id` yet).
|
||||
*/
|
||||
export function getModelSourceGroupKey(model) {
|
||||
const info = getModelSourceInfo(model);
|
||||
if (!info || !info.sourceId) return '';
|
||||
if (!info) return '';
|
||||
const strategy = info.groupKey || 'repo';
|
||||
if (strategy === 'none') return '';
|
||||
if (strategy === 'modelId') {
|
||||
const modelId =
|
||||
model && typeof model.source_model_id === 'string' ? model.source_model_id.trim() : '';
|
||||
return modelId ? `${info.groupPrefix}:${modelId}` : '';
|
||||
}
|
||||
if (!info.sourceId) return '';
|
||||
return `${info.groupPrefix}:${info.sourceId}`;
|
||||
}
|
||||
|
||||
|
||||
+10
-9
@@ -2,19 +2,20 @@
|
||||
<html>
|
||||
|
||||
<head>
|
||||
{% include 'components/base_path_bootstrap.html' %}
|
||||
<title>{% block title %}{{ t('header.appTitle') }}{% endblock %}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="/loras_static/css/style.css?v={{ version }}">
|
||||
<link rel="stylesheet" href="/loras_static/css/onboarding.css?v={{ version }}">
|
||||
<link rel="stylesheet" href="/loras_static/vendor/flag-icons/flag-icons.min.css">
|
||||
<link rel="stylesheet" href="{{ rel_prefix }}loras_static/css/style.css?v={{ version }}">
|
||||
<link rel="stylesheet" href="{{ rel_prefix }}loras_static/css/onboarding.css?v={{ version }}">
|
||||
<link rel="stylesheet" href="{{ rel_prefix }}loras_static/vendor/flag-icons/flag-icons.min.css">
|
||||
{% block page_css %}{% endblock %}
|
||||
<link rel="stylesheet" href="/loras_static/vendor/font-awesome/css/all.min.css"
|
||||
<link rel="stylesheet" href="{{ rel_prefix }}loras_static/vendor/font-awesome/css/all.min.css"
|
||||
crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/loras_static/images/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/loras_static/images/favicon-16x16.png">
|
||||
<link rel="manifest" href="/loras_static/images/site.webmanifest">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ rel_prefix }}loras_static/images/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="{{ rel_prefix }}loras_static/images/favicon-16x16.png">
|
||||
<link rel="manifest" href="{{ rel_prefix }}loras_static/images/site.webmanifest">
|
||||
|
||||
<link rel="preload" as="font" type="font/woff2" href="/loras_static/vendor/font-awesome/webfonts/fa-solid-900.woff2" crossorigin>
|
||||
<link rel="preload" as="font" type="font/woff2" href="{{ rel_prefix }}loras_static/vendor/font-awesome/webfonts/fa-solid-900.woff2" crossorigin>
|
||||
|
||||
<!-- 添加性能监控 -->
|
||||
<script>
|
||||
@@ -102,7 +103,7 @@
|
||||
|
||||
{% if is_initializing %}
|
||||
<!-- Load initialization JavaScript -->
|
||||
<script type="module" src="/loras_static/js/components/initialization.js?v={{ version }}"></script>
|
||||
<script type="module" src="{{ rel_prefix }}loras_static/js/components/initialization.js?v={{ version }}"></script>
|
||||
{% else %}
|
||||
{% block main_script %}{% endblock %}
|
||||
{% endif %}
|
||||
|
||||
@@ -75,5 +75,5 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block main_script %}
|
||||
<script type="module" src="/loras_static/js/checkpoints.js?v={{ version }}"></script>
|
||||
<script type="module" src="{{ rel_prefix }}loras_static/js/checkpoints.js?v={{ version }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
{#
|
||||
Base-path bootstrap. Must be the FIRST element in <head>: it detects the
|
||||
reverse-proxy subpath (e.g. "/comfyui" when served via llama-swap, or
|
||||
"/ComfyBackendDirect" via SwarmUI) from the current page URL and, only when
|
||||
a prefix exists, patches fetch/XHR/WebSocket/innerHTML/DOM URL setters so
|
||||
that root-absolute URLs ("/api/lm/...", "/loras_static/...") generated
|
||||
anywhere in the frontend or in backend JSON payloads get the prefix
|
||||
prepended. When no prefix is detected (normal or standalone deployment)
|
||||
nothing is patched and behavior is byte-identical to before.
|
||||
#}
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Manager page routes as the backend sees them (proxies strip the
|
||||
// prefix, so the page path always ends with one of these suffixes).
|
||||
// Longest first so "/loras/recipes" wins over "/loras".
|
||||
var KNOWN_PAGES = ['/loras/recipes', '/loras', '/checkpoints', '/embeddings', '/other', '/statistics'];
|
||||
|
||||
var path = window.location.pathname.replace(/\/+$/, '') || '/';
|
||||
var prefix = '';
|
||||
for (var i = 0; i < KNOWN_PAGES.length; i++) {
|
||||
var page = KNOWN_PAGES[i];
|
||||
if (path === page || (path.length > page.length && path.endsWith(page))) {
|
||||
prefix = path.slice(0, path.length - page.length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
window.LM_BASE_PATH = prefix;
|
||||
if (!prefix) {
|
||||
return;
|
||||
}
|
||||
|
||||
var prefixBody = prefix.slice(1);
|
||||
|
||||
function prefixPath(p) {
|
||||
if (p.charAt(0) !== '/' || p.charAt(1) === '/') {
|
||||
return p;
|
||||
}
|
||||
// Already prefixed (e.g. markup re-serialized from the DOM).
|
||||
if (p === prefix || p.indexOf(prefix + '/') === 0) {
|
||||
return p;
|
||||
}
|
||||
return prefix + p;
|
||||
}
|
||||
|
||||
function prefixUrl(url) {
|
||||
if (typeof url !== 'string') {
|
||||
return url;
|
||||
}
|
||||
if (url.charAt(0) === '/') {
|
||||
return prefixPath(url);
|
||||
}
|
||||
// Absolute same-origin URL (e.g. from a Request object).
|
||||
if (url.indexOf('http') === 0) {
|
||||
try {
|
||||
var u = new URL(url);
|
||||
if (u.origin === window.location.origin) {
|
||||
var prefixed = prefixPath(u.pathname);
|
||||
if (prefixed !== u.pathname) {
|
||||
u.pathname = prefixed;
|
||||
return u.toString();
|
||||
}
|
||||
}
|
||||
} catch (e) { /* not a parseable URL: leave untouched */ }
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
window.lmWithBasePath = prefixUrl;
|
||||
|
||||
// fetch()
|
||||
if (window.fetch) {
|
||||
var nativeFetch = window.fetch;
|
||||
window.fetch = function (input, init) {
|
||||
if (typeof input === 'string') {
|
||||
input = prefixUrl(input);
|
||||
} else if (typeof URL !== 'undefined' && input instanceof URL) {
|
||||
// fetch(new URL('/api/...', location.origin)) bypasses the
|
||||
// string check — coerce so same-origin URLs get prefixed.
|
||||
input = prefixUrl(input.href);
|
||||
} else if (typeof Request !== 'undefined' && input instanceof Request) {
|
||||
var requestUrl = prefixUrl(input.url);
|
||||
if (requestUrl !== input.url) {
|
||||
input = new Request(requestUrl, input);
|
||||
}
|
||||
}
|
||||
return nativeFetch.call(this, input, init);
|
||||
};
|
||||
}
|
||||
|
||||
// XMLHttpRequest
|
||||
if (window.XMLHttpRequest) {
|
||||
var nativeOpen = window.XMLHttpRequest.prototype.open;
|
||||
window.XMLHttpRequest.prototype.open = function (method, url) {
|
||||
arguments[1] = prefixUrl(typeof url === 'string' ? url : String(url));
|
||||
return nativeOpen.apply(this, arguments);
|
||||
};
|
||||
}
|
||||
|
||||
// WebSocket
|
||||
if (window.WebSocket) {
|
||||
var NativeWebSocket = window.WebSocket;
|
||||
var PatchedWebSocket = function (url, protocols) {
|
||||
if (url && typeof url !== 'string' && url.href) {
|
||||
url = url.href;
|
||||
}
|
||||
return protocols === undefined
|
||||
? new NativeWebSocket(prefixUrl(url))
|
||||
: new NativeWebSocket(prefixUrl(url), protocols);
|
||||
};
|
||||
PatchedWebSocket.prototype = NativeWebSocket.prototype;
|
||||
PatchedWebSocket.CONNECTING = NativeWebSocket.CONNECTING;
|
||||
PatchedWebSocket.OPEN = NativeWebSocket.OPEN;
|
||||
PatchedWebSocket.CLOSING = NativeWebSocket.CLOSING;
|
||||
PatchedWebSocket.CLOSED = NativeWebSocket.CLOSED;
|
||||
window.WebSocket = PatchedWebSocket;
|
||||
}
|
||||
|
||||
// Markup injected via innerHTML/outerHTML/insertAdjacentHTML carries
|
||||
// root-absolute URLs from backend JSON (preview URLs, placeholders) and
|
||||
// inline handlers (onerror="this.src='/...'"): rewrite the attributes.
|
||||
var ATTR_URL_RE = /((?:src|href|poster)\s*=\s*["'])(\/)(?!\/)/g;
|
||||
function rewriteMarkup(html) {
|
||||
if (typeof html !== 'string' || html.indexOf('/') === -1) {
|
||||
return html;
|
||||
}
|
||||
return html.replace(ATTR_URL_RE, function (match, head, slash, offset, whole) {
|
||||
var rest = whole.slice(offset + match.length);
|
||||
if (rest === prefixBody || rest.indexOf(prefixBody + '/') === 0) {
|
||||
return match;
|
||||
}
|
||||
return head + prefix + '/';
|
||||
});
|
||||
}
|
||||
|
||||
function patchMarkupProp(proto, prop) {
|
||||
var desc = Object.getOwnPropertyDescriptor(proto, prop);
|
||||
if (!desc || !desc.set) {
|
||||
return;
|
||||
}
|
||||
Object.defineProperty(proto, prop, {
|
||||
configurable: true,
|
||||
enumerable: desc.enumerable,
|
||||
get: desc.get,
|
||||
set: function (value) { desc.set.call(this, rewriteMarkup(value)); },
|
||||
});
|
||||
}
|
||||
|
||||
if (window.Element) {
|
||||
patchMarkupProp(window.Element.prototype, 'innerHTML');
|
||||
patchMarkupProp(window.Element.prototype, 'outerHTML');
|
||||
var nativeInsertAdjacentHTML = window.Element.prototype.insertAdjacentHTML;
|
||||
if (nativeInsertAdjacentHTML) {
|
||||
window.Element.prototype.insertAdjacentHTML = function (position, html) {
|
||||
return nativeInsertAdjacentHTML.call(this, position, rewriteMarkup(html));
|
||||
};
|
||||
}
|
||||
|
||||
// Direct DOM assignments: img.src = model.preview_url, anchor.href, ...
|
||||
var nativeSetAttribute = window.Element.prototype.setAttribute;
|
||||
window.Element.prototype.setAttribute = function (name, value) {
|
||||
if (typeof value === 'string' && /^(src|href|poster)$/i.test(name)) {
|
||||
value = prefixUrl(value);
|
||||
}
|
||||
return nativeSetAttribute.call(this, name, value);
|
||||
};
|
||||
}
|
||||
|
||||
function patchUrlProp(proto, prop) {
|
||||
if (!proto) {
|
||||
return;
|
||||
}
|
||||
var desc = Object.getOwnPropertyDescriptor(proto, prop);
|
||||
if (!desc || !desc.set) {
|
||||
return;
|
||||
}
|
||||
Object.defineProperty(proto, prop, {
|
||||
configurable: true,
|
||||
enumerable: desc.enumerable,
|
||||
get: desc.get,
|
||||
set: function (value) { desc.set.call(this, prefixUrl(value)); },
|
||||
});
|
||||
}
|
||||
|
||||
patchUrlProp(window.HTMLImageElement && window.HTMLImageElement.prototype, 'src');
|
||||
patchUrlProp(window.HTMLMediaElement && window.HTMLMediaElement.prototype, 'src');
|
||||
patchUrlProp(window.HTMLSourceElement && window.HTMLSourceElement.prototype, 'src');
|
||||
patchUrlProp(window.HTMLVideoElement && window.HTMLVideoElement.prototype, 'poster');
|
||||
patchUrlProp(window.HTMLAnchorElement && window.HTMLAnchorElement.prototype, 'href');
|
||||
patchUrlProp(window.HTMLScriptElement && window.HTMLScriptElement.prototype, 'src');
|
||||
patchUrlProp(window.HTMLLinkElement && window.HTMLLinkElement.prototype, 'href');
|
||||
})();
|
||||
</script>
|
||||
@@ -3,8 +3,8 @@
|
||||
<!-- Left section: Logo + Navigation -->
|
||||
<div class="header-left">
|
||||
<div class="header-branding">
|
||||
<a href="/loras" class="logo-link">
|
||||
<img src="/loras_static/images/favicon-32x32.png" alt="LoRA Manager" class="app-logo">
|
||||
<a href="{{ rel_prefix }}loras" class="logo-link">
|
||||
<img src="{{ rel_prefix }}loras_static/images/favicon-32x32.png" alt="LoRA Manager" class="app-logo">
|
||||
<span class="app-title">{{ t('header.appTitle') }}</span>
|
||||
</a>
|
||||
</div>
|
||||
@@ -23,26 +23,26 @@
|
||||
{% set current_page = 'loras' %}
|
||||
{% endif %}
|
||||
<nav class="main-nav">
|
||||
<a href="/loras" class="nav-item{% if current_path == '/loras' %} active{% endif %}" id="lorasNavItem">
|
||||
<a href="{{ rel_prefix }}loras" class="nav-item{% if current_path == '/loras' %} active{% endif %}" id="lorasNavItem">
|
||||
<i class="fas fa-layer-group"></i> <span>{{ t('header.navigation.loras') }}</span>
|
||||
</a>
|
||||
<a href="/loras/recipes" class="nav-item{% if current_path.startswith('/loras/recipes') %} active{% endif %}"
|
||||
<a href="{{ rel_prefix }}loras/recipes" class="nav-item{% if current_path.startswith('/loras/recipes') %} active{% endif %}"
|
||||
id="recipesNavItem">
|
||||
<i class="fas fa-book-open"></i> <span>{{ t('header.navigation.recipes') }}</span>
|
||||
</a>
|
||||
<a href="/checkpoints" class="nav-item{% if current_path.startswith('/checkpoints') %} active{% endif %}"
|
||||
<a href="{{ rel_prefix }}checkpoints" class="nav-item{% if current_path.startswith('/checkpoints') %} active{% endif %}"
|
||||
id="checkpointsNavItem">
|
||||
<i class="fas fa-check-circle"></i> <span>{{ t('header.navigation.checkpoints') }}</span>
|
||||
</a>
|
||||
<a href="/embeddings" class="nav-item{% if current_path.startswith('/embeddings') %} active{% endif %}"
|
||||
<a href="{{ rel_prefix }}embeddings" class="nav-item{% if current_path.startswith('/embeddings') %} active{% endif %}"
|
||||
id="embeddingsNavItem">
|
||||
<i class="fas fa-code"></i> <span>{{ t('header.navigation.embeddings') }}</span>
|
||||
</a>
|
||||
<a href="/other" class="nav-item{% if current_path.startswith('/other') %} active{% endif %}{% if not settings.get('enable_other_models') %} nav-item--hidden{% endif %}"
|
||||
<a href="{{ rel_prefix }}other" class="nav-item{% if current_path.startswith('/other') %} active{% endif %}{% if not settings.get('enable_other_models') %} nav-item--hidden{% endif %}"
|
||||
id="otherNavItem">
|
||||
<i class="fas fa-shapes"></i> <span>{{ t('header.navigation.other') }}</span>
|
||||
</a>
|
||||
<a href="/statistics" class="nav-item{% if current_path.startswith('/statistics') %} active{% endif %}"
|
||||
<a href="{{ rel_prefix }}statistics" class="nav-item{% if current_path.startswith('/statistics') %} active{% endif %}"
|
||||
id="statisticsNavItem">
|
||||
<i class="fas fa-chart-bar"></i> <span>{{ t('header.navigation.statistics') }}</span>
|
||||
</a>
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<div class="tip-carousel" id="tipCarousel">
|
||||
<div class="tip-item active">
|
||||
<div class="tip-image">
|
||||
<img src="/loras_static/images/tips/civitai-api.png" alt="{{ t('initialization.tips.civitai.alt') }}"
|
||||
<img src="{{ rel_prefix }}loras_static/images/tips/civitai-api.png" alt="{{ t('initialization.tips.civitai.alt') }}"
|
||||
onerror="this.src='/loras_static/images/no-preview.png'">
|
||||
</div>
|
||||
<div class="tip-text">
|
||||
@@ -39,7 +39,7 @@
|
||||
</div>
|
||||
<div class="tip-item">
|
||||
<div class="tip-image">
|
||||
<img src="/loras_static/images/tips/civitai-download.png" alt="{{ t('initialization.tips.download.alt') }}"
|
||||
<img src="{{ rel_prefix }}loras_static/images/tips/civitai-download.png" alt="{{ t('initialization.tips.download.alt') }}"
|
||||
onerror="this.src='/loras_static/images/no-preview.png'">
|
||||
</div>
|
||||
<div class="tip-text">
|
||||
@@ -49,7 +49,7 @@
|
||||
</div>
|
||||
<div class="tip-item">
|
||||
<div class="tip-image">
|
||||
<img src="/loras_static/images/tips/recipes.png" alt="{{ t('initialization.tips.recipes.alt') }}"
|
||||
<img src="{{ rel_prefix }}loras_static/images/tips/recipes.png" alt="{{ t('initialization.tips.recipes.alt') }}"
|
||||
onerror="this.src='/loras_static/images/no-preview.png'">
|
||||
</div>
|
||||
<div class="tip-text">
|
||||
@@ -59,7 +59,7 @@
|
||||
</div>
|
||||
<div class="tip-item">
|
||||
<div class="tip-image">
|
||||
<img src="/loras_static/images/tips/filter.png" alt="{{ t('initialization.tips.filter.alt') }}"
|
||||
<img src="{{ rel_prefix }}loras_static/images/tips/filter.png" alt="{{ t('initialization.tips.filter.alt') }}"
|
||||
onerror="this.src='/loras_static/images/no-preview.png'">
|
||||
</div>
|
||||
<div class="tip-text">
|
||||
@@ -69,7 +69,7 @@
|
||||
</div>
|
||||
<div class="tip-item">
|
||||
<div class="tip-image">
|
||||
<img src="/loras_static/images/tips/search.webp" alt="{{ t('initialization.tips.search.alt') }}"
|
||||
<img src="{{ rel_prefix }}loras_static/images/tips/search.webp" alt="{{ t('initialization.tips.search.alt') }}"
|
||||
onerror="this.src='/loras_static/images/no-preview.png'">
|
||||
</div>
|
||||
<div class="tip-text">
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
<i class="fas fa-chevron-down toggle-icon"></i>
|
||||
</button>
|
||||
<div class="qrcode-container" id="qrCodeContainer">
|
||||
<img src="/loras_static/images/wechat-qr.webp" alt="WeChat Pay QR Code" class="qrcode-image">
|
||||
<img src="{{ rel_prefix }}loras_static/images/wechat-qr.webp" alt="WeChat Pay QR Code" class="qrcode-image">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -71,5 +71,5 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block main_script %}
|
||||
<script type="module" src="/loras_static/js/embeddings.js?v={{ version }}"></script>
|
||||
<script type="module" src="{{ rel_prefix }}loras_static/js/embeddings.js?v={{ version }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -27,6 +27,6 @@
|
||||
|
||||
{% block main_script %}
|
||||
{% if not is_initializing %}
|
||||
<script type="module" src="/loras_static/js/loras.js?v={{ version }}"></script>
|
||||
<script type="module" src="{{ rel_prefix }}loras_static/js/loras.js?v={{ version }}"></script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -167,8 +167,8 @@
|
||||
|
||||
{% block main_script %}
|
||||
{% if other_disabled or other_no_paths %}
|
||||
<script type="module" src="/loras_static/js/other_disabled.js?v={{ version }}"></script>
|
||||
<script type="module" src="{{ rel_prefix }}loras_static/js/other_disabled.js?v={{ version }}"></script>
|
||||
{% else %}
|
||||
<script type="module" src="/loras_static/js/other.js?v={{ version }}"></script>
|
||||
<script type="module" src="{{ rel_prefix }}loras_static/js/other.js?v={{ version }}"></script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
{% block page_id %}recipes{% endblock %}
|
||||
|
||||
{% block page_css %}
|
||||
<link rel="stylesheet" href="/loras_static/css/components/card.css?v={{ version }}">
|
||||
<link rel="stylesheet" href="/loras_static/css/components/recipe-modal.css?v={{ version }}">
|
||||
<link rel="stylesheet" href="/loras_static/css/components/import-modal.css?v={{ version }}">
|
||||
<link rel="stylesheet" href="/loras_static/css/components/batch-import-modal.css?v={{ version }}">
|
||||
<link rel="stylesheet" href="{{ rel_prefix }}loras_static/css/components/card.css?v={{ version }}">
|
||||
<link rel="stylesheet" href="{{ rel_prefix }}loras_static/css/components/recipe-modal.css?v={{ version }}">
|
||||
<link rel="stylesheet" href="{{ rel_prefix }}loras_static/css/components/import-modal.css?v={{ version }}">
|
||||
<link rel="stylesheet" href="{{ rel_prefix }}loras_static/css/components/batch-import-modal.css?v={{ version }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block additional_components %}
|
||||
@@ -113,5 +113,5 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block main_script %}
|
||||
<script type="module" src="/loras_static/js/recipes.js?v={{ version }}"></script>
|
||||
<script type="module" src="{{ rel_prefix }}loras_static/js/recipes.js?v={{ version }}"></script>
|
||||
{% endblock %}
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{% block head_scripts %}
|
||||
<!-- Add Chart.js for statistics page -->
|
||||
<script src="/loras_static/vendor/chart.js/chart.umd.js"></script>
|
||||
<script src="{{ rel_prefix }}loras_static/vendor/chart.js/chart.umd.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block init_title %}{{ t('initialization.statistics.title') }}{% endblock %}
|
||||
@@ -192,6 +192,6 @@
|
||||
|
||||
{% block main_script %}
|
||||
{% if not is_initializing %}
|
||||
<script type="module" src="/loras_static/js/statistics.js?v={{ version }}"></script>
|
||||
<script type="module" src="{{ rel_prefix }}loras_static/js/statistics.js?v={{ version }}"></script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -50,6 +50,22 @@ vi.mock(UTILS_MODULE, () => ({
|
||||
chainCallback: (proto, property, callback) => {
|
||||
proto[property] = callback;
|
||||
},
|
||||
interceptModeChange: (node, onModeChange) => {
|
||||
let currentMode = node.mode;
|
||||
Object.defineProperty(node, "mode", {
|
||||
configurable: true,
|
||||
get() {
|
||||
return currentMode;
|
||||
},
|
||||
set(value) {
|
||||
const oldValue = currentMode;
|
||||
currentMode = value;
|
||||
if (oldValue !== value) {
|
||||
onModeChange(value, oldValue);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
getAllGraphNodes,
|
||||
getNodeFromGraph,
|
||||
getWidgetByName,
|
||||
|
||||
@@ -277,5 +277,44 @@ describe("Node mode change handling", () => {
|
||||
new Set(["LoaderLora1", "LoaderLora2"])
|
||||
);
|
||||
});
|
||||
|
||||
it("should keep bypass state in the shell state on ECS frontends (issue #1123)", async () => {
|
||||
// ComfyUI frontend >= 1.53 backs `mode` with a prototype accessor over
|
||||
// `node._state.mode` and serializes from `_state`; the interceptor must
|
||||
// delegate to it instead of shadowing it.
|
||||
class EcsLGraphNode {
|
||||
constructor() {
|
||||
this._state = { mode: 0 };
|
||||
}
|
||||
get mode() {
|
||||
return this._state.mode;
|
||||
}
|
||||
set mode(value) {
|
||||
this._state.mode = value;
|
||||
}
|
||||
}
|
||||
|
||||
const ecsNode = new EcsLGraphNode();
|
||||
Object.assign(ecsNode, {
|
||||
comfyClass: "Lora Loader (LoraManager)",
|
||||
widgets: [
|
||||
{ name: "text", value: "", options: {}, callback: null },
|
||||
{ name: "loras", value: [], options: {}, callback: null },
|
||||
],
|
||||
addInput: vi.fn(),
|
||||
graph: {},
|
||||
});
|
||||
|
||||
const nodeType = { comfyClass: "Lora Loader (LoraManager)", prototype: {} };
|
||||
await extension.beforeRegisterNodeDef(nodeType, {}, {});
|
||||
nodeType.prototype.onNodeCreated.call(ecsNode);
|
||||
|
||||
// Bypass the node: the write must reach the shell state that
|
||||
// serialization reads from.
|
||||
ecsNode.mode = 4;
|
||||
expect(ecsNode._state.mode).toBe(4);
|
||||
expect(ecsNode.mode).toBe(4);
|
||||
expect(updateConnectedTriggerWords).toHaveBeenCalledWith(ecsNode, expect.anything());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import piexif
|
||||
import piexif.helper
|
||||
import pytest
|
||||
from PIL import Image, PngImagePlugin
|
||||
|
||||
from py.nodes.load_image_metadata import LoadImageMetadataLM, MetadataError, resolve_resource
|
||||
from py.utils.exif_utils import ExifUtils
|
||||
|
||||
|
||||
PARAMETERS = 'cat <lora:style:0.7:0.2>\nNegative prompt: blur\nSteps: 25, Sampler: Euler, Schedule type: Normal, CFG scale: 6.5, Seed: 18446744073709551615, Size: 768x1024, Model: base'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runtime(tmp_path, monkeypatch):
|
||||
import comfy
|
||||
import folder_paths
|
||||
import nodes
|
||||
|
||||
image_path = tmp_path / "input.png"
|
||||
info = PngImagePlugin.PngInfo()
|
||||
info.add_text("parameters", PARAMETERS)
|
||||
Image.new("RGB", (16, 24)).save(image_path, pnginfo=info)
|
||||
model = tmp_path / "base.safetensors"
|
||||
lora = tmp_path / "style.safetensors"
|
||||
model.touch()
|
||||
lora.touch()
|
||||
library = ([{"file_path": str(model), "sub_type": "checkpoint"}], [str(tmp_path)], [{"file_path": str(lora)}], [str(tmp_path)])
|
||||
monkeypatch.setattr(LoadImageMetadataLM, "_library", staticmethod(lambda: library))
|
||||
monkeypatch.setattr(folder_paths, "get_annotated_filepath", lambda name: str(image_path), raising=False)
|
||||
monkeypatch.setattr(folder_paths, "exists_annotated_filepath", lambda name: image_path.exists(), raising=False)
|
||||
pixels = types.SimpleNamespace(shape=(1, 24, 16, 3))
|
||||
mask = object()
|
||||
class LoadImage:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required": {"image": (["input.png"], {"image_upload": True})}}
|
||||
|
||||
def load_image(self, name):
|
||||
return pixels, mask
|
||||
monkeypatch.setattr(nodes, "LoadImage", LoadImage, raising=False)
|
||||
samplers = types.ModuleType("comfy.samplers")
|
||||
samplers.KSampler = types.SimpleNamespace(SAMPLERS=["euler", "dpmpp_2m"], SCHEDULERS=["normal", "karras"])
|
||||
monkeypatch.setitem(sys.modules, "comfy.samplers", samplers)
|
||||
monkeypatch.setattr(comfy, "samplers", samplers, raising=False)
|
||||
return image_path, library, pixels, mask
|
||||
|
||||
|
||||
def test_full_node_contract_with_real_png_metadata(runtime):
|
||||
_, library, pixels, mask = runtime
|
||||
result = LoadImageMetadataLM().load_metadata("input.png")
|
||||
assert len(result) == len(LoadImageMetadataLM.RETURN_TYPES)
|
||||
assert result[:4] == (pixels, mask, "cat", "blur")
|
||||
assert result[5] == [(library[2][0]["file_path"], .7, .2)]
|
||||
assert result[7:15] == (2**64 - 1, 25, 6.5, "euler", "normal", 768, 1024, 1.0)
|
||||
assert "Resolved 1 LoRA" in result[15]
|
||||
assert LoadImageMetadataLM.INPUT_TYPES()["required"]["image"][1]["image_upload"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("extension", ["webp", "jpg"])
|
||||
def test_exif_parameters_from_real_image(runtime, extension):
|
||||
image_path, *_ = runtime
|
||||
exif = piexif.dump({"Exif": {piexif.ExifIFD.UserComment: piexif.helper.UserComment.dump(PARAMETERS, encoding="unicode")}})
|
||||
alternate = image_path.with_suffix("." + extension)
|
||||
Image.new("RGB", (16, 24)).save(alternate, exif=exif)
|
||||
fields = ExifUtils._load_structured_metadata(str(alternate))
|
||||
assert "Steps: 25" in fields["parameters"]
|
||||
|
||||
|
||||
def test_missing_lora_strict_or_explicit_skip(runtime):
|
||||
runtime[1][2].clear()
|
||||
strict_result = LoadImageMetadataLM().load_metadata("input.png")
|
||||
assert strict_result[5] == []
|
||||
assert "LoRA: style | model weight: 0.7 | CLIP weight: 0.2" in strict_result[17]
|
||||
result = LoadImageMetadataLM().load_metadata("input.png", missing_settings="use_defaults")
|
||||
assert result[5] == []
|
||||
assert "Skipped LoRA" in result[15]
|
||||
|
||||
|
||||
def test_overrides_replace_loras_and_preserve_large_seed(runtime):
|
||||
result = LoadImageMetadataLM().load_metadata("input.png", overrides_json=json.dumps({"seed": 2**64 - 2, "loras": [], "positive": "changed"}))
|
||||
assert result[2] == "changed"
|
||||
assert result[5] == []
|
||||
assert result[7] == 2**64 - 2
|
||||
|
||||
|
||||
def test_no_metadata_can_be_inspected_with_defaults(runtime):
|
||||
Image.new("RGB", (16, 24)).save(runtime[0])
|
||||
assert LoadImageMetadataLM().load_metadata("input.png")[12:14] == (1024, 1024)
|
||||
result = LoadImageMetadataLM().load_metadata("input.png", missing_settings="use_defaults")
|
||||
assert result[12:14] == (1024, 1024)
|
||||
assert "No model resolved" in result[15]
|
||||
|
||||
|
||||
def test_graph_without_recognized_latent_falls_back_to_image_size(runtime):
|
||||
info = PngImagePlugin.PngInfo()
|
||||
graph = {
|
||||
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "base.safetensors"}},
|
||||
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": "pos", "clip": ["1", 1]}},
|
||||
"3": {"class_type": "CLIPTextEncode", "inputs": {"text": "neg", "clip": ["1", 1]}},
|
||||
"5": {"class_type": "KSampler", "inputs": {
|
||||
"model": ["1", 0], "positive": ["2", 0], "negative": ["3", 0],
|
||||
"latent_image": ["9", 0], "seed": 1, "steps": 20, "cfg": 7,
|
||||
"sampler_name": "euler", "scheduler": "normal", "denoise": 1,
|
||||
}},
|
||||
"9": {"class_type": "VAEEncode", "inputs": {"pixels": ["10", 0], "vae": ["1", 2]}},
|
||||
}
|
||||
info.add_text("prompt", json.dumps(graph))
|
||||
Image.new("RGB", (16, 24)).save(runtime[0], pnginfo=info)
|
||||
# The mocked loader returns pixels with shape (1, 24, 16, 3): H=24, W=16.
|
||||
result = LoadImageMetadataLM().load_metadata("input.png")
|
||||
assert result[12:14] == (16, 24)
|
||||
assert "using source image dimension" in result[15]
|
||||
assert "❌ ERROR" not in result[16]
|
||||
|
||||
|
||||
def test_parameters_without_size_fall_back_to_image_size(runtime):
|
||||
info = PngImagePlugin.PngInfo()
|
||||
info.add_text("parameters", PARAMETERS.replace(", Size: 768x1024", ""))
|
||||
Image.new("RGB", (16, 24)).save(runtime[0], pnginfo=info)
|
||||
result = LoadImageMetadataLM().load_metadata("input.png")
|
||||
assert result[12:14] == (16, 24)
|
||||
|
||||
|
||||
def test_size_override_wins_over_image_size_fallback(runtime):
|
||||
info = PngImagePlugin.PngInfo()
|
||||
info.add_text("parameters", PARAMETERS.replace(", Size: 768x1024", ""))
|
||||
Image.new("RGB", (16, 24)).save(runtime[0], pnginfo=info)
|
||||
result = LoadImageMetadataLM().load_metadata("input.png", overrides_json='{"width": 512, "height": 640}')
|
||||
assert result[12:14] == (512, 640)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("override", [{"seed": -1}, {"steps": 2.5}, {"cfg": float("nan")}, {"sampler_name": "made_up"}, {"positive": ["1", 0]}, {"unknown": 1}])
|
||||
def test_invalid_override_rejected(runtime, override):
|
||||
with pytest.raises((MetadataError, ValueError)):
|
||||
LoadImageMetadataLM().load_metadata("input.png", overrides_json=json.dumps(override))
|
||||
|
||||
|
||||
def test_duplicate_basenames_require_path(tmp_path):
|
||||
items = []
|
||||
for folder in ("a", "b"):
|
||||
directory = tmp_path / folder
|
||||
directory.mkdir()
|
||||
path = directory / "same.safetensors"
|
||||
path.touch()
|
||||
items.append({"file_path": str(path)})
|
||||
with pytest.raises(MetadataError, match="Ambiguous"):
|
||||
resolve_resource("same", items, [str(tmp_path)])
|
||||
assert resolve_resource("b/same.safetensors", items, [str(tmp_path)]) == items[1]
|
||||
assert resolve_resource("b/same", items, [str(tmp_path)]) == items[1]
|
||||
|
||||
|
||||
def test_file_hash_detects_replacement_and_accepts_all_inputs(runtime):
|
||||
before = LoadImageMetadataLM.IS_CHANGED("input.png", sampler_node_id="", missing_settings="strict", overrides_json="{}")
|
||||
Image.new("RGB", (32, 32)).save(runtime[0])
|
||||
assert before != LoadImageMetadataLM.IS_CHANGED("input.png")
|
||||
|
||||
|
||||
def test_comfy_webp_exif_prompt_fields(runtime):
|
||||
image_path, *_ = runtime
|
||||
graph = {"1": {"class_type": "KSampler", "inputs": {"seed": 42}}}
|
||||
exif = piexif.dump({"0th": {
|
||||
piexif.ImageIFD.Make: "prompt:" + json.dumps(graph),
|
||||
piexif.ImageIFD.Model: 'workflow:{"nodes": []}',
|
||||
}})
|
||||
alternate = image_path.with_suffix(".webp")
|
||||
Image.new("RGB", (16, 24)).save(alternate, exif=exif)
|
||||
fields = ExifUtils._load_structured_metadata(str(alternate))
|
||||
assert json.loads(fields["prompt"]) == graph
|
||||
assert json.loads(fields["workflow"]) == {"nodes": []}
|
||||
|
||||
|
||||
|
||||
def test_report_preserves_extracted_names_without_catalog(runtime):
|
||||
runtime[1][0].clear()
|
||||
runtime[1][2].clear()
|
||||
result = LoadImageMetadataLM().load_metadata("input.png", missing_settings="use_defaults")
|
||||
payload = json.loads(result[15].split("\n\n", 1)[1])
|
||||
assert result[4:7] == ("", [], "")
|
||||
assert payload["source_resources"]["checkpoint_name"] == "base"
|
||||
assert payload["source_resources"]["loras"] == [["style", .7, .2]]
|
||||
|
||||
|
||||
# These user-provided images are optional local integration fixtures, not assets
|
||||
# required by the public test suite.
|
||||
_SAMPLE_PNGS = sorted((Path(__file__).resolve().parents[2] / "_tmp").glob("*.png"))
|
||||
_SAMPLE_PNGS = [path for path in _SAMPLE_PNGS if path.stem.endswith("_")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sample", _SAMPLE_PNGS or [pytest.param(None, marks=pytest.mark.skip(reason="No local PNG samples"))], ids=lambda path: path.name if path else "no-samples")
|
||||
def test_local_png_node_without_catalog(runtime, monkeypatch, sample):
|
||||
import comfy.samplers
|
||||
import folder_paths
|
||||
|
||||
runtime[1][0].clear()
|
||||
runtime[1][2].clear()
|
||||
monkeypatch.setattr(folder_paths, "get_annotated_filepath", lambda name: str(sample))
|
||||
monkeypatch.setattr(comfy.samplers.KSampler, "SAMPLERS", ["euler", "euler_ancestral", "er_sde"])
|
||||
monkeypatch.setattr(comfy.samplers.KSampler, "SCHEDULERS", ["normal", "simple", "sgm_uniform"])
|
||||
result = LoadImageMetadataLM().load_metadata(sample.name, missing_settings="use_defaults")
|
||||
payload = json.loads(result[15].split("\n\n", 1)[1])
|
||||
assert result[2] and result[3]
|
||||
assert "<lora:" not in result[2]
|
||||
assert result[7] == int(sample.stem.split("_")[-3])
|
||||
assert result[4:7] == ("", [], "")
|
||||
assert "Default " not in result[15]
|
||||
assert "Replaced unsupported" not in result[15]
|
||||
assert payload["source_resources"]["checkpoint_name"] in sample.name
|
||||
expected_count = 0 if any(name in sample.name for name in ("hyphoria", "pieModelsAnima")) else 1
|
||||
assert len(payload["source_resources"]["loras"]) == expected_count
|
||||
|
||||
|
||||
@pytest.mark.parametrize("chunk_type", [b"tEXt", b"zTXt", b"iTXt"])
|
||||
def test_png_metadata_after_pixel_data_is_read(runtime, chunk_type):
|
||||
import struct
|
||||
import zlib
|
||||
|
||||
image_path = runtime[0]
|
||||
Image.new("RGB", (16, 24)).save(image_path)
|
||||
original = image_path.read_bytes()
|
||||
encoded = PARAMETERS.encode("utf-8")
|
||||
if chunk_type == b"zTXt":
|
||||
payload = b"parameters\0\0" + zlib.compress(encoded)
|
||||
elif chunk_type == b"iTXt":
|
||||
payload = b"parameters\0\0\0\0\0" + encoded
|
||||
else:
|
||||
payload = b"parameters\0" + encoded
|
||||
chunk = (struct.pack(">I", len(payload)) + chunk_type + payload
|
||||
+ struct.pack(">I", zlib.crc32(chunk_type + payload) & 0xFFFFFFFF))
|
||||
# Place metadata immediately before IEND, after all pixel data.
|
||||
image_path.write_bytes(original[:-12] + chunk + original[-12:])
|
||||
result = LoadImageMetadataLM().load_metadata("input.png")
|
||||
assert result[2:4] == ("cat", "blur")
|
||||
assert result[4] == "base.safetensors"
|
||||
assert result[7] == 2**64 - 1
|
||||
|
||||
|
||||
def test_missing_metadata_report_identifies_actual_file(runtime):
|
||||
Image.new("RGB", (16, 24)).save(runtime[0])
|
||||
message = LoadImageMetadataLM().load_metadata("input.png")[15]
|
||||
assert str(runtime[0]) in message
|
||||
assert "Format: PNG" in message
|
||||
assert "metadata keys: (none)" in message
|
||||
assert "settings were not extracted" in message
|
||||
|
||||
|
||||
|
||||
def test_readable_report_contains_settings_prompts_and_missing_resources(runtime):
|
||||
runtime[1][0].clear()
|
||||
runtime[1][2].clear()
|
||||
result = LoadImageMetadataLM().load_metadata("input.png", missing_settings="use_defaults")
|
||||
readable = result[16]
|
||||
assert LoadImageMetadataLM.RETURN_NAMES[16] == "readable_report"
|
||||
assert "Checkpoint recorded in image: base" in readable
|
||||
assert "No local model resolved." in readable
|
||||
assert "Seed: 18446744073709551615" in readable
|
||||
assert "Sampler: euler" in readable
|
||||
assert "Size: 768 × 1024" in readable
|
||||
assert "style (model: 0.7, CLIP: 0.2)" in readable
|
||||
assert "Resolved locally: 0 of 1 requested entries." in readable
|
||||
assert "POSITIVE PROMPT\ncat" in readable
|
||||
assert "NEGATIVE PROMPT\nblur" in readable
|
||||
assert "WARNING" in readable
|
||||
assert json.loads(result[15].split("\n\n", 1)[1])["seed"] == 2**64 - 1
|
||||
|
||||
|
||||
|
||||
def test_empty_metadata_starter_respects_overrides_and_indexed_model(runtime):
|
||||
Image.new("RGB", (16, 24)).save(runtime[0])
|
||||
base = runtime[0].parent / "sd_xl_base_1.0.safetensors"
|
||||
base.touch()
|
||||
runtime[1][0].append({"file_path": str(base), "sub_type": "checkpoint"})
|
||||
result = LoadImageMetadataLM().load_metadata("input.png", overrides_json='{"seed": 123, "positive": "custom prompt", "width": 768}')
|
||||
assert result[2] == "custom prompt"
|
||||
assert result[4] == base.name
|
||||
assert result[7] == 123
|
||||
assert result[12:14] == (768, 1024)
|
||||
assert result[5] == []
|
||||
|
||||
|
||||
def test_user_example_png_runs_with_saved_strict_setting(runtime, monkeypatch):
|
||||
import folder_paths
|
||||
|
||||
path = Path(__file__).resolve().parents[2] / "_tmp" / "example.png"
|
||||
if not path.exists():
|
||||
pytest.skip("No local example.png fixture")
|
||||
monkeypatch.setattr(folder_paths, "get_annotated_filepath", lambda name: str(path))
|
||||
assert not any(ExifUtils._load_structured_metadata(str(path)).values())
|
||||
runtime[1][0].clear()
|
||||
runtime[1][2].clear()
|
||||
result = LoadImageMetadataLM().load_metadata("example.png", missing_settings="strict")
|
||||
assert "glass bottle" in result[2]
|
||||
assert result[3] == "text, watermark"
|
||||
assert result[4:7] == ("", [], "")
|
||||
assert result[7:15] == (0, 20, 7.0, "euler", "normal", 1024, 1024, 1.0)
|
||||
assert "starter preset" in result[16]
|
||||
|
||||
|
||||
|
||||
def test_missing_files_includes_model_and_lora_in_strict_mode(runtime):
|
||||
runtime[1][0].clear()
|
||||
runtime[1][2].clear()
|
||||
result = LoadImageMetadataLM().load_metadata("input.png", missing_settings="strict")
|
||||
assert result[4:7] == ("", [], "")
|
||||
assert "Model: base" in result[17]
|
||||
assert "LoRA: style | model weight: 0.7 | CLIP weight: 0.2" in result[17]
|
||||
assert LoadImageMetadataLM.RETURN_NAMES[17] == "missing_files"
|
||||
|
||||
|
||||
def test_missing_files_keeps_valid_stack_entries(runtime):
|
||||
result = LoadImageMetadataLM().load_metadata("input.png", overrides_json=json.dumps({"loras": [["style", .7, .2], ["missing", -.5, 0]]}))
|
||||
assert result[5] == [(runtime[1][2][0]["file_path"], .7, .2)]
|
||||
assert "LoRA: missing | model weight: -0.5 | CLIP weight: 0" in result[17]
|
||||
assert "LoRA: style" not in result[17]
|
||||
assert LoadImageMetadataLM().load_metadata("input.png")[17] == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize("subtype", ["checkpoint", "diffusion_model"])
|
||||
def test_generic_model_name_resolves_both_model_categories(runtime, subtype):
|
||||
runtime[1][0][0]["sub_type"] = subtype
|
||||
result = LoadImageMetadataLM().load_metadata("input.png")
|
||||
assert result[4] == "base.safetensors"
|
||||
assert result[17] == ""
|
||||
assert subtype in result[16]
|
||||
assert LoadImageMetadataLM.RETURN_NAMES[4:7] == ("model_name", "lora_stack", "lora_stack_text")
|
||||
assert result[6] == f"{runtime[1][2][0]['file_path']} | model weight: 0.7 | CLIP weight: 0.2"
|
||||
|
||||
|
||||
def test_duplicate_model_names_across_categories_require_path(runtime):
|
||||
directory = runtime[0].parent / "unet"
|
||||
directory.mkdir()
|
||||
model = directory / "base.safetensors"
|
||||
model.touch()
|
||||
runtime[1][0].append({"file_path": str(model), "sub_type": "diffusion_model"})
|
||||
# The exact root-relative name wins when present.
|
||||
result = LoadImageMetadataLM().load_metadata("input.png", overrides_json='{"model_name":"unet/base.safetensors"}')
|
||||
assert result[4] == "unet/base.safetensors"
|
||||
result = LoadImageMetadataLM().load_metadata("input.png", overrides_json='{"model_name":"old/base.safetensors"}')
|
||||
assert result[4] == ""
|
||||
assert "Ambiguous" in result[17]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["model_name", "checkpoint_name", "unet_name"])
|
||||
def test_model_override_aliases(runtime, key):
|
||||
runtime[1][0][0]["sub_type"] = "diffusion_model"
|
||||
result = LoadImageMetadataLM().load_metadata("input.png", overrides_json=json.dumps({key: "base.safetensors"}))
|
||||
assert result[4] == "base.safetensors"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("policy", ["strict", "use_defaults"])
|
||||
def test_unsupported_sampler_returns_defaults_and_error(runtime, policy):
|
||||
info = PngImagePlugin.PngInfo()
|
||||
info.add_text("prompt", json.dumps({"1": {"class_type": "CustomSampler", "inputs": {}}}))
|
||||
Image.new("RGB", (16, 24)).save(runtime[0], pnginfo=info)
|
||||
result = LoadImageMetadataLM().load_metadata("input.png", missing_settings=policy)
|
||||
assert result[7:15] == (0, 20, 7.0, "euler", "normal", 1024, 1024, 1.0)
|
||||
assert "glass bottle" in result[2]
|
||||
assert result[5] == []
|
||||
assert "❌ ERROR" in result[16]
|
||||
assert "supported sampler IDs: none" in result[16]
|
||||
assert "⚙️ SAMPLING" in result[16]
|
||||
|
||||
|
||||
def test_unsupported_graph_uses_valid_parameters_before_defaults(runtime):
|
||||
info = PngImagePlugin.PngInfo()
|
||||
info.add_text("prompt", json.dumps({"1": {"class_type": "CustomSampler", "inputs": {}}}))
|
||||
info.add_text("parameters", PARAMETERS)
|
||||
Image.new("RGB", (16, 24)).save(runtime[0], pnginfo=info)
|
||||
result = LoadImageMetadataLM().load_metadata("input.png", missing_settings="strict", prefer_saved_image_metadata=False)
|
||||
assert result[2] == "cat"
|
||||
assert result[7] == 2**64 - 1
|
||||
assert result[8] == 25
|
||||
assert "recovered saved generation parameters" in result[16]
|
||||
assert "❌ ERROR" in result[16]
|
||||
|
||||
|
||||
def test_invalid_extracted_number_preserves_other_settings(runtime):
|
||||
info = PngImagePlugin.PngInfo()
|
||||
info.add_text("parameters", PARAMETERS.replace("CFG scale: 6.5", "CFG scale: nan"))
|
||||
Image.new("RGB", (16, 24)).save(runtime[0], pnginfo=info)
|
||||
result = LoadImageMetadataLM().load_metadata("input.png")
|
||||
assert result[9] == 7.0
|
||||
assert result[8] == 25
|
||||
assert "ERROR: Invalid cfg" in result[16]
|
||||
|
||||
|
||||
|
||||
def test_actual_custom_sampler_png_uses_saved_parameters(runtime, monkeypatch):
|
||||
import comfy.samplers
|
||||
import folder_paths
|
||||
|
||||
path = Path(__file__).resolve().parents[2] / "_tmp" / "20260613-122517_S4_unnamedaANIMA_v10_617459040116303.png"
|
||||
if not path.exists():
|
||||
pytest.skip("No local custom sampler PNG")
|
||||
monkeypatch.setattr(folder_paths, "get_annotated_filepath", lambda name: str(path))
|
||||
monkeypatch.setattr(comfy.samplers.KSampler, "SAMPLERS", ["euler", "er_sde"])
|
||||
monkeypatch.setattr(comfy.samplers.KSampler, "SCHEDULERS", ["normal", "simple"])
|
||||
result = LoadImageMetadataLM().load_metadata(path.name, missing_settings="strict", prefer_saved_image_metadata=False)
|
||||
assert result[7:15] == (617459040116303, 30, 4.0, "er_sde", "simple", 1664, 1088, 1.0)
|
||||
assert result[2]
|
||||
assert "❌ ERROR" in result[16]
|
||||
assert "recovered saved generation parameters" in result[16]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("selector", ["1481:1783", "1481/1783", "1481", "1783"])
|
||||
def test_actual_png_subgraph_sampler_selection(runtime, monkeypatch, selector):
|
||||
import comfy.samplers
|
||||
import folder_paths
|
||||
|
||||
path = Path(__file__).resolve().parents[2] / "_tmp" / "20260613-122517_S4_unnamedaANIMA_v10_617459040116303.png"
|
||||
if not path.exists():
|
||||
pytest.skip("No local custom sampler PNG")
|
||||
monkeypatch.setattr(folder_paths, "get_annotated_filepath", lambda name: str(path))
|
||||
monkeypatch.setattr(comfy.samplers.KSampler, "SAMPLERS", ["euler", "er_sde"])
|
||||
monkeypatch.setattr(comfy.samplers.KSampler, "SCHEDULERS", ["normal", "simple"])
|
||||
result = LoadImageMetadataLM().load_metadata(path.name, sampler_node_id=selector, prefer_saved_image_metadata=False)
|
||||
assert result[7:12] == (617459040116303, 30, 4.0, "er_sde", "simple")
|
||||
assert "sampler 1481:1783" in result[16]
|
||||
assert "Detail Daemon" in result[16]
|
||||
assert "recovered saved generation parameters" not in result[16]
|
||||
|
||||
|
||||
def test_source_preference_flag_defaults_true(runtime):
|
||||
assert LoadImageMetadataLM.INPUT_TYPES()["required"]["prefer_saved_image_metadata"][1]["default"] is True
|
||||
info = PngImagePlugin.PngInfo()
|
||||
info.add_text("parameters", PARAMETERS)
|
||||
info.add_text("prompt", json.dumps({"1": {"class_type": "CustomSampler", "inputs": {}}}))
|
||||
Image.new("RGB", (16, 24)).save(runtime[0], pnginfo=info)
|
||||
result = LoadImageMetadataLM().load_metadata("input.png")
|
||||
assert result[7] == 2**64 - 1
|
||||
assert "saved image generation parameters (preferred)" in result[16]
|
||||
assert "❌ ERROR" not in result[16]
|
||||
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["Kroma.v2.1", "Kroma.v2.1.safetensors", " Kroma.v2.1 "])
|
||||
def test_model_resolution_preserves_dotted_extensionless_names(tmp_path, name):
|
||||
directory = tmp_path / "Krea 2"
|
||||
directory.mkdir()
|
||||
path = directory / "Kroma.v2.1.safetensors"
|
||||
path.touch()
|
||||
item = {"file_path": str(path)}
|
||||
assert resolve_resource(name, [item], [str(tmp_path)]) == item
|
||||
|
||||
|
||||
def test_model_resolution_accepts_unique_catalog_model_name(tmp_path):
|
||||
path = tmp_path / "local-renamed.safetensors"
|
||||
path.touch()
|
||||
item = {"file_path": str(path), "model_name": "Kroma catalog name"}
|
||||
assert resolve_resource("Kroma catalog name", [item], [str(tmp_path)]) == item
|
||||
|
||||
|
||||
def test_catalog_alias_ambiguity_and_stale_entries(tmp_path):
|
||||
items = []
|
||||
for name in ("a", "b"):
|
||||
path = tmp_path / (name + ".safetensors")
|
||||
path.touch()
|
||||
items.append({"file_path": str(path), "model_name": "Kroma"})
|
||||
with pytest.raises(MetadataError, match="Ambiguous"):
|
||||
resolve_resource("Kroma", items, [str(tmp_path)])
|
||||
items.append({"file_path": str(tmp_path / "absent.safetensors"), "model_name": "missing"})
|
||||
with pytest.raises(MetadataError, match="could not be matched"):
|
||||
resolve_resource("missing", items, [str(tmp_path)])
|
||||
assert resolve_resource("a.safetensors", items, [str(tmp_path)]) == items[0]
|
||||
@@ -200,3 +200,19 @@ def test_lora_loader_qwen_model_raises_clear_error_when_helper_import_fails(monk
|
||||
[],
|
||||
lora_stack=[("stack_qwen.safetensors", 0.6, 0.1)],
|
||||
)
|
||||
|
||||
|
||||
def test_stack_entry_keeps_resolved_absolute_path(monkeypatch):
|
||||
from py.nodes.lora_loader import _collect_stack_entries
|
||||
|
||||
seen = []
|
||||
|
||||
def resolve(name):
|
||||
seen.append(name)
|
||||
return name, ["trigger"]
|
||||
|
||||
monkeypatch.setattr("py.nodes.lora_loader.get_lora_info_absolute", resolve)
|
||||
result = _collect_stack_entries([("/models/b/same.safetensors", .7, .3)])
|
||||
assert seen == ["/models/b/same.safetensors"]
|
||||
assert result[0]["absolute_path"] == "/models/b/same.safetensors"
|
||||
assert result[0]["clip_strength"] == .3
|
||||
|
||||
@@ -84,3 +84,100 @@ def test_prompt_lm_is_changed_forces_rerun_without_seed_when_text_is_dynamic():
|
||||
def test_prompt_lm_is_changed_keeps_cache_for_seeded_or_static_text():
|
||||
assert PromptLM.IS_CHANGED("__flower__", clip="clip", seed=11) is False
|
||||
assert PromptLM.IS_CHANGED("plain text", clip="clip", seed=None) is False
|
||||
|
||||
|
||||
def _linked_prompt(upstream_inputs):
|
||||
return {
|
||||
"1": {"class_type": "TextMultiline", "inputs": upstream_inputs},
|
||||
"2": {
|
||||
"class_type": "PromptLM",
|
||||
"inputs": {"text": ["1", 0], "clip": ["3", 0]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_prompt_lm_is_changed_forces_rerun_for_linked_dynamic_text():
|
||||
prompt = _linked_prompt({"text": "{red|blue|green}"})
|
||||
|
||||
result = PromptLM.IS_CHANGED(None, clip="clip", seed=None, prompt=prompt, unique_id="2")
|
||||
|
||||
assert result != result
|
||||
|
||||
|
||||
def test_prompt_lm_is_changed_keeps_cache_for_linked_static_text():
|
||||
prompt = _linked_prompt({"text": "a plain static prompt"})
|
||||
|
||||
assert PromptLM.IS_CHANGED(None, clip="clip", seed=None, prompt=prompt, unique_id="2") is False
|
||||
assert PromptLM.IS_CHANGED(None, clip="clip", seed=5, prompt=prompt, unique_id="2") is False
|
||||
|
||||
|
||||
def test_prompt_lm_is_changed_forces_rerun_when_linked_text_unresolvable():
|
||||
chained = _linked_prompt({"text": ["9", 0]})
|
||||
|
||||
result = PromptLM.IS_CHANGED(None, clip="clip", seed=None, prompt=chained, unique_id="2")
|
||||
|
||||
assert result != result
|
||||
assert PromptLM.IS_CHANGED(None, clip="clip", seed=None, prompt=None, unique_id="2") != 0
|
||||
missing_upstream = _linked_prompt({"text": "static"})
|
||||
missing_upstream["2"]["inputs"]["text"] = ["99", 0]
|
||||
assert (
|
||||
PromptLM.IS_CHANGED(None, clip="clip", seed=None, prompt=missing_upstream, unique_id="2")
|
||||
!= 0
|
||||
)
|
||||
|
||||
|
||||
def test_text_lm_is_changed_forces_rerun_for_linked_dynamic_text():
|
||||
prompt = _linked_prompt({"text": "__flower__"})
|
||||
|
||||
result = TextLM.IS_CHANGED(None, seed=None, prompt=prompt, unique_id="2")
|
||||
|
||||
assert result != result
|
||||
|
||||
|
||||
def test_text_lm_is_changed_keeps_cache_for_linked_static_text():
|
||||
prompt = _linked_prompt({"text": "a plain static prompt"})
|
||||
|
||||
assert TextLM.IS_CHANGED(None, seed=None, prompt=prompt, unique_id="2") is False
|
||||
|
||||
|
||||
def test_text_lm_process_accepts_hidden_inputs(monkeypatch):
|
||||
node = TextLM()
|
||||
|
||||
class StubService:
|
||||
def expand_text(self, text, seed=None):
|
||||
return text
|
||||
|
||||
monkeypatch.setattr("py.nodes.text.get_wildcard_service", lambda: StubService())
|
||||
|
||||
assert node.process("hello", seed=None, prompt={}, unique_id="2") == ("hello",)
|
||||
|
||||
|
||||
def test_prompt_lm_encode_accepts_hidden_inputs(monkeypatch):
|
||||
node = PromptLM()
|
||||
|
||||
class StubService:
|
||||
def expand_text(self, text, seed=None):
|
||||
return text
|
||||
|
||||
class StubEncoder:
|
||||
def encode(self, clip, prompt):
|
||||
return ("conditioning",)
|
||||
|
||||
monkeypatch.setattr("py.nodes.prompt.get_wildcard_service", lambda: StubService())
|
||||
monkeypatch.setattr("nodes.CLIPTextEncode", lambda: StubEncoder(), raising=False)
|
||||
|
||||
result = node.encode("hello", "clip", seed=None, prompt={}, unique_id="2")
|
||||
|
||||
assert result == ("conditioning", "hello")
|
||||
|
||||
|
||||
def test_prompt_lm_input_types_declare_hidden_prompt_inputs():
|
||||
hidden = PromptLM.INPUT_TYPES()["hidden"]
|
||||
|
||||
assert hidden == {"prompt": "PROMPT", "unique_id": "UNIQUE_ID"}
|
||||
|
||||
|
||||
def test_text_lm_input_types_declare_hidden_prompt_inputs():
|
||||
hidden = TextLM.INPUT_TYPES()["hidden"]
|
||||
|
||||
assert hidden == {"prompt": "PROMPT", "unique_id": "UNIQUE_ID"}
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
]),
|
||||
'settings': dict({
|
||||
'civitai_api_key_set': True,
|
||||
'huggingface_api_key_set': False,
|
||||
'language': 'en',
|
||||
'llm_api_key_set': False,
|
||||
'other_models_paths_available': False,
|
||||
|
||||
@@ -55,10 +55,10 @@ async def test_model_page_view_reads_version_per_request():
|
||||
)
|
||||
|
||||
view._get_app_version = lambda: "1.0.2-old"
|
||||
first = await view.handle(SimpleNamespace()) # pyright: ignore[reportArgumentType]
|
||||
first = await view.handle(SimpleNamespace(path="/loras")) # pyright: ignore[reportArgumentType]
|
||||
|
||||
view._get_app_version = lambda: "1.0.2-new"
|
||||
second = await view.handle(SimpleNamespace()) # pyright: ignore[reportArgumentType]
|
||||
second = await view.handle(SimpleNamespace(path="/loras")) # pyright: ignore[reportArgumentType]
|
||||
|
||||
assert first.text == "1.0.2-old"
|
||||
assert second.text == "1.0.2-new"
|
||||
|
||||
@@ -1027,6 +1027,8 @@ def _modelscope_card_payload() -> dict:
|
||||
"modelVersion": {
|
||||
"showName": "c1-st1000",
|
||||
"triggerWords": '["kreaface","kreamodel"]',
|
||||
"id": 1002,
|
||||
"modelId": 555,
|
||||
},
|
||||
"coverImages": [
|
||||
{"url": "https://resources.modelscope.cn/cover-images/b.png"},
|
||||
@@ -1141,6 +1143,9 @@ async def test_download_hydrates_the_card_from_the_site(tmp_path, monkeypatch):
|
||||
'{"strength_min": 0.5, "strength_max": 1.2, "strength_range": "0.5-1.2"}'
|
||||
)
|
||||
assert saved["metadata_source"] == "source:modelscope"
|
||||
# The site-native identity ids are persisted for version grouping.
|
||||
assert saved["source_model_id"] == "555"
|
||||
assert saved["source_version_id"] == "1002"
|
||||
# No provider answered, so claiming an AI enrichment would be a lie.
|
||||
assert "llm_enriched_at" not in saved
|
||||
|
||||
@@ -1148,3 +1153,55 @@ async def test_download_hydrates_the_card_from_the_site(tmp_path, monkeypatch):
|
||||
assert scanner.update_single_model_cache.await_count == 1
|
||||
cached = scanner.update_single_model_cache.await_args.args[2]
|
||||
assert cached["model_name"] == "Krea-2-LORA"
|
||||
assert cached["source_model_id"] == "555"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_model_source_sends_hf_token_as_custom_headers(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""A gated/private HF repo needs the configured token on the download."""
|
||||
captured = _stub_download_backend(monkeypatch)
|
||||
monkeypatch.setattr(model_source_handlers, "_save_source_metadata", AsyncMock())
|
||||
monkeypatch.setattr(
|
||||
"py.services.model_sources.huggingface._hf_token", lambda: "hf_secret"
|
||||
)
|
||||
|
||||
response = await ModelSourceHandler().download_model_source(
|
||||
FakeRequest(
|
||||
json_data={
|
||||
"platform": "huggingface",
|
||||
"repo": "user/repo",
|
||||
"filename": "f.safetensors",
|
||||
"model_root": str(tmp_path),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert response.status == 200
|
||||
assert captured["custom_headers"] == {"Authorization": "Bearer hf_secret"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_model_source_sends_no_headers_without_hf_token(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
captured = _stub_download_backend(monkeypatch)
|
||||
monkeypatch.setattr(model_source_handlers, "_save_source_metadata", AsyncMock())
|
||||
monkeypatch.setattr(
|
||||
"py.services.model_sources.huggingface._hf_token", lambda: ""
|
||||
)
|
||||
|
||||
response = await ModelSourceHandler().download_model_source(
|
||||
FakeRequest(
|
||||
json_data={
|
||||
"platform": "huggingface",
|
||||
"repo": "user/repo",
|
||||
"filename": "f.safetensors",
|
||||
"model_root": str(tmp_path),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert response.status == 200
|
||||
assert captured["custom_headers"] is None
|
||||
|
||||
@@ -1281,3 +1281,64 @@ async def test_download_file_does_not_refresh_url_for_other_errors(
|
||||
assert "Download aborted" in result
|
||||
assert add_uri_count["n"] == 1
|
||||
assert downloader._transfers == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_file_preresolves_huggingface_redirect_and_strips_token(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""aria2 forwards custom headers to redirect targets, so the HF Bearer
|
||||
token must never leave huggingface.co: the /resolve/ redirect is resolved
|
||||
first and the signed CDN URL is handed to aria2 without headers."""
|
||||
downloader = Aria2Downloader()
|
||||
downloader._rpc_url = "http://127.0.0.1/jsonrpc"
|
||||
downloader._rpc_secret = "secret"
|
||||
|
||||
save_path = tmp_path / "downloads" / "model.safetensors"
|
||||
rpc_calls = []
|
||||
statuses = iter(
|
||||
[
|
||||
{
|
||||
"gid": "gid-1",
|
||||
"status": "complete",
|
||||
"completedLength": "10",
|
||||
"totalLength": "10",
|
||||
"downloadSpeed": "0",
|
||||
"files": [{"path": str(save_path)}],
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
async def fake_rpc_call(method, params, **_kwargs):
|
||||
rpc_calls.append((method, params))
|
||||
if method == "aria2.addUri":
|
||||
return "gid-1"
|
||||
if method == "aria2.tellStatus":
|
||||
return next(statuses)
|
||||
raise AssertionError(f"Unexpected RPC method: {method}")
|
||||
|
||||
monkeypatch.setattr(downloader, "_ensure_process", AsyncMock())
|
||||
monkeypatch.setattr(
|
||||
downloader,
|
||||
"_resolve_authenticated_redirect_url",
|
||||
AsyncMock(
|
||||
return_value="https://cdn-lfs.huggingface.co/signed/model.safetensors?sig=abc"
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(downloader, "_rpc_call", fake_rpc_call)
|
||||
monkeypatch.setattr("py.services.aria2_downloader.asyncio.sleep", AsyncMock())
|
||||
|
||||
success, result = await downloader.download_file(
|
||||
"https://huggingface.co/user/repo/resolve/main/model.safetensors",
|
||||
str(save_path),
|
||||
download_id="download-1",
|
||||
headers={"Authorization": "Bearer hf_secret"},
|
||||
)
|
||||
|
||||
assert success is True
|
||||
assert result == str(save_path)
|
||||
assert rpc_calls[0][0] == "aria2.addUri"
|
||||
assert rpc_calls[0][1][0] == [
|
||||
"https://cdn-lfs.huggingface.co/signed/model.safetensors?sig=abc"
|
||||
]
|
||||
assert "header" not in rpc_calls[0][1][1]
|
||||
|
||||
@@ -1263,39 +1263,8 @@ async def test_get_model_civitai_url_falls_back_when_host_setting_is_not_a_strin
|
||||
}
|
||||
|
||||
|
||||
class TestHfGroupKey:
|
||||
"""Tests for _extract_hf_group_key and _extract_group_key."""
|
||||
|
||||
# --- _extract_hf_group_key ---
|
||||
|
||||
def test_hf_group_key_valid_url(self):
|
||||
"""Standard HF URL returns hf:user/repo."""
|
||||
item = {"hf_url": "https://huggingface.co/unsloth/qwen-edit"}
|
||||
assert BaseModelService._extract_hf_group_key(item) == "hf:unsloth/qwen-edit"
|
||||
|
||||
def test_hf_group_key_url_with_subpath(self):
|
||||
"""URL with subpath still extracts just owner/repo."""
|
||||
item = {"hf_url": "https://huggingface.co/user/repo/resolve/main/file.safetensors"}
|
||||
assert BaseModelService._extract_hf_group_key(item) == "hf:user/repo"
|
||||
|
||||
def test_hf_group_key_empty_url(self):
|
||||
"""Empty hf_url returns None."""
|
||||
assert BaseModelService._extract_hf_group_key({"hf_url": ""}) is None
|
||||
|
||||
def test_hf_group_key_no_url(self):
|
||||
"""Missing hf_url key returns None."""
|
||||
assert BaseModelService._extract_hf_group_key({}) is None
|
||||
|
||||
def test_hf_group_key_none_url(self):
|
||||
"""None hf_url returns None."""
|
||||
assert BaseModelService._extract_hf_group_key({"hf_url": None}) is None
|
||||
|
||||
def test_hf_group_key_invalid_url(self):
|
||||
"""Malformed HF URL returns None."""
|
||||
assert BaseModelService._extract_hf_group_key({"hf_url": "not-a-url"}) is None
|
||||
assert BaseModelService._extract_hf_group_key({"hf_url": "https://example.com"}) is None
|
||||
|
||||
# --- _extract_group_key ---
|
||||
class TestSourceGroupKey:
|
||||
"""Tests for _extract_group_key (CivitAI id, then site-native source identity)."""
|
||||
|
||||
def test_group_key_civitai_only(self):
|
||||
"""CivitAI modelId returned as int."""
|
||||
@@ -1303,30 +1272,64 @@ class TestHfGroupKey:
|
||||
assert BaseModelService._extract_group_key(item) == 123
|
||||
|
||||
def test_group_key_hf_only(self):
|
||||
"""HF-only item returns hf:user/repo string."""
|
||||
"""HF-linked items never group: a repository is not a model identity."""
|
||||
item = {"hf_url": "https://huggingface.co/user/repo"}
|
||||
assert BaseModelService._extract_group_key(item) == "hf:user/repo"
|
||||
assert BaseModelService._extract_group_key(item) is None
|
||||
|
||||
def test_group_key_civitai_preferred(self):
|
||||
"""CivitAI modelId takes precedence over hf_url."""
|
||||
"""CivitAI modelId takes precedence over any source identity."""
|
||||
item = {
|
||||
"civitai": {"modelId": 456},
|
||||
"hf_url": "https://huggingface.co/other/repo",
|
||||
"source_url": "https://tensor.art/models/789",
|
||||
}
|
||||
assert BaseModelService._extract_group_key(item) == 456
|
||||
|
||||
def test_group_key_neither(self):
|
||||
"""No CivitAI or HF returns None."""
|
||||
"""No CivitAI or groupable source returns None."""
|
||||
assert BaseModelService._extract_group_key({}) is None
|
||||
assert BaseModelService._extract_group_key({"some": "data"}) is None
|
||||
|
||||
def test_group_key_civitai_none_model_id(self):
|
||||
"""civitai.modelId=None falls through to HF."""
|
||||
"""civitai.modelId=None falls through to the source identity."""
|
||||
item = {
|
||||
"civitai": {"modelId": None},
|
||||
"hf_url": "https://huggingface.co/user/repo",
|
||||
"source_url": "https://tensor.art/models/789",
|
||||
}
|
||||
assert BaseModelService._extract_group_key(item) == "hf:user/repo"
|
||||
assert BaseModelService._extract_group_key(item) == "ta:789"
|
||||
|
||||
def test_group_key_modelscope_uses_published_model_id(self):
|
||||
"""ModelScope groups under ms:<modelId> once enrichment recorded it."""
|
||||
item = {
|
||||
"source_platform": "modelscope",
|
||||
"source_url": "https://modelscope.cn/models/u/r",
|
||||
"source_model_id": "555",
|
||||
}
|
||||
assert BaseModelService._extract_group_key(item) == "ms:555"
|
||||
|
||||
def test_group_key_modelscope_unenriched_stays_standalone(self):
|
||||
"""Without source_model_id there is no key — never repo-level grouping."""
|
||||
item = {
|
||||
"source_platform": "modelscope",
|
||||
"source_url": "https://modelscope.cn/models/u/r",
|
||||
}
|
||||
assert BaseModelService._extract_group_key(item) is None
|
||||
|
||||
def test_group_key_modelscope_identity_crosses_repos(self):
|
||||
"""Same published-model id groups across repos; same repo does not."""
|
||||
|
||||
def ms_item(repo, model_id):
|
||||
return {
|
||||
"source_platform": "modelscope",
|
||||
"source_url": f"https://modelscope.cn/models/{repo}",
|
||||
"source_model_id": model_id,
|
||||
}
|
||||
|
||||
assert BaseModelService._extract_group_key(
|
||||
ms_item("alice/collection", "555")
|
||||
) == BaseModelService._extract_group_key(ms_item("bob/mirror", "555"))
|
||||
assert BaseModelService._extract_group_key(
|
||||
ms_item("alice/collection", "555")
|
||||
) != BaseModelService._extract_group_key(ms_item("alice/collection", "777"))
|
||||
|
||||
|
||||
class TestApplyHashFilters:
|
||||
|
||||
@@ -238,6 +238,51 @@ async def test_successful_download_uses_defaults(
|
||||
assert captured["download_urls"] == ["https://example.invalid/file.safetensors"]
|
||||
|
||||
|
||||
def test_calculate_relative_path_ignores_keyword_dump_tag():
|
||||
"""The #1119 download flow: the real tag list must not become a folder.
|
||||
|
||||
The model's only two tags are the keyword dump and Civitai's "base model"
|
||||
label, so nothing usable is left and the template falls back to "no tags".
|
||||
"""
|
||||
keyword_dump = (
|
||||
"lora, character, rosie, irish, redhead, auburn, freckles, green eyes, "
|
||||
"curly hair, woman, female, photorealistic, realistic, krea2, dark beast, "
|
||||
"kreativity, nsfw, nude, portrait, face"
|
||||
)
|
||||
manager = DownloadManager()
|
||||
|
||||
relative_path = manager._calculate_relative_path(
|
||||
{
|
||||
"baseModel": "BaseModel",
|
||||
"creator": {"username": "mad_macs"},
|
||||
"name": "v1.2",
|
||||
"model": {"name": "Rosie", "tags": [keyword_dump, "base model"]},
|
||||
},
|
||||
"lora",
|
||||
)
|
||||
|
||||
assert relative_path == "MappedModel/no tags"
|
||||
assert keyword_dump not in relative_path
|
||||
assert len(relative_path) < 50
|
||||
|
||||
|
||||
def test_calculate_relative_path_sanitizes_tag_segment():
|
||||
"""A tag with path separators must not create nested folders."""
|
||||
manager = DownloadManager()
|
||||
|
||||
relative_path = manager._calculate_relative_path(
|
||||
{
|
||||
"baseModel": "BaseModel",
|
||||
"creator": {"username": "author"},
|
||||
"name": "v1.2",
|
||||
"model": {"name": "Rosie", "tags": ["a/b:c"]},
|
||||
},
|
||||
"lora",
|
||||
)
|
||||
|
||||
assert relative_path == "MappedModel/a_b_c"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_accepts_enhancement_lora_primary_file(
|
||||
monkeypatch, scanners, metadata_provider, tmp_path
|
||||
|
||||
@@ -967,6 +967,8 @@ def _make_cache_entry(**overrides) -> Dict[str, Any]:
|
||||
"source_platform": "",
|
||||
"source_url": "",
|
||||
"hf_url": "",
|
||||
"source_model_id": "",
|
||||
"source_version_id": "",
|
||||
"license_flags": 113,
|
||||
"hash_status": "completed",
|
||||
}
|
||||
@@ -1005,6 +1007,8 @@ async def test_sync_cache_no_change(tmp_path: Path):
|
||||
"tags": ["alpha"],
|
||||
"civitai": {"id": 111, "modelId": 222, "name": "v1"},
|
||||
"hf_url": "",
|
||||
"source_model_id": "",
|
||||
"source_version_id": "",
|
||||
}
|
||||
|
||||
changed = await scanner.sync_cache_from_metadata(
|
||||
@@ -1049,6 +1053,8 @@ async def test_sync_cache_in_place_update(tmp_path: Path):
|
||||
"tags": ["beta", "gamma"],
|
||||
"civitai": {"id": 111, "modelId": 222, "name": "v1"},
|
||||
"hf_url": "",
|
||||
"source_model_id": "",
|
||||
"source_version_id": "",
|
||||
}
|
||||
|
||||
changed = await scanner.sync_cache_from_metadata(
|
||||
@@ -1094,6 +1100,8 @@ async def test_sync_cache_not_in_cache_delegates(tmp_path: Path):
|
||||
"tags": [],
|
||||
"civitai": {},
|
||||
"hf_url": "",
|
||||
"source_model_id": "",
|
||||
"source_version_id": "",
|
||||
}
|
||||
|
||||
changed = await scanner.sync_cache_from_metadata(
|
||||
@@ -1147,6 +1155,8 @@ async def test_sync_cache_conditional_resort_skipped(tmp_path: Path, monkeypatch
|
||||
"tags": ["alpha"],
|
||||
"civitai": {"id": 111, "modelId": 222, "name": "v1"},
|
||||
"hf_url": "",
|
||||
"source_model_id": "",
|
||||
"source_version_id": "",
|
||||
}
|
||||
|
||||
changed = await scanner.sync_cache_from_metadata(
|
||||
@@ -1197,6 +1207,8 @@ async def test_sync_cache_conditional_resort_triggered(tmp_path: Path, monkeypat
|
||||
"tags": ["alpha"],
|
||||
"civitai": {"id": 111, "modelId": 222, "name": "v1"},
|
||||
"hf_url": "",
|
||||
"source_model_id": "",
|
||||
"source_version_id": "",
|
||||
}
|
||||
|
||||
changed = await scanner.sync_cache_from_metadata(
|
||||
|
||||
@@ -279,15 +279,39 @@ class TestHelpers:
|
||||
assert get_source_platform({"source_platform": "tensorart"}) == "tensorart"
|
||||
assert get_source_platform({}) == ""
|
||||
|
||||
def test_group_keys_match_legacy_hf_shape(self):
|
||||
assert source_group_key({"hf_url": "https://huggingface.co/u/r"}) == "hf:u/r"
|
||||
assert (
|
||||
source_group_key({"source_url": "https://modelscope.cn/models/u/r"}) == "ms:u/r"
|
||||
)
|
||||
def test_group_keys_use_site_native_identity(self):
|
||||
# Hugging Face has no site-native model identity: never grouped.
|
||||
assert source_group_key({"hf_url": "https://huggingface.co/u/r"}) is None
|
||||
# TensorArt's numeric id already identifies a single model.
|
||||
assert (
|
||||
source_group_key({"source_url": "https://tensor.art/models/123"}) == "ta:123"
|
||||
)
|
||||
|
||||
def test_modelscope_groups_by_published_model_id(self):
|
||||
# Without an enriched source_model_id the model stays standalone —
|
||||
# never grouped by repo, which would collapse a collection repo.
|
||||
assert (
|
||||
source_group_key({"source_url": "https://modelscope.cn/models/u/r"}) is None
|
||||
)
|
||||
assert (
|
||||
source_group_key(
|
||||
{
|
||||
"source_url": "https://modelscope.cn/models/u/r",
|
||||
"source_model_id": "555",
|
||||
}
|
||||
)
|
||||
== "ms:555"
|
||||
)
|
||||
assert (
|
||||
source_group_key(
|
||||
{
|
||||
"source_url": "https://www.modelscope.ai/models/u/r",
|
||||
"source_model_id": "678",
|
||||
}
|
||||
)
|
||||
== "msai:678"
|
||||
)
|
||||
|
||||
def test_group_key_is_none_without_source(self):
|
||||
assert source_group_key({}) is None
|
||||
assert source_group_key({"hf_url": "https://example.com/x"}) is None
|
||||
@@ -457,7 +481,12 @@ def _modelscope_detail_payload() -> dict:
|
||||
"versions": [
|
||||
{
|
||||
"stats": {"fileList": ["Krea-2-LORA_c1-st8000.safetensors"]},
|
||||
"modelVersion": {"showName": "c1-st8000", "triggerWords": '[""]'},
|
||||
"modelVersion": {
|
||||
"showName": "c1-st8000",
|
||||
"triggerWords": '[""]',
|
||||
"id": 1001,
|
||||
"modelId": 555,
|
||||
},
|
||||
"coverImages": [
|
||||
{"url": "https://resources.modelscope.cn/cover-images/a.png"}
|
||||
],
|
||||
@@ -467,6 +496,8 @@ def _modelscope_detail_payload() -> dict:
|
||||
"modelVersion": {
|
||||
"showName": "c1-st1000",
|
||||
"triggerWords": '["kreaface","kreamodel"]',
|
||||
"id": 1002,
|
||||
"modelId": 555,
|
||||
},
|
||||
"coverImages": [
|
||||
{"url": "https://resources.modelscope.cn/cover-images/b.png"},
|
||||
@@ -533,6 +564,9 @@ class TestFetchModelCardContext:
|
||||
# The version label is taken from the file that was matched, not from
|
||||
# whichever version happens to come first in the payload.
|
||||
assert context.version_name == "c1-st1000"
|
||||
# The site-native identity ids belong to the matched version too.
|
||||
assert context.source_model_id == "555"
|
||||
assert context.source_version_id == "1002"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modelscope_version_label_is_empty_for_an_unknown_file(
|
||||
@@ -550,6 +584,9 @@ class TestFetchModelCardContext:
|
||||
)
|
||||
|
||||
assert context.version_name == ""
|
||||
# No version matched, so there is no per-version identity either.
|
||||
assert context.source_model_id == ""
|
||||
assert context.source_version_id == ""
|
||||
# The repository-wide fields are still published.
|
||||
assert context.model_name == "Krea-2-LORA"
|
||||
|
||||
@@ -1139,3 +1176,100 @@ class TestHashBasedVersionMatching:
|
||||
)
|
||||
|
||||
assert mock_ctx.call_args.kwargs["sha256"] == "c" * 64
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hugging Face authentication (gated / private repositories)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHuggingFaceAuth:
|
||||
def test_auth_headers_empty_without_token(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"py.services.model_sources.huggingface._hf_token", lambda: ""
|
||||
)
|
||||
|
||||
assert HuggingFaceSource().auth_headers() == {}
|
||||
|
||||
def test_auth_headers_bearer_with_token(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"py.services.model_sources.huggingface._hf_token", lambda: "hf_secret"
|
||||
)
|
||||
|
||||
assert HuggingFaceSource().auth_headers() == {
|
||||
"Authorization": "Bearer hf_secret"
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_files_sends_token_to_tree_api(self, monkeypatch):
|
||||
captured: dict = {}
|
||||
|
||||
async def fake_fetch_json(url, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return 200, []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"py.services.model_sources.huggingface.fetch_json", fake_fetch_json
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"py.services.model_sources.huggingface._hf_token", lambda: "hf_secret"
|
||||
)
|
||||
|
||||
await HuggingFaceSource().list_files("u/r")
|
||||
|
||||
assert captured["headers"] == {"Authorization": "Bearer hf_secret"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_card_sends_token(self, monkeypatch):
|
||||
captured: dict = {}
|
||||
|
||||
async def fake_fetch_text(url, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return "# card"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"py.services.model_sources.huggingface.fetch_text", fake_fetch_text
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"py.services.model_sources.huggingface._hf_token", lambda: "hf_secret"
|
||||
)
|
||||
|
||||
await HuggingFaceSource().fetch_model_card("u/r")
|
||||
|
||||
assert captured["headers"] == {"Authorization": "Bearer hf_secret"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthorised_without_token_explains_how_to_fix(self, monkeypatch):
|
||||
async def fake_fetch_json(url, **_kwargs):
|
||||
return 401, None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"py.services.model_sources.huggingface.fetch_json", fake_fetch_json
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"py.services.model_sources.huggingface._hf_token", lambda: ""
|
||||
)
|
||||
|
||||
with pytest.raises(ModelSourceError) as excinfo:
|
||||
await HuggingFaceSource().list_files("u/r")
|
||||
|
||||
assert excinfo.value.status == 401
|
||||
assert "access token" in str(excinfo.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_denied_with_token_points_at_repo_terms(self, monkeypatch):
|
||||
async def fake_fetch_json(url, **_kwargs):
|
||||
return 403, None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"py.services.model_sources.huggingface.fetch_json", fake_fetch_json
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"py.services.model_sources.huggingface._hf_token", lambda: "hf_secret"
|
||||
)
|
||||
|
||||
with pytest.raises(ModelSourceError) as excinfo:
|
||||
await HuggingFaceSource().list_files("u/r")
|
||||
|
||||
assert excinfo.value.status == 403
|
||||
assert "accept its terms" in str(excinfo.value)
|
||||
|
||||
@@ -817,6 +817,75 @@ class TestSiteProvidedContext:
|
||||
"https://huggingface.co/user/repo/resolve/main/images/cat.png"
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_site_identity_ids_are_persisted(self, processor):
|
||||
"""source_model_id/source_version_id reach the sidecar for grouping."""
|
||||
context = ModelCardContext(source_model_id="555", source_version_id="1002")
|
||||
|
||||
with (
|
||||
mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply,
|
||||
mock.patch("py.metadata_ops.download_preview", return_value=None),
|
||||
mock.patch("py.metadata_ops.refresh_cache"),
|
||||
):
|
||||
await processor.process(
|
||||
skill_name="enrich_hf_metadata",
|
||||
model_path="/p.safetensors",
|
||||
llm_output=self.LLM_OUTPUT,
|
||||
metadata=dict(self.MODELSCOPE_METADATA),
|
||||
readme_content="",
|
||||
source_context=context,
|
||||
)
|
||||
|
||||
applied = mock_apply.call_args[0][1]
|
||||
assert applied["source_model_id"] == "555"
|
||||
assert applied["source_version_id"] == "1002"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_site_identity_ids_absent_without_context_values(self, processor):
|
||||
"""No identity keys are written when the site did not publish any."""
|
||||
with (
|
||||
mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply,
|
||||
mock.patch("py.metadata_ops.download_preview", return_value=None),
|
||||
mock.patch("py.metadata_ops.refresh_cache"),
|
||||
):
|
||||
await processor.process(
|
||||
skill_name="enrich_hf_metadata",
|
||||
model_path="/p.safetensors",
|
||||
llm_output=self.LLM_OUTPUT,
|
||||
metadata=dict(self.MODELSCOPE_METADATA),
|
||||
readme_content="",
|
||||
source_context=ModelCardContext(description="summary only"),
|
||||
)
|
||||
|
||||
applied = mock_apply.call_args[0][1]
|
||||
assert "source_model_id" not in applied
|
||||
assert "source_version_id" not in applied
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_site_identity_ids_skipped_for_a_model_with_no_external_source(
|
||||
self, processor
|
||||
):
|
||||
"""A CivitAI-only model must not pick up source identity ids."""
|
||||
context = ModelCardContext(source_model_id="555", source_version_id="1002")
|
||||
|
||||
with (
|
||||
mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply,
|
||||
mock.patch("py.metadata_ops.download_preview", return_value=None),
|
||||
mock.patch("py.metadata_ops.refresh_cache"),
|
||||
):
|
||||
await processor.process(
|
||||
skill_name="enrich_hf_metadata",
|
||||
model_path="/p.safetensors",
|
||||
llm_output=self.LLM_OUTPUT,
|
||||
metadata={"from_civitai": True},
|
||||
readme_content="",
|
||||
source_context=context,
|
||||
)
|
||||
|
||||
applied = mock_apply.call_args[0][1]
|
||||
assert "source_model_id" not in applied
|
||||
assert "source_version_id" not in applied
|
||||
|
||||
|
||||
|
||||
# ======================================================================
|
||||
|
||||
@@ -365,6 +365,87 @@ def test_download_path_template_unknown_type_is_flat(manager):
|
||||
assert manager.get_download_path_template("not-a-model-type") == ""
|
||||
|
||||
|
||||
# Real CivitAI data for the model reported in issue #1119: the uploader dumped
|
||||
# a whole keyword list into a single tag.
|
||||
KEYWORD_DUMP_TAG = (
|
||||
"lora, character, rosie, irish, redhead, auburn, freckles, green eyes, "
|
||||
"curly hair, woman, female, photorealistic, realistic, krea2, dark beast, "
|
||||
"kreativity, nsfw, nude, portrait, face"
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_priority_tag_prefers_configured_priority(manager):
|
||||
# Priority order from CIVITAI_MODEL_TAGS: "character" precedes "anime".
|
||||
assert manager.resolve_priority_tag_for_model(["anime", "character"], "lora") == (
|
||||
"character"
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_priority_tag_falls_back_to_first_usable_tag(manager):
|
||||
assert (
|
||||
manager.resolve_priority_tag_for_model(["portrait", "anime-ish"], "lora")
|
||||
== "portrait"
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_priority_tag_skips_keyword_dump_tag(manager):
|
||||
"""A keyword-dump tag must not be used as a folder name (#1119)."""
|
||||
assert manager.resolve_priority_tag_for_model([KEYWORD_DUMP_TAG], "lora") == ""
|
||||
|
||||
|
||||
def test_resolve_priority_tag_skips_keyword_dump_and_uses_next_tag(manager):
|
||||
assert (
|
||||
manager.resolve_priority_tag_for_model([KEYWORD_DUMP_TAG, "portrait"], "lora")
|
||||
== "portrait"
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_priority_tag_skips_unusable_tags(manager):
|
||||
overlong_tag = "x" * 51
|
||||
|
||||
assert manager.resolve_priority_tag_for_model([overlong_tag], "lora") == ""
|
||||
assert manager.resolve_priority_tag_for_model([overlong_tag, " "], "lora") == ""
|
||||
# Non-string entries never win the fallback.
|
||||
assert manager.resolve_priority_tag_for_model([None, 42], "lora") == ""
|
||||
# A tag at the length budget is still accepted and stripped.
|
||||
assert manager.resolve_priority_tag_for_model(["x" * 50], "lora") == "x" * 50
|
||||
assert manager.resolve_priority_tag_for_model([" portrait "], "lora") == "portrait"
|
||||
|
||||
|
||||
def test_resolve_priority_tag_skips_civitai_meta_tags(manager):
|
||||
"""Civitai's structural labels are not content, so they cannot be folders."""
|
||||
assert manager.resolve_priority_tag_for_model(["base model"], "lora") == ""
|
||||
assert (
|
||||
manager.resolve_priority_tag_for_model(["Base Model"], "lora") == ""
|
||||
), "the meta tag check must be case-insensitive"
|
||||
assert manager.resolve_priority_tag_for_model(["base model", " "], "lora") == ""
|
||||
# A real tag after the label is still used.
|
||||
assert (
|
||||
manager.resolve_priority_tag_for_model(["base model", "portrait"], "lora")
|
||||
== "portrait"
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_priority_tag_meta_tag_can_be_opted_into(manager):
|
||||
"""An explicit priority entry still wins over the meta tag exclusion."""
|
||||
manager.settings["priority_tags"] = {"lora": "base model"}
|
||||
|
||||
assert (
|
||||
manager.resolve_priority_tag_for_model(["base model", "portrait"], "lora")
|
||||
== "base model"
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_priority_tag_real_1119_tag_list(manager):
|
||||
"""End to end for the reported model: both of its tags are unusable."""
|
||||
assert (
|
||||
manager.resolve_priority_tag_for_model(
|
||||
[KEYWORD_DUMP_TAG, "base model"], "lora"
|
||||
)
|
||||
== ""
|
||||
)
|
||||
|
||||
|
||||
def test_auto_set_default_roots(manager):
|
||||
# Clear any previously auto-set values to test fresh behavior
|
||||
manager.settings["default_lora_root"] = ""
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from py.utils.generation_metadata import (
|
||||
GraphReader, MetadataError, extract_generation_metadata, parse_parameters, split_lora_tags,
|
||||
)
|
||||
|
||||
|
||||
def graph():
|
||||
return {
|
||||
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "base.safetensors"}},
|
||||
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": "ugly monster, (detail:1.2)", "clip": ["1", 1]}},
|
||||
"3": {"class_type": "CLIPTextEncode", "inputs": {"text": "sunshine", "clip": ["1", 1]}},
|
||||
"4": {"class_type": "EmptyLatentImage", "inputs": {"width": 768, "height": 1024}},
|
||||
"5": {"class_type": "KSampler", "inputs": {"model": ["1", 0], "positive": ["2", 0], "negative": ["3", 0], "latent_image": ["4", 0], "seed": 18446744073709551615, "steps": 25, "cfg": 6.5, "sampler_name": "euler", "scheduler": "normal", "denoise": 1}},
|
||||
}
|
||||
|
||||
|
||||
def test_traces_polarity_without_content_heuristics():
|
||||
result = GraphReader(graph()).read("")
|
||||
assert result.values["positive"] == "ugly monster, (detail:1.2)"
|
||||
assert result.values["negative"] == "sunshine"
|
||||
assert result.values["seed"] == 2**64 - 1
|
||||
assert result.values["width"] == 768
|
||||
assert not result.issues
|
||||
|
||||
|
||||
def test_multiple_samplers_require_selection_and_do_not_mix():
|
||||
data = graph()
|
||||
data["6"] = {"class_type": "KSampler", "inputs": {**data["5"]["inputs"], "seed": 42}}
|
||||
with pytest.raises(MetadataError, match="5, 6"):
|
||||
GraphReader(data).read("")
|
||||
assert GraphReader(data).read("6").values["seed"] == 42
|
||||
|
||||
|
||||
def test_model_lora_order_repeated_entries_and_clip_strength():
|
||||
data = graph()
|
||||
data["6"] = {"class_type": "LoraLoader", "inputs": {"model": ["1", 0], "lora_name": "same.safetensors", "strength_model": .7, "strength_clip": .3}}
|
||||
data["7"] = {"class_type": "Lora Loader (LoraManager)", "inputs": {"model": ["6", 0], "loras": {"__value__": [{"name": "same", "active": True, "strength": .4, "clipStrength": 0}, {"name": "disabled", "active": False}]}}}
|
||||
data["5"]["inputs"]["model"] = ["7", 0]
|
||||
result = GraphReader(data).read("")
|
||||
assert result.loras == [("same.safetensors", .7, .3), ("same", .4, 0)]
|
||||
|
||||
|
||||
def test_linked_primitive_and_cycle_detection():
|
||||
data = graph()
|
||||
data["6"] = {"class_type": "PrimitiveInt", "inputs": {"value": 123}}
|
||||
data["5"]["inputs"]["seed"] = ["6", 0]
|
||||
assert GraphReader(data).read("").values["seed"] == 123
|
||||
data["6"]["inputs"]["value"] = ["6", 0]
|
||||
assert "Cyclic" in GraphReader(data).read("").issues["seed"]
|
||||
|
||||
|
||||
def test_unsupported_conditioning_is_not_silently_flattened():
|
||||
data = graph()
|
||||
data["2"]["class_type"] = "ConditioningCombine"
|
||||
assert "Unsupported conditioning" in GraphReader(data).read("").issues["positive"]
|
||||
|
||||
|
||||
def test_parameters_sampler_mapping_and_clean_prompts():
|
||||
result = parse_parameters('portrait (detail:1.2) <lora:style:0.7:0.2>\nsecond line\nNegative prompt: blur\nmore blur\nSteps: 25, Sampler: DPM++ 2M Karras, CFG scale: 7, Seed: 123, Size: 512x768, Model: base')
|
||||
assert result.values["sampler_name"] == "dpmpp_2m"
|
||||
assert result.values["scheduler"] == "karras"
|
||||
assert result.values["negative"] == "blur\nmore blur"
|
||||
clean, loras = split_lora_tags(result.values["positive"])
|
||||
assert clean == "portrait (detail:1.2) \nsecond line"
|
||||
assert loras == []
|
||||
assert result.loras == [("style", .7, .2)]
|
||||
|
||||
|
||||
def test_unspecified_a1111_scheduler_requires_decision():
|
||||
result = parse_parameters("cat\nSteps: 20, Sampler: Euler a, Seed: 1, CFG scale: 7")
|
||||
assert result.values["sampler_name"] == "euler_ancestral"
|
||||
assert "scheduler" in result.issues
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["<lora:foo:nan>", "<lora:foo:1e999>", "<lora:foo:bad>"])
|
||||
def test_bad_lora_strength(value):
|
||||
with pytest.raises(ValueError):
|
||||
split_lora_tags(value)
|
||||
|
||||
|
||||
def test_malformed_and_missing_metadata():
|
||||
with pytest.raises(MetadataError, match="Malformed"):
|
||||
extract_generation_metadata({"prompt": "{"})
|
||||
with pytest.raises(MetadataError, match="no supported"):
|
||||
extract_generation_metadata({})
|
||||
assert extract_generation_metadata({"comment": json.dumps(graph())}).values["steps"] == 25
|
||||
|
||||
|
||||
def test_core_ui_workflow_fallback():
|
||||
workflow = {"nodes": [
|
||||
{"id": 1, "type": "CheckpointLoaderSimple", "widgets_values": ["base.safetensors"]},
|
||||
{"id": 2, "type": "CLIPTextEncode", "widgets_values": ["positive"]},
|
||||
{"id": 3, "type": "CLIPTextEncode", "widgets_values": ["negative"]},
|
||||
{"id": 4, "type": "KSampler", "widgets_values": [42, "fixed", 20, 7, "euler", "normal", 1], "inputs": [
|
||||
{"name": "model", "link": 1}, {"name": "positive", "link": 2}, {"name": "negative", "link": 3}]},
|
||||
], "links": [[1, 1, 0, 4, 0, "MODEL"], [2, 2, 0, 4, 1, "CONDITIONING"], [3, 3, 0, 4, 2, "CONDITIONING"]]}
|
||||
result = extract_generation_metadata({"workflow": json.dumps(workflow)})
|
||||
assert result.values["positive"] == "positive"
|
||||
assert result.values["seed"] == 42
|
||||
assert "UI workflow fallback" in result.notes[0]
|
||||
|
||||
|
||||
def test_stack_combiner_uses_numeric_order():
|
||||
data = {str(i): {"class_type": "Lora Stacker (LoraManager)", "inputs": {"loras": [{"name": str(i), "strength": 1, "active": True}]}} for i in (1, 2, 10)}
|
||||
data["20"] = {"class_type": "Lora Stack Combiner (LoraManager)", "inputs": {"lora_stack10": ["10", 0], "lora_stack2": ["2", 0], "lora_stack1": ["1", 0]}}
|
||||
assert [entry[0] for entry in GraphReader(data).stack(["20", 0])] == ["1", "2", "10"]
|
||||
|
||||
|
||||
def test_model_and_clip_lora_mismatch_requires_override():
|
||||
data = graph()
|
||||
data["6"] = {"class_type": "LoraLoader", "inputs": {"model": ["1", 0], "clip": ["1", 1], "lora_name": "style", "strength_model": .7, "strength_clip": .3}}
|
||||
data["5"]["inputs"]["model"] = ["6", 0]
|
||||
assert "different LoRAs" in GraphReader(data).read("").issues["loras"]
|
||||
data["2"]["inputs"]["clip"] = ["6", 1]
|
||||
data["3"]["inputs"]["clip"] = ["6", 1]
|
||||
assert not GraphReader(data).read("").issues
|
||||
|
||||
|
||||
def test_malformed_sampler_inputs():
|
||||
data = graph()
|
||||
data["5"]["inputs"] = None
|
||||
with pytest.raises(MetadataError, match="Malformed sampler"):
|
||||
GraphReader(data).read("")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("label,sampler,scheduler", [
|
||||
("Euler a SGM Uniform", "euler_ancestral", "sgm_uniform"),
|
||||
("Euler simple", "euler", "simple"),
|
||||
("Euler Normal", "euler", "normal"),
|
||||
("er_sde simple", "er_sde", "simple"),
|
||||
])
|
||||
def test_combined_sampler_scheduler_labels(label, sampler, scheduler):
|
||||
result = parse_parameters(f"cat\nSteps: 30, Sampler: {label}, Seed: 42, CFG scale: 5")
|
||||
assert result.values["sampler_name"] == sampler
|
||||
assert result.values["scheduler"] == scheduler
|
||||
assert not result.issues
|
||||
|
||||
|
||||
def test_multiline_settings_and_single_resource_weight():
|
||||
result = parse_parameters('cat\nNegative prompt: blur\nSteps: 30, Sampler: Euler Normal, Seed: 42, CFG scale: 5, Clip skip: 0, extra text,\nmore text\n, Model: example, Hashes: {"model":"123", "LORA:style, special":"456"}, Civitai resources: [{"air":"urn:model"}, {"air":"urn:lora", "weight":0.74}]')
|
||||
assert result.values["checkpoint_name"] == "example"
|
||||
assert result.values["negative"] == "blur"
|
||||
assert result.loras == [("style, special", .74, .74)]
|
||||
assert result.resource_hints[0]["hash"] == "456"
|
||||
|
||||
|
||||
def test_multiple_resource_weights_are_not_paired_by_order():
|
||||
result = parse_parameters('cat\nSteps: 20, Sampler: Euler Normal, Hashes: {"LORA:first":"aaa","LORA:second":"bbb"}, Civitai resources: [{"weight":0.5},{"weight":0.8}]')
|
||||
assert result.loras == []
|
||||
assert "loras" in result.issues
|
||||
assert [item["name"] for item in result.resource_hints] == ["first", "second"]
|
||||
|
||||
|
||||
|
||||
def test_duplicate_tags_with_single_authoritative_resource():
|
||||
result = parse_parameters('cat <lora:style:0.45> <lora:style:0.45>\nSteps: 10, Sampler: Euler simple, Hashes: {"LORA:style":"abc"}, Civitai resources: [{"weight":0.45}]')
|
||||
assert result.loras == [("style", .45, .45)]
|
||||
assert "<lora:" not in result.values["positive"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("selector", ["outer:inner:5", "outer/inner/5", "outer:inner", "5", ""])
|
||||
def test_qualified_subgraph_sampler_selection(selector):
|
||||
original = graph()
|
||||
expanded = {}
|
||||
for key, node in original.items():
|
||||
inputs = {name: ["outer:inner:" + value[0], value[1]] if isinstance(value, list) else value for name, value in node["inputs"].items()}
|
||||
expanded["outer:inner:" + key] = {**node, "inputs": inputs}
|
||||
result = GraphReader(expanded).read(selector)
|
||||
assert result.values["seed"] == 2**64 - 1
|
||||
assert "outer:inner:5" in result.notes[0]
|
||||
assert not result.issues
|
||||
|
||||
|
||||
def test_subgraph_leaf_selection_rejects_ambiguity():
|
||||
reader = GraphReader({
|
||||
"10:5": {"class_type": "KSampler", "inputs": {}},
|
||||
"20:5": {"class_type": "KSampler", "inputs": {}},
|
||||
})
|
||||
with pytest.raises(MetadataError, match="10:5, 20:5"):
|
||||
reader.read("5")
|
||||
assert reader.select_sampler("20") == "20:5"
|
||||
|
||||
|
||||
def test_standard_custom_sampler_pipeline():
|
||||
data = graph()
|
||||
old = data["5"]["inputs"]
|
||||
data["noise"] = {"class_type": "RandomNoise", "inputs": {"noise_seed": 123}}
|
||||
data["guider"] = {"class_type": "CFGGuider", "inputs": {key: old[key] for key in ("model", "positive", "negative", "cfg")}}
|
||||
data["schedule"] = {"class_type": "BasicScheduler", "inputs": {"steps": 28, "scheduler": "karras", "denoise": .6}}
|
||||
data["sampler"] = {"class_type": "KSamplerSelect", "inputs": {"sampler_name": "euler"}}
|
||||
data["5"] = {"class_type": "SamplerCustomAdvanced", "inputs": {"noise": ["noise", 0], "guider": ["guider", 0], "sigmas": ["schedule", 0], "sampler": ["sampler", 0], "latent_image": old["latent_image"]}}
|
||||
result = GraphReader(data).read("5")
|
||||
assert not result.issues
|
||||
assert result.values["seed"] == 123
|
||||
assert result.values["steps"] == 28
|
||||
assert result.values["denoise"] == .6
|
||||
assert result.values["positive"] == "ugly monster, (detail:1.2)"
|
||||
|
||||
|
||||
def test_saved_metadata_is_preferred_and_workflow_can_be_selected():
|
||||
fields = {
|
||||
"prompt": json.dumps(graph()),
|
||||
"parameters": "saved prompt\nSteps: 12, Sampler: Euler Normal, CFG scale: 4, Seed: 42, Model: saved",
|
||||
}
|
||||
result = extract_generation_metadata(fields, "not-a-node")
|
||||
assert result.values["seed"] == "42"
|
||||
assert result.values["positive"] == "saved prompt"
|
||||
assert any("ignored" in note for note in result.notes)
|
||||
result = extract_generation_metadata(fields, "5", prefer_saved_image_metadata=False)
|
||||
assert result.values["seed"] == 2**64 - 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [2, 4])
|
||||
def test_muted_or_bypassed_api_sampler_is_not_selected(mode):
|
||||
data = graph()
|
||||
data["6"] = {"class_type": "KSampler", "mode": mode, "inputs": {**data["5"]["inputs"], "seed": 123}}
|
||||
reader = GraphReader(data)
|
||||
assert reader.read("").values["seed"] == 2**64 - 1
|
||||
with pytest.raises(MetadataError, match="muted, bypassed"):
|
||||
reader.read("6")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [2, 4])
|
||||
@pytest.mark.parametrize("inactive_parent", [False, True])
|
||||
def test_workflow_modes_exclude_nested_api_sampler(mode, inactive_parent):
|
||||
data = graph()
|
||||
sampler = data.pop("5")
|
||||
data["10:20:5"] = sampler
|
||||
data["30:5"] = {**sampler, "inputs": {**sampler["inputs"], "seed": 123}}
|
||||
workflow = {
|
||||
"nodes": [{"id": 10, "type": "outer", "mode": mode if inactive_parent else 0}, {"id": 30, "type": "active"}],
|
||||
"definitions": {"subgraphs": [
|
||||
{"id": "outer", "nodes": [{"id": 20, "type": "inner"}]},
|
||||
{"id": "inner", "nodes": [{"id": 5, "type": "KSampler", "mode": 0 if inactive_parent else mode}]},
|
||||
{"id": "active", "nodes": [{"id": 5, "type": "KSampler"}]},
|
||||
]},
|
||||
}
|
||||
fields = {"prompt": json.dumps(data), "workflow": json.dumps(workflow)}
|
||||
assert extract_generation_metadata(fields, prefer_saved_image_metadata=False).values["seed"] == 123
|
||||
with pytest.raises(MetadataError, match="muted, bypassed"):
|
||||
extract_generation_metadata(fields, "10:20:5", prefer_saved_image_metadata=False)
|
||||
|
||||
|
||||
def test_invalid_preferred_parameters_recover_workflow():
|
||||
result = extract_generation_metadata({"parameters": "invalid", "prompt": json.dumps(graph())})
|
||||
assert result.values["seed"] == 2**64 - 1
|
||||
assert any("ERROR: Saved image metadata" in note for note in result.notes)
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Tests for py/utils/url_utils.py relative_root_prefix."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from py.utils.url_utils import relative_root_prefix
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("request_path", "expected"),
|
||||
[
|
||||
("/", ""),
|
||||
("/loras", ""),
|
||||
("/checkpoints", ""),
|
||||
("/statistics", ""),
|
||||
("/loras/", ""),
|
||||
("/loras/recipes", "../"),
|
||||
("/loras/recipes/", "../"),
|
||||
],
|
||||
)
|
||||
def test_relative_root_prefix(request_path: str, expected: str) -> None:
|
||||
assert relative_root_prefix(request_path) == expected
|
||||
@@ -2,6 +2,11 @@ import pytest
|
||||
|
||||
from py.services.settings_manager import SettingsManager, get_settings_manager
|
||||
from py.services.service_registry import ServiceRegistry
|
||||
from py.utils.constants import (
|
||||
MAX_FILENAME_STEM_LENGTH,
|
||||
MAX_FOLDER_NAME_LENGTH,
|
||||
MAX_PATH_TAG_LENGTH,
|
||||
)
|
||||
from py.utils.utils import (
|
||||
calculate_filename_for_model,
|
||||
calculate_recipe_fingerprint,
|
||||
@@ -12,6 +17,15 @@ from py.utils.utils import (
|
||||
)
|
||||
|
||||
|
||||
# Real CivitAI data for the model reported in issue #1119: the uploader dumped
|
||||
# a whole keyword list into a single tag.
|
||||
KEYWORD_DUMP_TAG = (
|
||||
"lora, character, rosie, irish, redhead, auburn, freckles, green eyes, "
|
||||
"curly hair, woman, female, photorealistic, realistic, krea2, dark beast, "
|
||||
"kreativity, nsfw, nude, portrait, face"
|
||||
)
|
||||
|
||||
|
||||
class _FakeCache:
|
||||
def __init__(self, items):
|
||||
self.raw_data = list(items)
|
||||
@@ -147,6 +161,80 @@ def test_calculate_relative_path_sanitizes_double_slashes(isolated_settings):
|
||||
assert relative_path == "no tags/Author"
|
||||
|
||||
|
||||
def test_calculate_relative_path_ignores_keyword_dump_tag(isolated_settings):
|
||||
"""A tag holding a whole keyword list must not become a folder name (#1119)."""
|
||||
model_data = {"base_model": "Krea 2", "tags": [KEYWORD_DUMP_TAG]}
|
||||
|
||||
relative_path = calculate_relative_path_for_model(model_data, "lora")
|
||||
|
||||
assert relative_path == "Krea 2/no tags"
|
||||
|
||||
|
||||
def test_calculate_relative_path_uses_next_usable_tag(isolated_settings):
|
||||
"""Unusable tags are skipped instead of hijacking the folder name (#1119)."""
|
||||
model_data = {"base_model": "Krea 2", "tags": [KEYWORD_DUMP_TAG, "portrait"]}
|
||||
|
||||
assert calculate_relative_path_for_model(model_data, "lora") == "Krea 2/portrait"
|
||||
|
||||
|
||||
def test_calculate_relative_path_ignores_civitai_meta_tag(isolated_settings):
|
||||
"""Civitai's "base model" label is not content, so it is not a folder."""
|
||||
model_data = {"base_model": "Krea 2", "tags": ["base model"]}
|
||||
|
||||
assert calculate_relative_path_for_model(model_data, "lora") == "Krea 2/no tags"
|
||||
|
||||
|
||||
def test_calculate_relative_path_ignores_full_1119_tag_list(isolated_settings):
|
||||
"""The reported model carries only a keyword dump and the meta label."""
|
||||
model_data = {"base_model": "Krea 2", "tags": [KEYWORD_DUMP_TAG, "base model"]}
|
||||
|
||||
assert calculate_relative_path_for_model(model_data, "lora") == "Krea 2/no tags"
|
||||
|
||||
|
||||
def test_calculate_relative_path_sanitizes_tag_segment(isolated_settings):
|
||||
"""A tag with path separators must not create nested folders."""
|
||||
model_data = {"base_model": "SDXL", "tags": ["a/b:c"]}
|
||||
|
||||
assert calculate_relative_path_for_model(model_data, "lora") == "SDXL/a_b_c"
|
||||
|
||||
|
||||
def test_calculate_relative_path_caps_tag_segment(isolated_settings):
|
||||
"""A long configured priority tag is truncated to the tag length budget."""
|
||||
long_tag = "y" * 80
|
||||
isolated_settings["priority_tags"] = {"lora": long_tag}
|
||||
|
||||
model_data = {"base_model": "SDXL", "tags": [long_tag]}
|
||||
|
||||
relative_path = calculate_relative_path_for_model(model_data, "lora")
|
||||
|
||||
assert relative_path == "SDXL/" + "y" * MAX_PATH_TAG_LENGTH
|
||||
|
||||
|
||||
def test_calculate_relative_path_keeps_tag_within_budget(isolated_settings):
|
||||
model_data = {"base_model": "SDXL", "tags": ["t" * 40]}
|
||||
|
||||
relative_path = calculate_relative_path_for_model(model_data, "lora")
|
||||
|
||||
assert relative_path == "SDXL/" + "t" * 40
|
||||
|
||||
|
||||
def test_calculate_relative_path_caps_model_and_version_names(isolated_settings):
|
||||
isolated_settings["download_path_templates"]["lora"] = "{model_name}/{version_name}"
|
||||
|
||||
model_data = {
|
||||
"model_name": "m" * 300,
|
||||
"base_model": "SDXL",
|
||||
"tags": [],
|
||||
"civitai": {"id": 1, "name": "v" * 300, "creator": {"username": "Creator"}},
|
||||
}
|
||||
|
||||
relative_path = calculate_relative_path_for_model(model_data, "lora")
|
||||
|
||||
assert relative_path == (
|
||||
"m" * MAX_FOLDER_NAME_LENGTH + "/" + "v" * MAX_FOLDER_NAME_LENGTH
|
||||
)
|
||||
|
||||
|
||||
def test_calculate_recipe_fingerprint_filters_and_sorts():
|
||||
loras = [
|
||||
{"hash": "ABC", "strength": 0.1234},
|
||||
@@ -304,6 +392,32 @@ def test_calculate_filename_original_name_falls_back_to_file_name(isolated_setti
|
||||
assert calculate_filename_for_model(model_data, "lora") == "legacy-name-0123456789"
|
||||
|
||||
|
||||
def test_calculate_filename_drops_keyword_dump_tag(isolated_settings):
|
||||
"""The keyword-dump tag collapses instead of filling the filename (#1119)."""
|
||||
_set_filename_templates(isolated_settings, "{base_model}-{first_tag}")
|
||||
|
||||
model_data = {
|
||||
"base_model": "Krea 2",
|
||||
"tags": [KEYWORD_DUMP_TAG],
|
||||
"file_path": "/models/V1.safetensors",
|
||||
}
|
||||
|
||||
assert calculate_filename_for_model(model_data, "lora") == "Krea 2"
|
||||
|
||||
|
||||
def test_calculate_filename_caps_rendered_stem(isolated_settings):
|
||||
_set_filename_templates(isolated_settings, "{model_name}")
|
||||
|
||||
model_data = {
|
||||
"model_name": "m" * 400,
|
||||
"file_path": "/models/V1.safetensors",
|
||||
}
|
||||
|
||||
result = calculate_filename_for_model(model_data, "lora")
|
||||
|
||||
assert len(result) == MAX_FILENAME_STEM_LENGTH
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"original, expected",
|
||||
[
|
||||
@@ -318,6 +432,27 @@ def test_sanitize_folder_name(original, expected):
|
||||
assert sanitize_folder_name(original) == expected
|
||||
|
||||
|
||||
def test_sanitize_folder_name_without_max_length_is_unbounded():
|
||||
assert sanitize_folder_name("x" * 300) == "x" * 300
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"original, max_length, expected",
|
||||
[
|
||||
("abcdefghij", 4, "abcd"),
|
||||
# Re-trim separators and spaces exposed by the cut.
|
||||
("abc...defg", 4, "abc"),
|
||||
("abcdefg hij", 8, "abcdefg"),
|
||||
# Shorter than the cap is returned untouched.
|
||||
("short", 10, "short"),
|
||||
# A cut that leaves only separators falls back to "unnamed".
|
||||
("...abcdef", 3, "unnamed"),
|
||||
],
|
||||
)
|
||||
def test_sanitize_folder_name_truncates_to_max_length(original, max_length, expected):
|
||||
assert sanitize_folder_name(original, max_length=max_length) == expected
|
||||
|
||||
|
||||
def test_get_lora_info_absolute_bare_name(mock_lora_scanner):
|
||||
mock_lora_scanner([
|
||||
{"file_name": "mylora", "folder": "SDXL", "file_path": "/models/Lora/SDXL/mylora.safetensors", "civitai": {"trainedWords": ["trigger1"]}},
|
||||
@@ -427,3 +562,22 @@ def test_get_lora_info_not_found_returns_original(mock_lora_scanner):
|
||||
|
||||
assert path == "nonexistent"
|
||||
assert triggers == []
|
||||
|
||||
|
||||
def test_get_lora_info_absolute_preserves_exact_stack_path(mock_lora_scanner):
|
||||
mock_lora_scanner([
|
||||
{"file_name": "same", "folder": "a", "file_path": "/models/a/same.safetensors", "civitai": {"trainedWords": ["wrong"]}},
|
||||
{"file_name": "same", "folder": "b", "file_path": "/models/b/same.safetensors", "civitai": {"trainedWords": ["right"]}},
|
||||
])
|
||||
assert get_lora_info_absolute("/models/b/same.safetensors") == (
|
||||
"/models/b/same.safetensors", ["right"]
|
||||
)
|
||||
|
||||
|
||||
def test_get_lora_info_absolute_does_not_substitute_missing_absolute_path(mock_lora_scanner):
|
||||
mock_lora_scanner([
|
||||
{"file_name": "same", "folder": "a", "file_path": "/models/a/same.safetensors"},
|
||||
])
|
||||
assert get_lora_info_absolute("/models/missing/same.safetensors") == (
|
||||
"/models/missing/same.safetensors", []
|
||||
)
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
import { ref, computed, watch, nextTick, onUnmounted } from 'vue'
|
||||
import ModalWrapper from '../lora-pool/modals/ModalWrapper.vue'
|
||||
import type { LoraItem } from '../../composables/types'
|
||||
import { lmApiUrl } from '@/utils/basePath'
|
||||
|
||||
interface LoraListItem {
|
||||
index: number
|
||||
@@ -131,7 +132,7 @@ const selectLora = (index: number) => {
|
||||
// in the Vue widgets build, so we need to use the full path with /api prefix
|
||||
const customPreviewUrlResolver = async (modelName: string) => {
|
||||
const response = await fetch(
|
||||
`/api/lm/loras/preview-url?name=${encodeURIComponent(modelName)}&license_flags=true`
|
||||
lmApiUrl(`/api/lm/loras/preview-url?name=${encodeURIComponent(modelName)}&license_flags=true`)
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch preview URL')
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import type { LoraEntry } from '../../composables/types'
|
||||
import { lmApiUrl } from '@/utils/basePath'
|
||||
|
||||
const props = defineProps<{
|
||||
loras: LoraEntry[]
|
||||
@@ -48,7 +49,7 @@ const previewUrls = ref<Record<string, string>>({})
|
||||
// Fetch preview URL for a lora using API
|
||||
const fetchPreviewUrl = async (loraName: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/lm/loras/preview-url?name=${encodeURIComponent(loraName)}`)
|
||||
const response = await fetch(lmApiUrl(`/api/lm/loras/preview-url?name=${encodeURIComponent(loraName)}`))
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import type { ComponentWidget, CyclerConfig, LoraPoolConfig } from './types'
|
||||
import { lmApiUrl } from '@/utils/basePath'
|
||||
|
||||
export interface CyclerLoraItem {
|
||||
file_name: string
|
||||
@@ -173,7 +174,7 @@ export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
|
||||
requestBody.pool_config = poolConfig.filters
|
||||
}
|
||||
|
||||
const response = await fetch('/api/lm/loras/cycler-list', {
|
||||
const response = await fetch(lmApiUrl('/api/lm/loras/cycler-list'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { ref } from 'vue'
|
||||
import type { BaseModelOption, TagOption, FolderTreeNode, LoraItem } from './types'
|
||||
import { lmApiUrl } from '@/utils/basePath'
|
||||
|
||||
export function useLoraPoolApi() {
|
||||
const isLoading = ref(false)
|
||||
|
||||
const fetchBaseModels = async (limit = 50): Promise<BaseModelOption[]> => {
|
||||
try {
|
||||
const response = await fetch(`/api/lm/loras/base-models?limit=${limit}`)
|
||||
const response = await fetch(lmApiUrl(`/api/lm/loras/base-models?limit=${limit}`))
|
||||
const data = await response.json()
|
||||
return data.base_models || []
|
||||
} catch (error) {
|
||||
@@ -17,7 +18,7 @@ export function useLoraPoolApi() {
|
||||
|
||||
const fetchTags = async (limit = 0): Promise<TagOption[]> => {
|
||||
try {
|
||||
const response = await fetch(`/api/lm/loras/top-tags?limit=${limit}`)
|
||||
const response = await fetch(lmApiUrl(`/api/lm/loras/top-tags?limit=${limit}`))
|
||||
const data = await response.json()
|
||||
return data.tags || []
|
||||
} catch (error) {
|
||||
@@ -28,7 +29,7 @@ export function useLoraPoolApi() {
|
||||
|
||||
const fetchFolderTree = async (): Promise<FolderTreeNode[]> => {
|
||||
try {
|
||||
const response = await fetch('/api/lm/loras/unified-folder-tree')
|
||||
const response = await fetch(lmApiUrl('/api/lm/loras/unified-folder-tree'))
|
||||
const data = await response.json()
|
||||
return transformFolderTree(data.tree || {})
|
||||
} catch (error) {
|
||||
@@ -102,7 +103,7 @@ export function useLoraPoolApi() {
|
||||
urlParams.set('name_pattern_use_regex', String(params.namePatternsUseRegex))
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/lm/loras/list?${urlParams}`)
|
||||
const response = await fetch(lmApiUrl(`/api/lm/loras/list?${urlParams}`))
|
||||
const data = await response.json()
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { ComponentWidget, RandomizerConfig, LoraEntry } from './types'
|
||||
import { lmApiUrl } from '@/utils/basePath'
|
||||
|
||||
export function useLoraRandomizerState(widget: ComponentWidget<RandomizerConfig>) {
|
||||
// Flag to prevent infinite loops during config restoration
|
||||
@@ -160,7 +161,7 @@ export function useLoraRandomizerState(widget: ComponentWidget<RandomizerConfig>
|
||||
}
|
||||
|
||||
// Call API endpoint
|
||||
const response = await fetch('/api/lm/loras/random-sample', {
|
||||
const response = await fetch(lmApiUrl('/api/lm/loras/random-sample'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
* - Lora Cycler (LoraManager)
|
||||
*/
|
||||
|
||||
// @ts-ignore
|
||||
import { interceptModeChange } from '../../web/comfyui/utils.js'
|
||||
|
||||
/**
|
||||
* List of node types that act as LoRA providers in the workflow chain.
|
||||
* These nodes can be traversed when collecting active LoRAs and can trigger
|
||||
@@ -120,8 +123,11 @@ export function isNodeActive(mode: number | undefined): boolean {
|
||||
/**
|
||||
* Setup a mode change handler for a node.
|
||||
*
|
||||
* Intercepts the mode property setter to trigger a callback when the mode changes.
|
||||
* This is needed because ComfyUI sets the mode property directly without using a setter.
|
||||
* Delegates to `interceptModeChange`, which observes the mode property
|
||||
* without shadowing the frontend's own `mode` accessor. Since ComfyUI
|
||||
* frontend 1.53, `mode` is backed by shell state (`node._state.mode`) that
|
||||
* serialization reads directly — redefining the property on the instance
|
||||
* would silently revert bypass/mute on save/reload.
|
||||
*
|
||||
* @param node - The node to set up the handler for
|
||||
* @param onModeChange - Callback function called when mode changes (receives newMode and oldMode)
|
||||
@@ -130,21 +136,7 @@ export function setupModeChangeHandler(
|
||||
node: any,
|
||||
onModeChange: (newMode: number, oldMode: number) => void
|
||||
): void {
|
||||
let _mode = node.mode;
|
||||
|
||||
Object.defineProperty(node, 'mode', {
|
||||
get() {
|
||||
return _mode;
|
||||
},
|
||||
set(value: number) {
|
||||
const oldValue = _mode;
|
||||
_mode = value;
|
||||
|
||||
if (oldValue !== value) {
|
||||
onModeChange(value, oldValue);
|
||||
}
|
||||
}
|
||||
});
|
||||
interceptModeChange(node, onModeChange);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export function getLmBasePath(): string {
|
||||
const { pathname } = window.location;
|
||||
return pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
||||
}
|
||||
|
||||
export function lmApiUrl(path: string): string {
|
||||
return `${getLmBasePath()}${path}`;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user