Files
ChrisandClaude Opus 4.8 e23137d1f9 feat: detect rgthree Power Lora Loader + Lora Loader Stack
The Save Metadata (Civitai) graph trace only recognized ComfyUI's stock
LoraLoader/LoraLoaderModelOnly, so LoRAs loaded via rgthree's multi-LoRA
nodes were silently missing from the written metadata and the preview.

- tracer.py: harvest LoRAs from "Power Lora Loader (rgthree)" (lora_N dicts)
  and "Lora Loader Stack (rgthree)" (flat lora_0N/strength_0N pairs), several
  per node, in load order. Skip disabled, empty ("None") and zero-strength
  slots to match what actually loads. Stock loaders still work.
- save_civitai_metadata.js: mirror the same detection in the live-graph
  Test-detection preview (kept in sync with the Python tracer).
- Preview warning now distinguishes positive/negative (may be resolved at run
  time by the AI2Go Prompt Batch node) from genuinely-unresolved fields.
- Tests for both rgthree loaders (multiple loras, ordering, skipped slots).
- README: document the supported LoRA loaders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:07:08 +02:00

124 lines
6.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Tests for tracer model/LoRA chain — part of ComfyUI-AI2Go-Utils. GPL-3.0.
from nodes.civitai_metadata.tracer import trace
def _base():
return {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "sub/myCkpt.safetensors"}},
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": "pos", "clip": ["1", 1]}},
"3": {"class_type": "CLIPTextEncode", "inputs": {"text": "neg", "clip": ["1", 1]}},
"4": {"class_type": "KSampler", "inputs": {"model": ["1", 0], "positive": ["2", 0],
"negative": ["3", 0], "seed": 1, "steps": 20, "cfg": 7,
"sampler_name": "euler", "scheduler": "normal"}},
"6": {"class_type": "VAEDecode", "inputs": {"samples": ["4", 0]}},
"7": {"class_type": "AI2GoSaveCivitaiMetadata", "inputs": {"images": ["6", 0]}},
}
def test_no_lora_checkpoint_name():
r = trace(_base(), "7")
assert r.model_name == "myCkpt" and r.model_file == "sub/myCkpt.safetensors"
assert r.loras == []
def test_lora_chain_in_load_order():
p = _base()
p["8"] = {"class_type": "LoraLoader", "inputs": {"model": ["1", 0], "clip": ["1", 1],
"lora_name": "A.safetensors", "strength_model": 0.8, "strength_clip": 0.8}}
p["9"] = {"class_type": "LoraLoader", "inputs": {"model": ["8", 0], "clip": ["8", 1],
"lora_name": "B.safetensors", "strength_model": 0.5, "strength_clip": 0.5}}
p["4"]["inputs"]["model"] = ["9", 0] # sampler <- B <- A <- checkpoint
r = trace(p, "7")
assert r.model_name == "myCkpt"
assert [(lo["name"], lo["strength"], lo["file"]) for lo in r.loras] == [
("A", 0.8, "A.safetensors"), ("B", 0.5, "B.safetensors")]
def test_model_only_lora_and_passthrough():
p = _base()
p["8"] = {"class_type": "ModelSamplingDiscrete", "inputs": {"model": ["10", 0], "sampling": "eps"}}
p["10"] = {"class_type": "LoraLoaderModelOnly", "inputs": {"model": ["1", 0],
"lora_name": "C.safetensors", "strength_model": 1.0}}
p["4"]["inputs"]["model"] = ["8", 0] # sampler <- passthrough <- C <- checkpoint
r = trace(p, "7")
assert [lo["name"] for lo in r.loras] == ["C"]
assert r.model_name == "myCkpt"
def test_checkpoint_folder_is_checkpoints():
r = trace(_base(), "7")
assert r.model_folder == "checkpoints"
def test_unet_loader_diffusion_model():
# Flux/Krea-style: model comes from a Load Diffusion Model (UNETLoader), not a checkpoint.
p = _base()
del p["1"]["inputs"]["ckpt_name"]
p["1"] = {"class_type": "UNETLoader", "inputs": {"unet_name": "flux/krea-turbo.safetensors",
"weight_dtype": "default"}}
r = trace(p, "7")
assert r.model_name == "krea-turbo"
assert r.model_file == "flux/krea-turbo.safetensors"
assert r.model_folder == "diffusion_models"
def test_power_lora_loader_rgthree_multiple():
# rgthree Power Lora Loader packs several loras into one node as lora_N dicts.
p = _base()
p["8"] = {"class_type": "Power Lora Loader (rgthree)", "inputs": {
"model": ["1", 0], "clip": ["1", 1],
"PowerLoraLoaderHeaderWidget": {"type": "PowerLoraLoaderHeaderWidget"},
"lora_1": {"on": True, "lora": "styleA.safetensors", "strength": 0.8, "strengthTwo": None},
"lora_2": {"on": True, "lora": "sub/styleB.safetensors", "strength": 0.5, "strengthTwo": None},
"lora_3": {"on": False, "lora": "disabledLora.safetensors", "strength": 1.0, "strengthTwo": None},
"lora_4": {"on": True, "lora": "None", "strength": 1.0, "strengthTwo": None},
"➕ Add Lora": ""}}
p["4"]["inputs"]["model"] = ["8", 0] # sampler <- PowerLora <- checkpoint
r = trace(p, "7")
assert r.model_name == "myCkpt"
# enabled real loras only (disabled + empty "None" slot skipped), in listed order.
assert [(lo["name"], lo["strength"], lo["file"]) for lo in r.loras] == [
("styleA", 0.8, "styleA.safetensors"), ("styleB", 0.5, "sub/styleB.safetensors")]
def test_power_lora_loader_mixed_with_standard_load_order():
# PowerLora(A,B) closest to sampler, a standard LoraLoader(C) closest to the checkpoint.
p = _base()
p["8"] = {"class_type": "LoraLoader", "inputs": {"model": ["1", 0], "clip": ["1", 1],
"lora_name": "C.safetensors", "strength_model": 1.0, "strength_clip": 1.0}}
p["9"] = {"class_type": "Power Lora Loader (rgthree)", "inputs": {
"model": ["8", 0], "clip": ["8", 1],
"lora_1": {"on": True, "lora": "A.safetensors", "strength": 0.8},
"lora_2": {"on": True, "lora": "B.safetensors", "strength": 0.6}}}
p["4"]["inputs"]["model"] = ["9", 0] # sampler <- PowerLora(A,B) <- LoraLoader(C) <- ckpt
r = trace(p, "7")
# load order = nearest checkpoint first: C, then the power loader's A, B.
assert [lo["name"] for lo in r.loras] == ["C", "A", "B"]
def test_lora_loader_stack_rgthree():
# rgthree Lora Loader Stack: flat lora_0N / strength_0N pairs.
p = _base()
p["8"] = {"class_type": "Lora Loader Stack (rgthree)", "inputs": {
"model": ["1", 0], "clip": ["1", 1],
"lora_01": "A.safetensors", "strength_01": 0.8,
"lora_02": "sub/B.safetensors", "strength_02": 0.5,
"lora_03": "None", "strength_03": 1.0, # empty slot skipped
"lora_04": "zero.safetensors", "strength_04": 0}} # zero strength skipped (rgthree won't load it)
p["4"]["inputs"]["model"] = ["8", 0]
r = trace(p, "7")
assert [(lo["name"], lo["strength"], lo["file"]) for lo in r.loras] == [
("A", 0.8, "A.safetensors"), ("B", 0.5, "sub/B.safetensors")]
def test_unet_loader_with_model_only_lora():
# sampler <- LoraLoaderModelOnly <- UNETLoader (common Flux LoRA wiring)
p = _base()
p["1"] = {"class_type": "UNETLoader", "inputs": {"unet_name": "krea.safetensors", "weight_dtype": "default"}}
p["8"] = {"class_type": "LoraLoaderModelOnly", "inputs": {"model": ["1", 0],
"lora_name": "styleFlux.safetensors", "strength_model": 0.9}}
p["4"]["inputs"]["model"] = ["8", 0]
r = trace(p, "7")
assert r.model_name == "krea" and r.model_folder == "diffusion_models"
assert [lo["name"] for lo in r.loras] == ["styleFlux"]