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:
Martial Michel
2026-09-22 22:18:03 -04:00
parent 521531111a
commit e9aff35957
11 changed files with 1966 additions and 1 deletions
+430
View File
@@ -0,0 +1,430 @@
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]
@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
+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)
+19
View File
@@ -427,3 +427,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", []
)