Merge pull request #1121 from mmartial/loader

Add Load Image Metadata node for reusing generation settings
This commit is contained in:
pixelpaws
2026-09-23 20:31:44 +08:00
committed by GitHub
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
@@ -562,3 +562,22 @@ def test_get_lora_info_not_found_returns_original(mock_lora_scanner):
assert path == "nonexistent"
assert triggers == []
def test_get_lora_info_absolute_preserves_exact_stack_path(mock_lora_scanner):
mock_lora_scanner([
{"file_name": "same", "folder": "a", "file_path": "/models/a/same.safetensors", "civitai": {"trainedWords": ["wrong"]}},
{"file_name": "same", "folder": "b", "file_path": "/models/b/same.safetensors", "civitai": {"trainedWords": ["right"]}},
])
assert get_lora_info_absolute("/models/b/same.safetensors") == (
"/models/b/same.safetensors", ["right"]
)
def test_get_lora_info_absolute_does_not_substitute_missing_absolute_path(mock_lora_scanner):
mock_lora_scanner([
{"file_name": "same", "folder": "a", "file_path": "/models/a/same.safetensors"},
])
assert get_lora_info_absolute("/models/missing/same.safetensors") == (
"/models/missing/same.safetensors", []
)