mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-23 20:14:08 -03:00
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.
432 lines
23 KiB
Python
432 lines
23 KiB
Python
"""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)
|
||
for key, default in EMPTY_IMAGE_DEFAULTS.items():
|
||
if key not in values:
|
||
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
|