mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-23 12:04:09 -03:00
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.
This commit is contained in:
@@ -0,0 +1,431 @@
|
||||
"""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
|
||||
@@ -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,
|
||||
|
||||
@@ -177,6 +177,11 @@ class ExifUtils:
|
||||
return brotli_meta
|
||||
|
||||
with Image.open(image_path) as img:
|
||||
# PNG text chunks may legally follow IDAT. Pillow reads those only
|
||||
# when loading the image, so inspecting info immediately after open
|
||||
# can incorrectly report a metadata-free image.
|
||||
if img.format == "PNG":
|
||||
img.load()
|
||||
info = getattr(img, "info", {}) or {}
|
||||
|
||||
if "parameters" in info:
|
||||
@@ -193,6 +198,18 @@ class ExifUtils:
|
||||
exif[piexif.ExifIFD.UserComment]
|
||||
)
|
||||
|
||||
# ComfyUI's WebP exporter stores JSON in EXIF Make/Model with
|
||||
# prompt:/workflow: prefixes instead of UserComment.
|
||||
exif = img.getexif()
|
||||
for tag in (piexif.ImageIFD.Make, piexif.ImageIFD.Model):
|
||||
text = ExifUtils._decode_exif_text(exif.get(tag))
|
||||
if not text:
|
||||
continue
|
||||
for key in ("prompt", "workflow"):
|
||||
prefix = key + ":"
|
||||
if text.startswith(prefix) and not metadata[key]:
|
||||
metadata[key] = text[len(prefix):].rstrip("\x00")
|
||||
|
||||
try:
|
||||
exif_dict = piexif.load(image_path)
|
||||
except Exception as e:
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
"""Offline extraction of reusable generation settings from image metadata.
|
||||
|
||||
Embedded graphs are data: only explicit adapters are followed, never executed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
class MetadataError(ValueError):
|
||||
"""Metadata cannot be interpreted without a user decision."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class GenerationMetadata:
|
||||
values: dict[str, Any] = field(default_factory=dict)
|
||||
loras: list[tuple[str, float, float]] = field(default_factory=list)
|
||||
issues: dict[str, str] = field(default_factory=dict)
|
||||
notes: list[str] = field(default_factory=list)
|
||||
resource_hints: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
|
||||
LORA_PATTERN = re.compile(r"<lora:([^<>]+?):([+-]?[\d.eE]+)(?::([+-]?[\d.eE]+))?>", re.I)
|
||||
SAMPLERS = {
|
||||
"euler": "euler", "euler a": "euler_ancestral", "heun": "heun",
|
||||
"lms": "lms", "dpm2": "dpm_2", "dpm2 a": "dpm_2_ancestral",
|
||||
"dpm++ 2m": "dpmpp_2m", "dpm++ 2s a": "dpmpp_2s_ancestral",
|
||||
"dpm++ sde": "dpmpp_sde", "dpm++ 2m sde": "dpmpp_2m_sde",
|
||||
"dpm++ 3m sde": "dpmpp_3m_sde", "ddim": "ddim", "uni pc": "uni_pc",
|
||||
}
|
||||
|
||||
|
||||
def finite_number(value: Any) -> float:
|
||||
if isinstance(value, bool):
|
||||
raise MetadataError("Boolean is not a numeric generation setting")
|
||||
number = float(value)
|
||||
if not math.isfinite(number):
|
||||
raise MetadataError("Generation settings must be finite numbers")
|
||||
return number
|
||||
|
||||
|
||||
def split_lora_tags(text: str) -> tuple[str, list[tuple[str, float, float]]]:
|
||||
loras = []
|
||||
|
||||
def remove(match: re.Match[str]) -> str:
|
||||
model = finite_number(match[2])
|
||||
clip = finite_number(match[3]) if match[3] is not None else model
|
||||
loras.append((match[1].strip(), model, clip))
|
||||
return ""
|
||||
|
||||
clean = LORA_PATTERN.sub(remove, text).strip()
|
||||
if re.search(r"<lora:", clean, re.I):
|
||||
raise MetadataError("Malformed LoRA directive; correct the prompt with overrides_json")
|
||||
return clean, loras
|
||||
|
||||
|
||||
def _json_object(value: Any) -> dict[str, Any]:
|
||||
if isinstance(value, str):
|
||||
if len(value) > 16 * 1024 * 1024:
|
||||
raise MetadataError("Metadata exceeds the 16 MiB parsing limit")
|
||||
value = json.loads(value)
|
||||
if not isinstance(value, dict):
|
||||
raise MetadataError("Expected a metadata JSON object")
|
||||
return value
|
||||
|
||||
|
||||
class GraphReader:
|
||||
"""Follow a selected sampler's inputs without mixing workflow branches."""
|
||||
|
||||
def __init__(self, graph: dict[str, Any], inactive_ids: set[str] | None = None) -> None:
|
||||
if len(graph) > 10000:
|
||||
raise MetadataError("Workflow exceeds the 10,000 node parsing limit")
|
||||
self.graph = {str(key): value for key, value in graph.items()}
|
||||
self.inactive_ids = inactive_ids or set()
|
||||
self.result = GenerationMetadata()
|
||||
|
||||
def node(self, link: Any, seen: tuple[str, ...]) -> tuple[str, str, dict[str, Any]]:
|
||||
if not (isinstance(link, list) and len(link) == 2 and isinstance(link[1], int)):
|
||||
raise MetadataError("Expected a workflow connection")
|
||||
node_id = str(link[0])
|
||||
if node_id in seen or len(seen) >= 100:
|
||||
raise MetadataError("Cyclic or excessively deep workflow connection")
|
||||
node = self.graph.get(node_id)
|
||||
if not isinstance(node, dict) or not isinstance(node.get("inputs"), dict):
|
||||
raise MetadataError(f"Missing or malformed node {node_id}")
|
||||
return node_id, node.get("class_type", ""), node["inputs"]
|
||||
|
||||
def scalar(self, value: Any, seen: tuple[str, ...] = ()) -> Any:
|
||||
if not isinstance(value, list):
|
||||
if isinstance(value, (str, int, float)) and not isinstance(value, bool):
|
||||
return value
|
||||
raise MetadataError("Missing or non-scalar setting")
|
||||
node_id, kind, inputs = self.node(value, seen)
|
||||
if kind == "Input Parameters (Image Saver)":
|
||||
keys = ("seed", "steps", "cfg", "sampler", "scheduler", "denoise")
|
||||
if not 0 <= value[1] < len(keys):
|
||||
raise MetadataError(f"Unsupported parameter output {value[1]} on {node_id}")
|
||||
return self.scalar(inputs.get(keys[value[1]]), (*seen, node_id))
|
||||
if value[1] != 0:
|
||||
raise MetadataError(f"Unsupported output {value[1]} on {kind} ({node_id})")
|
||||
keys = {
|
||||
"PrimitiveNode": "value", "PrimitiveInt": "value", "PrimitiveFloat": "value",
|
||||
"PrimitiveString": "value", "PrimitiveStringMultiline": "value",
|
||||
"easy int": "value", "easy float": "value", "easy string": "value",
|
||||
"Seed (rgthree)": "seed",
|
||||
"Sampler Selector (Image Saver)": "sampler_name",
|
||||
"Scheduler Selector (Image Saver)": "scheduler",
|
||||
"Text (LoraManager)": "text", "Reroute": "value",
|
||||
}
|
||||
if kind not in keys:
|
||||
raise MetadataError(f"Unsupported value node {kind} ({node_id})")
|
||||
resolved = self.scalar(inputs.get(keys[kind]), (*seen, node_id))
|
||||
if kind == "Text (LoraManager)" and isinstance(resolved, str) and re.search(r"__[^\n]+?__|\{[^{}]*\|[^{}]*\}", resolved):
|
||||
raise MetadataError("Dynamic text expansion requires an explicit prompt override")
|
||||
return resolved
|
||||
|
||||
def text(self, link: Any, seen: tuple[str, ...] = ()) -> str:
|
||||
node_id, kind, inputs = self.node(link, seen)
|
||||
if link[1] != 0:
|
||||
raise MetadataError(f"Unsupported conditioning output on {kind} ({node_id})")
|
||||
if kind in ("CLIPTextEncode", "Prompt (LoraManager)"):
|
||||
if kind == "Prompt (LoraManager)" and any(k.startswith("trigger_words") for k in inputs):
|
||||
raise MetadataError("Prompt has dynamic trigger words; provide an explicit prompt override")
|
||||
value = self.scalar(inputs.get("text"), (*seen, node_id))
|
||||
if not isinstance(value, str):
|
||||
raise MetadataError("Prompt is not text")
|
||||
if kind == "Prompt (LoraManager)" and re.search(r"__[^\n]+?__|\{[^{}]*\|[^{}]*\}", value):
|
||||
raise MetadataError("Dynamic prompt expansion cannot be recovered from source text; provide an explicit prompt override")
|
||||
return value
|
||||
if kind in ("CLIPTextEncodeSDXL", "CLIPTextEncodeFlux"):
|
||||
keys = ("text_g", "text_l") if kind == "CLIPTextEncodeSDXL" else ("clip_l", "t5xxl")
|
||||
texts = [self.scalar(inputs.get(key), (*seen, node_id)) for key in keys]
|
||||
if texts[0] != texts[1] or not isinstance(texts[0], str):
|
||||
raise MetadataError(f"{kind} has distinct encoder prompts; a single string cannot reproduce it")
|
||||
self.result.notes.append(f"{kind}: restore architecture-specific conditioning separately.")
|
||||
return texts[0]
|
||||
if kind == "ConditioningZeroOut":
|
||||
raise MetadataError("Zeroed conditioning is not equivalent to encoding an empty prompt")
|
||||
raise MetadataError(f"Unsupported conditioning node {kind} ({node_id}); use a prompt override")
|
||||
|
||||
def widget_loras(self, value: Any) -> list[tuple[str, float, float]]:
|
||||
if isinstance(value, dict):
|
||||
value = value.get("__value__")
|
||||
if isinstance(value, list) and len(value) == 1 and isinstance(value[0], list):
|
||||
value = value[0]
|
||||
if not isinstance(value, list):
|
||||
raise MetadataError("Unsupported LoRA widget data")
|
||||
entries = []
|
||||
for item in value:
|
||||
if not isinstance(item, dict):
|
||||
raise MetadataError("Malformed LoRA widget entry")
|
||||
if item.get("active", False):
|
||||
name = item.get("name")
|
||||
if not isinstance(name, str) or not name:
|
||||
raise MetadataError("LoRA name is missing")
|
||||
strength = finite_number(item.get("strength"))
|
||||
entries.append((name, strength, finite_number(item.get("clipStrength", strength))))
|
||||
return entries
|
||||
|
||||
def stack(self, link: Any, seen: tuple[str, ...] = ()) -> list[tuple[str, float, float]]:
|
||||
node_id, kind, inputs = self.node(link, seen)
|
||||
if link[1] != 0:
|
||||
raise MetadataError("Unsupported LoRA stack output")
|
||||
seen = (*seen, node_id)
|
||||
if kind == "Lora Stacker (LoraManager)":
|
||||
previous = self.stack(inputs["lora_stack"], seen) if "lora_stack" in inputs else []
|
||||
return previous + self.widget_loras(inputs.get("loras", []))
|
||||
if kind == "Lora Stack Combiner (LoraManager)":
|
||||
entries = []
|
||||
keys = [key for key in inputs if re.fullmatch(r"lora_stack\d+", key)]
|
||||
for key in sorted(keys, key=lambda key: int(key[len("lora_stack"):])):
|
||||
entries.extend(self.stack(inputs[key], seen))
|
||||
return entries
|
||||
raise MetadataError(f"Unsupported LoRA stack node {kind} ({node_id})")
|
||||
|
||||
def model(self, link: Any, seen: tuple[str, ...] = ()) -> None:
|
||||
node_id, kind, inputs = self.node(link, seen)
|
||||
if link[1] != 0:
|
||||
raise MetadataError("Unsupported model output")
|
||||
seen = (*seen, node_id)
|
||||
loaders = {
|
||||
"CheckpointLoaderSimple": ("checkpoint_name", "ckpt_name"),
|
||||
"CheckpointLoader": ("checkpoint_name", "ckpt_name"),
|
||||
"Checkpoint Loader (LoraManager)": ("checkpoint_name", "ckpt_name"),
|
||||
"UNETLoader": ("unet_name", "unet_name"),
|
||||
"Unet Loader (LoraManager)": ("unet_name", "unet_name"),
|
||||
}
|
||||
if kind in loaders:
|
||||
output, key = loaders[kind]
|
||||
self.result.values[output] = self.scalar(inputs.get(key), seen)
|
||||
return
|
||||
if kind in ("LoraLoader", "LoraLoaderModelOnly", "Lora Loader (LoraManager)", "LoraLoaderLM", "LoRA Text Loader (LoraManager)"):
|
||||
self.model(inputs.get("model"), seen)
|
||||
if "lora_stack" in inputs:
|
||||
self.result.loras.extend(self.stack(inputs["lora_stack"], seen))
|
||||
if kind in ("LoraLoader", "LoraLoaderModelOnly"):
|
||||
strength = finite_number(self.scalar(inputs.get("strength_model"), seen))
|
||||
clip = 0.0 if kind == "LoraLoaderModelOnly" else finite_number(self.scalar(inputs.get("strength_clip"), seen))
|
||||
name = self.scalar(inputs.get("lora_name"), seen)
|
||||
if not isinstance(name, str):
|
||||
raise MetadataError("LoRA name is not text")
|
||||
self.result.loras.append((name, strength, clip))
|
||||
elif kind == "LoRA Text Loader (LoraManager)":
|
||||
_, entries = split_lora_tags(self.scalar(inputs.get("lora_syntax"), seen))
|
||||
self.result.loras.extend(entries)
|
||||
else:
|
||||
self.result.loras.extend(self.widget_loras(inputs.get("loras", [])))
|
||||
return
|
||||
raise MetadataError(f"Unsupported model node {kind} ({node_id}); model/LoRA chain is incomplete")
|
||||
|
||||
def clip_loras(self, link: Any, seen: tuple[str, ...] = ()) -> list[tuple[str, float]]:
|
||||
"""Check that prompt CLIP branches actually use the recovered LoRA stack."""
|
||||
node_id, kind, inputs = self.node(link, seen)
|
||||
seen = (*seen, node_id)
|
||||
if kind in ("CheckpointLoaderSimple", "CheckpointLoader", "Checkpoint Loader (LoraManager)") and link[1] == 1:
|
||||
return []
|
||||
if kind in ("CLIPLoader", "DualCLIPLoader", "TripleCLIPLoader") and link[1] == 0:
|
||||
return []
|
||||
if kind in ("LoraLoader", "Lora Loader (LoraManager)", "LoraLoaderLM", "LoRA Text Loader (LoraManager)") and link[1] == 1:
|
||||
previous = self.clip_loras(inputs.get("clip"), seen)
|
||||
entries = self.stack(inputs["lora_stack"], seen) if "lora_stack" in inputs else []
|
||||
if kind == "LoraLoader":
|
||||
entries.append((self.scalar(inputs.get("lora_name")), 0, finite_number(self.scalar(inputs.get("strength_clip")))))
|
||||
elif kind == "LoRA Text Loader (LoraManager)":
|
||||
_, parsed = split_lora_tags(self.scalar(inputs.get("lora_syntax")))
|
||||
entries.extend(parsed)
|
||||
else:
|
||||
entries.extend(self.widget_loras(inputs.get("loras", [])))
|
||||
return previous + [(name, clip) for name, _, clip in entries if clip != 0]
|
||||
raise MetadataError(f"Unsupported CLIP branch {kind} ({node_id}); restore text encoder/conditioning separately")
|
||||
|
||||
def select_sampler(self, sampler_id: str) -> str:
|
||||
candidates = [key for key, node in self.graph.items() if isinstance(node, dict) and node.get("class_type") in ("KSampler", "KSamplerAdvanced", "SamplerCustomAdvanced")
|
||||
and node.get("mode", 0) == 0
|
||||
and not any(key == prefix or key.startswith(prefix + ":") for prefix in self.inactive_ids)]
|
||||
selector = sampler_id.strip()
|
||||
if selector in candidates:
|
||||
return selector
|
||||
# ComfyUI API prompts expand native subgraphs into colon-qualified IDs.
|
||||
# Accept slash paths too, as well as an unambiguous container/leaf ID.
|
||||
selector = selector.replace("/", ":")
|
||||
if selector in self.graph and selector not in candidates:
|
||||
raise MetadataError(f"Sampler {selector} is muted, bypassed or unsupported; active sampler IDs: {', '.join(candidates) or 'none'}")
|
||||
if selector in candidates:
|
||||
return selector
|
||||
matches = candidates if not selector else [key for key in candidates if key.startswith(selector + ":") or key.endswith(":" + selector)]
|
||||
if len(matches) == 1:
|
||||
return matches[0]
|
||||
choices = ", ".join(matches or candidates) or "none"
|
||||
raise MetadataError(f"Choose a unique sampler_node_id; supported sampler IDs: {choices}")
|
||||
|
||||
def custom_sampler_inputs(self, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Adapt the core advanced sampling pipeline without executing any nodes."""
|
||||
result = {"latent_image": inputs.get("latent_image")}
|
||||
adapters = (
|
||||
("noise", {"RandomNoise": {"seed": "noise_seed"}}, ("seed",)),
|
||||
("guider", {
|
||||
"CFGGuider": {"cfg": "cfg", "model": "model", "positive": "positive", "negative": "negative"},
|
||||
"BasicGuider": {"model": "model", "positive": "conditioning"},
|
||||
}, ("cfg", "model", "positive", "negative")),
|
||||
("sigmas", {"BasicScheduler": {"steps": "steps", "scheduler": "scheduler", "denoise": "denoise"}}, ("steps", "scheduler", "denoise")),
|
||||
)
|
||||
for key, kinds, fields in adapters:
|
||||
try:
|
||||
link = inputs.get(key)
|
||||
node_id, kind, upstream = self.node(link, ())
|
||||
if link[1] != 0 or kind not in kinds:
|
||||
raise MetadataError(f"Unsupported {key} node {kind} ({node_id})")
|
||||
for output, source in kinds[kind].items():
|
||||
result[output] = upstream.get(source)
|
||||
if kind == "BasicGuider":
|
||||
result["cfg"] = 1.0
|
||||
self.result.issues["negative"] = "BasicGuider has no negative conditioning; restore that architecture-specific setup separately"
|
||||
except MetadataError as exc:
|
||||
for field in fields:
|
||||
self.result.issues[field] = str(exc)
|
||||
try:
|
||||
link = inputs.get("sampler")
|
||||
seen = ()
|
||||
while True:
|
||||
node_id, kind, upstream = self.node(link, seen)
|
||||
seen = (*seen, node_id)
|
||||
if link[1] != 0:
|
||||
raise MetadataError("Unsupported sampler output")
|
||||
if kind == "KSamplerSelect":
|
||||
result["sampler_name"] = upstream.get("sampler_name")
|
||||
break
|
||||
if kind == "DetailDaemonSamplerNode":
|
||||
self.result.issues["sampler_effects"] = "Detail Daemon modifies sampling; recovered base sampler settings do not reproduce this effect"
|
||||
link = upstream.get("sampler")
|
||||
continue
|
||||
raise MetadataError(f"Unsupported sampler node {kind} ({node_id})")
|
||||
except MetadataError as exc:
|
||||
self.result.issues["sampler_name"] = str(exc)
|
||||
return result
|
||||
|
||||
def read(self, sampler_id: str) -> GenerationMetadata:
|
||||
sampler_id = self.select_sampler(sampler_id)
|
||||
node = self.graph[sampler_id]
|
||||
inputs = node.get("inputs")
|
||||
if not isinstance(inputs, dict):
|
||||
raise MetadataError("Malformed sampler inputs")
|
||||
self.result.notes.append(f"ComfyUI API graph; sampler {sampler_id} ({node['class_type']}).")
|
||||
if node["class_type"] == "SamplerCustomAdvanced":
|
||||
inputs = self.custom_sampler_inputs(inputs)
|
||||
for output, key in {"seed": "noise_seed" if node["class_type"] == "KSamplerAdvanced" else "seed", "steps": "steps", "cfg": "cfg", "sampler_name": "sampler_name", "scheduler": "scheduler"}.items():
|
||||
try:
|
||||
self.result.values[output] = self.scalar(inputs.get(key))
|
||||
except (ValueError, TypeError) as exc:
|
||||
self.result.issues[output] = str(exc)
|
||||
if node["class_type"] == "KSamplerAdvanced":
|
||||
self.result.issues["denoise"] = "KSamplerAdvanced start/end/noise settings cannot be represented by denoise alone"
|
||||
else:
|
||||
try:
|
||||
self.result.values["denoise"] = self.scalar(inputs.get("denoise", 1.0))
|
||||
except (ValueError, TypeError) as exc:
|
||||
self.result.issues["denoise"] = str(exc)
|
||||
for key in ("positive", "negative"):
|
||||
try:
|
||||
self.result.values[key] = self.text(inputs.get(key))
|
||||
except (ValueError, TypeError) as exc:
|
||||
self.result.issues[key] = str(exc)
|
||||
try:
|
||||
self.model(inputs.get("model"))
|
||||
except (ValueError, TypeError) as exc:
|
||||
self.result.issues["model"] = str(exc)
|
||||
self.result.issues["loras"] = "Model/LoRA chain could not be fully recovered"
|
||||
expected_clip = [(name, clip) for name, _, clip in self.result.loras if clip != 0]
|
||||
for polarity in ("positive", "negative"):
|
||||
if polarity in self.result.issues:
|
||||
continue
|
||||
try:
|
||||
_, _, encoder = self.node(inputs.get(polarity), ())
|
||||
if "clip" in encoder:
|
||||
actual_clip = self.clip_loras(encoder["clip"])
|
||||
if actual_clip != expected_clip:
|
||||
self.result.issues["loras"] = "Model and prompt CLIP branches use different LoRAs; explicitly choose a reusable stack with a loras override"
|
||||
except MetadataError as exc:
|
||||
self.result.issues[polarity] = str(exc)
|
||||
try:
|
||||
_, kind, latent = self.node(inputs.get("latent_image"), ())
|
||||
if kind in ("EmptyLatentImage", "EmptySD3LatentImage"):
|
||||
for key in ("width", "height"):
|
||||
self.result.values[key] = self.scalar(latent.get(key))
|
||||
else:
|
||||
self.result.notes.append("Latent dimensions unavailable; using image dimensions. Restore the original latent/img2img setup separately.")
|
||||
except MetadataError:
|
||||
self.result.notes.append("Latent dimensions unavailable; using image dimensions.")
|
||||
return self.result
|
||||
|
||||
|
||||
def _parameter_fields(text: str) -> dict[str, str]:
|
||||
"""Split multiline parameters without splitting JSON objects or quoted names."""
|
||||
parts = []
|
||||
start = 0
|
||||
depth = 0
|
||||
quoted = False
|
||||
escaped = False
|
||||
for index, char in enumerate(text):
|
||||
if quoted:
|
||||
if escaped:
|
||||
escaped = False
|
||||
elif char == "\\":
|
||||
escaped = True
|
||||
elif char == '"':
|
||||
quoted = False
|
||||
elif char == '"':
|
||||
quoted = True
|
||||
elif char in "[{":
|
||||
depth += 1
|
||||
elif char in "]}":
|
||||
depth = max(0, depth - 1)
|
||||
elif char == "," and depth == 0:
|
||||
parts.append(text[start:index])
|
||||
start = index + 1
|
||||
parts.append(text[start:])
|
||||
fields = {}
|
||||
for part in parts:
|
||||
match = re.match(r"^\s*([\w ]+):\s*([\s\S]*)$", part)
|
||||
if match:
|
||||
fields[match[1].strip()] = match[2].strip()
|
||||
return fields
|
||||
|
||||
|
||||
def _parameter_loras(fields: dict[str, str], result: GenerationMetadata) -> None:
|
||||
for key in ("positive", "negative"):
|
||||
result.values[key], entries = split_lora_tags(result.values[key])
|
||||
result.loras.extend(entries)
|
||||
try:
|
||||
hashes = json.loads(fields.get("Hashes", "{}"))
|
||||
resources = json.loads(fields.get("Civitai resources", "[]"))
|
||||
if not isinstance(hashes, dict) or not isinstance(resources, list):
|
||||
raise ValueError("Invalid resource containers")
|
||||
except (ValueError, TypeError) as exc:
|
||||
result.issues["loras"] = f"Malformed embedded resource metadata: {exc}"
|
||||
return
|
||||
names = [(key[5:], value) for key, value in hashes.items() if key.upper().startswith("LORA:")]
|
||||
weighted = [item for item in resources if isinstance(item, dict) and "weight" in item]
|
||||
result.resource_hints = [{"name": name, "hash": value} for name, value in names]
|
||||
if result.loras:
|
||||
if len(names) == 1 and len(weighted) == 1:
|
||||
strength = finite_number(weighted[0]["weight"])
|
||||
single = (names[0][0], strength, strength)
|
||||
if len(result.loras) > 1 and all(entry == single for entry in result.loras):
|
||||
result.loras = [single]
|
||||
result.notes.append("Repeated identical prompt tags collapsed to the single LoRA recorded in resource metadata.")
|
||||
return
|
||||
# Without a catalog there is no general mapping between a hash name and
|
||||
# a Civitai version ID. One name and one resource are unambiguous; multiple
|
||||
# resources must not be paired by their incidental JSON ordering.
|
||||
if len(names) == 1 and len(weighted) == 1:
|
||||
strength = finite_number(weighted[0]["weight"])
|
||||
result.loras.append((names[0][0], strength, strength))
|
||||
result.resource_hints[0].update(weighted[0])
|
||||
result.notes.append("LoRA name recovered from Hashes and its sole resource weight; separate CLIP strength was not saved, so model strength is used for both.")
|
||||
elif names or weighted:
|
||||
result.issues["loras"] = "LoRA resource names/weights cannot be paired unambiguously without a catalog; provide an explicit loras override"
|
||||
|
||||
|
||||
def parse_parameters(text: str) -> GenerationMetadata:
|
||||
match = re.search(r"^Steps:\s*\d+.*$", text, re.M)
|
||||
if not match:
|
||||
raise MetadataError("No supported A1111/Forge generation parameters found")
|
||||
prompt = text[:match.start()].strip()
|
||||
positive, separator, negative = prompt.partition("Negative prompt:")
|
||||
fields = _parameter_fields(text[match.start():])
|
||||
result = GenerationMetadata(notes=["A1111/Forge parameters."])
|
||||
result.values.update(positive=positive.strip(), negative=negative.strip() if separator else "")
|
||||
for output, key in {"seed": "Seed", "steps": "Steps", "cfg": "CFG scale", "sampler_name": "Sampler", "scheduler": "Schedule type", "checkpoint_name": "Model", "denoise": "Denoising strength"}.items():
|
||||
if key in fields:
|
||||
result.values[output] = fields[key].strip().strip('"')
|
||||
result.values.setdefault("denoise", 1.0)
|
||||
size = re.fullmatch(r"(\d+)x(\d+)", fields.get("Size", "").strip())
|
||||
if size:
|
||||
result.values.update(width=int(size[1]), height=int(size[2]))
|
||||
sampler = str(result.values.get("sampler_name", "")).lower().strip()
|
||||
for suffix, scheduler in (
|
||||
(" sgm uniform", "sgm_uniform"), (" sgm_uniform", "sgm_uniform"),
|
||||
(" karras", "karras"), (" exponential", "exponential"),
|
||||
(" simple", "simple"), ("_simple", "simple"),
|
||||
(" normal", "normal"), ("_normal", "normal"), ("_sgm_uniform", "sgm_uniform"),
|
||||
(" ddim uniform", "ddim_uniform"),
|
||||
(" beta", "beta"), (" linear quadratic", "linear_quadratic"),
|
||||
):
|
||||
if sampler.endswith(suffix):
|
||||
sampler = sampler[:-len(suffix)]
|
||||
result.values.setdefault("scheduler", scheduler)
|
||||
break
|
||||
result.values["sampler_name"] = SAMPLERS.get(sampler, sampler)
|
||||
if "scheduler" in result.values:
|
||||
result.values["scheduler"] = result.values["scheduler"].lower()
|
||||
if result.values["scheduler"] == "automatic":
|
||||
result.values.pop("scheduler")
|
||||
if "scheduler" not in result.values:
|
||||
result.issues["scheduler"] = "A1111 scheduler is unspecified/Automatic; choose an explicit ComfyUI scheduler"
|
||||
for key in ("Clip skip", "Hires upscale", "Hires steps", "Hires upscaler"):
|
||||
if key in fields:
|
||||
result.notes.append(f"Restore separately: {key}: {fields[key]}")
|
||||
_parameter_loras(fields, result)
|
||||
return result
|
||||
|
||||
|
||||
def inactive_workflow_nodes(workflow: dict[str, Any]) -> set[str]:
|
||||
"""Map muted/bypassed instances and nested nodes to API-qualified IDs."""
|
||||
inactive: set[str] = set()
|
||||
definitions = {str(item["id"]): item for item in workflow.get("definitions", {}).get("subgraphs", []) if isinstance(item, dict) and "id" in item}
|
||||
count = 0
|
||||
|
||||
def visit(container: dict[str, Any], prefix: str, ancestors: tuple[str, ...]) -> None:
|
||||
nonlocal count
|
||||
for node in container.get("nodes", []):
|
||||
count += 1
|
||||
if count > 10000 or len(ancestors) > 100:
|
||||
raise MetadataError("Workflow subgraph traversal limit exceeded")
|
||||
if not isinstance(node, dict) or "id" not in node:
|
||||
continue
|
||||
node_id = prefix + str(node["id"])
|
||||
if node.get("mode", 0) != 0:
|
||||
inactive.add(node_id)
|
||||
continue
|
||||
kind = node.get("type")
|
||||
if kind in definitions:
|
||||
if kind in ancestors:
|
||||
raise MetadataError("Cyclic workflow subgraph definition")
|
||||
visit(definitions[kind], node_id + ":", (*ancestors, kind))
|
||||
|
||||
visit(workflow, "", ())
|
||||
return inactive
|
||||
|
||||
|
||||
def extract_generation_metadata(
|
||||
fields: dict[str, Any], sampler_id: str = "", prefer_saved_image_metadata: bool = True,
|
||||
) -> GenerationMetadata:
|
||||
parameters = fields.get("parameters") or fields.get("comment")
|
||||
saved_text = isinstance(parameters, str) and bool(parameters.strip()) and not parameters.lstrip().startswith("{")
|
||||
recovery_notes = []
|
||||
if prefer_saved_image_metadata and saved_text:
|
||||
try:
|
||||
result = parse_parameters(parameters)
|
||||
result.notes.append("Source: saved image generation parameters (preferred).")
|
||||
if sampler_id.strip():
|
||||
result.notes.append("sampler_node_id is ignored while using saved image generation parameters.")
|
||||
return result
|
||||
except (ValueError, TypeError) as exc:
|
||||
recovery_notes.append(f"ERROR: Saved image metadata could not be parsed: {exc}; trying workflow metadata.")
|
||||
prompt = fields.get("prompt")
|
||||
workflow = _json_object(fields["workflow"]) if fields.get("workflow") else None
|
||||
if prompt:
|
||||
try:
|
||||
graph = _json_object(prompt)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise MetadataError(f"Malformed embedded prompt: {exc}") from exc
|
||||
result = GraphReader(graph, inactive_workflow_nodes(workflow) if workflow else None).read(sampler_id.strip())
|
||||
elif isinstance(parameters, str) and parameters.lstrip().startswith("{"):
|
||||
result = GraphReader(_json_object(parameters), inactive_workflow_nodes(workflow) if workflow else None).read(sampler_id.strip())
|
||||
elif workflow:
|
||||
result = GraphReader(workflow_to_prompt(workflow)).read(sampler_id.strip())
|
||||
result.notes.insert(0, "UI workflow fallback: only known core widget layouts are supported; saved widget values may differ from executed values.")
|
||||
elif saved_text:
|
||||
result = parse_parameters(parameters)
|
||||
result.notes.append("Source: saved image generation parameters; no workflow metadata available.")
|
||||
else:
|
||||
raise MetadataError("Image contains no supported generation metadata")
|
||||
result.notes.extend(recovery_notes)
|
||||
return result
|
||||
|
||||
|
||||
def workflow_to_prompt(workflow: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Decode only known core widget layouts; preserve links to unknown nodes."""
|
||||
nodes = workflow.get("nodes")
|
||||
links = workflow.get("links", [])
|
||||
if not isinstance(nodes, list) or not isinstance(links, list) or len(nodes) > 10000:
|
||||
raise MetadataError("Malformed or excessively large UI workflow")
|
||||
link_map = {}
|
||||
for link in links:
|
||||
if isinstance(link, list) and len(link) >= 5:
|
||||
link_map[str(link[0])] = [str(link[1]), link[2]]
|
||||
layouts = {
|
||||
"CheckpointLoaderSimple": ["ckpt_name"],
|
||||
"UNETLoader": ["unet_name", "weight_dtype"],
|
||||
"LoraLoader": ["lora_name", "strength_model", "strength_clip"],
|
||||
"LoraLoaderModelOnly": ["lora_name", "strength_model"],
|
||||
"CLIPTextEncode": ["text"],
|
||||
"EmptyLatentImage": ["width", "height", "batch_size"],
|
||||
"EmptySD3LatentImage": ["width", "height", "batch_size"],
|
||||
"KSampler": ["seed", "control_after_generate", "steps", "cfg", "sampler_name", "scheduler", "denoise"],
|
||||
"PrimitiveNode": ["value"],
|
||||
"PrimitiveInt": ["value"], "PrimitiveFloat": ["value"],
|
||||
"PrimitiveString": ["value"], "PrimitiveStringMultiline": ["value"],
|
||||
}
|
||||
graph = {}
|
||||
for node in nodes:
|
||||
if not isinstance(node, dict) or "id" not in node:
|
||||
raise MetadataError("Malformed workflow node")
|
||||
kind = node.get("type", "")
|
||||
widgets = node.get("widgets_values", [])
|
||||
inputs = {}
|
||||
layout = layouts.get(kind)
|
||||
if node.get("mode", 0) != 0:
|
||||
kind = "Unsupported muted/bypassed " + kind
|
||||
elif layout is not None:
|
||||
if not isinstance(widgets, list):
|
||||
raise MetadataError(f"Unsupported widget layout for {kind}")
|
||||
if kind == "KSampler" and len(widgets) == 6:
|
||||
layout = [key for key in layout if key != "control_after_generate"]
|
||||
for key, value in zip(layout, widgets):
|
||||
inputs[key] = value
|
||||
for slot in node.get("inputs", []):
|
||||
if not isinstance(slot, dict) or not isinstance(slot.get("name"), str):
|
||||
raise MetadataError("Malformed workflow input")
|
||||
if slot.get("link") is not None:
|
||||
inputs[slot["name"]] = link_map.get(str(slot["link"]), ["missing", 0])
|
||||
graph[str(node["id"])] = {"class_type": kind, "inputs": inputs}
|
||||
return graph
|
||||
@@ -114,6 +114,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"):
|
||||
|
||||
Reference in New Issue
Block a user