Compare commits

...
14 Commits
Author SHA1 Message Date
Will Miao 3555ddb588 chore(release): bump version to v1.2.4 2026-09-26 08:53:42 +08:00
Will Miao ede15032ce fix: expose source_model_id/source_version_id in model list payloads
format_response whitelists fields explicitly, so the new ModelScope
identity fields never reached the frontend: group badges rendered, but
card.dataset.modelId stayed empty and clicking 'N versions' silently
no-oped (handleViewLocalVersionsFromCard early-returns without it).
Found via browser E2E against a sandboxed standalone server.
2026-09-26 07:35:36 +08:00
Will Miao c48feeddb6 fix: stop grouping HF/ModelScope models by repository
A repository is not a model identity: collection repos on Hugging Face
and ModelScope host many unrelated models, which were wrongly shown as
versions of each other.

- Hugging Face models no longer auto-group (the Hub exposes no
  site-native model id)
- ModelScope models group by the site's native published-model id
  (MuseInfo modelVersion.modelId), extracted during enrichment and
  persisted on the sidecar as source_model_id/source_version_id;
  unenriched models stay standalone instead of collapsing a whole repo
  into one group
- TensorArt grouping unchanged (its URL id is already model-level)
- Frontend group-key derivation mirrors the new backend semantics
2026-09-25 23:29:30 +08:00
Will Miao 8a80f82d93 docs: clarify .civitai.info is a read-only third-party file, not an LM sidecar 2026-09-25 22:40:32 +08:00
Will Miao c4676183b5 i18n: translate huggingfaceApiKey settings strings in all locales
Fill the six settings.huggingfaceApiKey* placeholders left by the HF
access-token feature in all 9 locales, reusing each locale's existing
civitaiApiKey* status renderings; document the new terminology
(access token, gated repository) in the translation guidelines.
2026-09-25 18:46:31 +08:00
Will Miao 8b7ba59263 feat: support gated/private Hugging Face repos via access token
Add a huggingface_api_key setting (Settings UI, HF_TOKEN /
HUGGING_FACE_HUB_TOKEN env override) and attach it as a Bearer token
to Hugging Face file listing, model card fetching and downloads, so
gated and private repositories can be downloaded once the user has
accepted the repo terms.

- fetch_json/fetch_text accept custom headers; ModelSource gains an
  auth_headers() hook so handlers stay platform-agnostic
- 401/403 from the tree API now explain how to fix (configure token /
  accept gated terms)
- aria2 pre-resolves huggingface.co redirects and strips credentials
  before handing the signed CDN URL to aria2, mirroring the CivitAI
  handling so the token never leaks to the CDN
- settings API exposes huggingface_api_key_set only; the raw key joins
  _NO_SYNC_KEYS
2026-09-25 18:44:06 +08:00
Will Miao 067e605e75 fix: keep bypass/mute state on frontend 1.53+ node shell state (#1123)
ComfyUI frontend 1.53 turned LGraphNode.mode into a prototype accessor
backed by node._state, and serialize() now reads that state directly.
Redefining mode on the instance shadowed the setter, so bypass/mute
never reached the serialized workflow and silently reverted to Always
on save/reload or workflow tab switch.

Add interceptModeChange() in web/comfyui/utils.js: it delegates to the
prototype accessor when one exists (observing changes only), and falls
back to the legacy closure accessor on older frontends. Use it in
lora_loader.js and in the Vue widgets' setupModeChangeHandler, which
covers the LoRA provider/aggregator nodes with the same latent bug.
2026-09-25 18:12:01 +08:00
Will Miao dae18b3d1d fix: re-run dynamic prompts fed through linked text inputs
IS_CHANGED only receives constant inputs, so a linked text always
arrived as None and the node kept serving its cached first expansion.
Declare hidden PROMPT/UNIQUE_ID inputs and walk the prompt graph to
the upstream node: rerun only when its constants contain dynamic
syntax or cannot be statically resolved, keep caching for static
linked text.

Fixes #1120
2026-09-24 09:33:27 +08:00
Will Miao 2f9bd3ee7d feat: support reverse-proxy URL subpaths (llama-swap, SwarmUI) (#1122) 2026-09-24 08:04:00 +08:00
Will Miao 755e1a5bca fix: fall back to source image dimensions for missing width/height
When metadata extraction succeeds but no recognized latent source
provides dimensions (e.g. img2img via VAEEncode), width/height now fall
back to the source image size from the loaded pixels instead of the
synthetic 1024x1024 starter preset. The starter preset for metadata-free
images keeps its fixed size, and explicit overrides still win.
2026-09-23 20:31:59 +08:00
pixelpaws c202654d49 Merge pull request #1121 from mmartial/loader
Add Load Image Metadata node for reusing generation settings
2026-09-23 20:31:44 +08:00
Will Miao 74736f7560 fix(organize): exclude Civitai meta tags from folder names (#1119)
Follow-up to the keyword-dump guard. The reported model's tag list is
["lora, character, ... face", "base model"], so skipping the dump left the
"base model" label to be picked as the folder name. That label describes
Civitai's listing rather than the model's content, which makes it as
meaningless as a folder as the blob was.

Add CIVITAI_META_TAGS and is_civitai_meta_tag(), and skip those labels in
the automatic fallback. An explicit priority entry still matches them, so a
user who does want a "base model" folder can configure one.

The reported model now resolves to "Krea 2/no tags" instead of
"Krea 2/base model".

Also correct a comment that listed ".civitai.info" among the files sitting
next to a model. LoRA Manager only reads that sidecar -- other tools write
it -- and writes ".metadata.json" itself.
2026-09-23 13:33:59 +08:00
Will Miao 0ada32d0c7 fix(organize): stop keyword-dump tags from becoming folder names (#1119)
CivitAI tags are normally short single-concept labels, but some uploaders
pack their entire keyword list into one tag. The model in #1119 carries
"lora, character, rosie, irish, ... face" as a single 181-character tag.
Priority resolution matches aliases by exact equality, so that tag matched
nothing and resolve_priority_tag_for_model fell back to tags[0] -- the blob.
With the default "{base_model}/{first_tag}" template the model was filed
under "Krea 2/<181-character blob>/", and the full path plus the
".civitai.info" sidecar and the preview images next to it ran into the
Windows MAX_PATH limit.

Tags also bypassed sanitization on the way into a path: both
calculate_relative_path_for_model and DownloadManager._calculate_relative_path
sanitized model_name and version_name but interpolated {first_tag} verbatim,
so a tag containing "/" or ":" silently produced nested or illegal folders.

Two changes:

- The fallback skips tags that cannot serve as a folder name.
  is_usable_path_tag rejects comma-separated keyword dumps and tags longer
  than MAX_PATH_TAG_LENGTH; the resolver returns "" when nothing usable is
  left, which callers already render as "no tags". Whole-tag priority
  matching is untouched, so existing priority configurations behave the
  same.
- sanitize_folder_name gains an optional max_length, and every tag-derived
  segment now goes through it. Tags are capped at MAX_PATH_TAG_LENGTH, model
  and version names at MAX_FOLDER_NAME_LENGTH, and rendered filename stems at
  MAX_FILENAME_STEM_LENGTH.

For the reported model the folder becomes "Krea 2/base model" instead of the
blob, and the full path drops from 235 to 64 characters.

Existing libraries are not migrated up front: a path is only recomputed on
download, on an auto-organize run or when a filename template is applied, and
values already inside the caps are left byte-identical. Models previously
filed under a keyword-dump folder move on the next auto-organize run.
2026-09-23 13:16:26 +08:00
Martial Michel e9aff35957 feat: add image metadata loader with native LoRA Manager integration
Add Load Image Metadata (LoraManager) to extract reusable prompts,
model references, LoRA stacks, and sampling settings from images.

Prefer saved A1111-style parameters by default, with optional workflow
and subgraph sampler selection. Resolve local model and LoRA names,
report missing resources, and recover extraction failures with explicit
defaults and readable diagnostics.

Include parser, resource-resolution, and node regression tests, plus
usage documentation.
2026-09-22 22:18:03 -04:00
112 changed files with 4745 additions and 549 deletions
+5
View File
@@ -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
+3
View File
@@ -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
View File
@@ -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
}
+14
View File
@@ -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
+211
View File
@@ -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.
+7 -1
View File
@@ -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`.
+6
View File
@@ -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.",
+6
View File
@@ -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.",
+6
View File
@@ -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\".",
+6
View File
@@ -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 ».",
+6
View File
@@ -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\".",
+6
View File
@@ -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 サイトを選択します。",
+6
View File
@@ -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 사이트를 열지 선택합니다.",
+6
View File
@@ -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».",
+6
View File
@@ -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 站点。",
+6
View File
@@ -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 站點。",
+444
View File
@@ -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
+4 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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),)
+3
View File
@@ -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
+2
View File
@@ -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(
+3
View File
@@ -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
+2
View File
@@ -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(
+9
View File
@@ -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
+15 -7
View File
@@ -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:
+6 -10
View File
@@ -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)
+2
View File
@@ -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", ""),
}
+14 -2
View File
@@ -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":
+2
View File
@@ -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", ""),
}
+2
View File
@@ -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", ""),
}
+5 -1
View File
@@ -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)
+50 -8
View File
@@ -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:
+49 -2
View File
@@ -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})"
+48 -1
View File
@@ -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.
+8 -3
View File
@@ -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__ = [
+2
View File
@@ -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", ""),
}
+10
View File
@@ -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:
+20 -2
View File
@@ -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]]:
+45
View File
@@ -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."""
+25
View File
@@ -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(
+17
View File
@@ -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:
+578
View File
@@ -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
+42
View File
@@ -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
+18
View File
@@ -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
View File
@@ -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
View File
@@ -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');
}
+3 -2
View File
@@ -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
+1 -1
View File
@@ -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 -2
View File
@@ -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) {
+45 -10
View File
@@ -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) {
+2
View File
@@ -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: '',
+16
View File
@@ -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}`;
}
+22 -4
View File
@@ -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
View File
@@ -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 %}
+1 -1
View File
@@ -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>
+8 -8
View File
@@ -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>
+5 -5
View File
@@ -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">
+2 -2
View File
@@ -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>
+1 -1
View File
@@ -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 %}
+1 -1
View File
@@ -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 %}
+2 -2
View File
@@ -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 %}
+5 -5
View File
@@ -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 %}
+2 -2
View File
@@ -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());
});
});
});
+21
View File
@@ -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', () => {
+468
View File
@@ -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]
+16
View File
@@ -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
+97
View File
@@ -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,
+2 -2
View File
@@ -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
+61
View File
@@ -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]
+44 -41
View File
@@ -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
+12
View File
@@ -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(
+140 -6
View File
@@ -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)
+69
View File
@@ -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
# ======================================================================
+81
View File
@@ -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"] = ""
+250
View File
@@ -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)
+23
View File
@@ -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
+154
View File
@@ -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',
+9 -17
View File
@@ -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);
}
/**
+8
View File
@@ -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