Files

588 lines
22 KiB
Python

import json
from smart_metadata_reader.graph import GraphIndex
from smart_metadata_reader.metadata_reader import parse_metadata_bundle
from smart_metadata_reader.models import MetadataBundle
from smart_metadata_reader.prompt_merge import merge_prompt_segments
def resolver_for(prompt, max_depth=40):
from smart_metadata_reader.resolver import ConditioningResolver
graph = GraphIndex(prompt=prompt, workflow=None)
sampler = graph.get_node("1")
resolver = ConditioningResolver(
graph=graph,
max_depth=max_depth,
prefer_cached_text=True,
)
return resolver, sampler
def segment_texts(segments):
return [segment.text for segment in segments]
def test_resolves_simple_ksampler_positive_to_clip_text():
prompt = {
"1": {"class_type": "KSampler", "inputs": {"positive": ["2", 0]}},
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": "simple positive"}},
}
resolver, sampler = resolver_for(prompt)
assert segment_texts(resolver.resolve_positive(sampler)) == ["simple positive"]
def test_resolves_controlnet_apply_passthrough():
prompt = {
"1": {"class_type": "KSampler", "inputs": {"positive": ["3", 0]}},
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": "controlnet positive"}},
"3": {
"class_type": "ControlNetApply",
"inputs": {"conditioning": ["2", 0]},
},
}
resolver, sampler = resolver_for(prompt)
assert segment_texts(resolver.resolve_positive(sampler)) == ["controlnet positive"]
def test_resolves_controlnet_apply_advanced_passthrough():
prompt = {
"1": {"class_type": "KSampler", "inputs": {"positive": ["3", 0]}},
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": "advanced positive"}},
"3": {
"class_type": "ControlNetApplyAdvanced",
"inputs": {"positive": ["2", 0]},
},
}
resolver, sampler = resolver_for(prompt)
assert segment_texts(resolver.resolve_positive(sampler)) == ["advanced positive"]
def test_resolves_cr_apply_controlnet_conditioning_passthrough_without_parameters():
prompt = {
"1": {"class_type": "KSampler", "inputs": {"positive": ["150", 0]}},
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": "real positive prompt"}},
"3": {"class_type": "ControlNetLoader", "inputs": {"control_net_name": "control_net"}},
"150": {
"class_type": "CR Apply ControlNet",
"inputs": {
"conditioning": ["2", 0],
"image": "control_image.png",
"strength": 0.3,
"control_net": ["3", 0],
},
},
}
resolver, sampler = resolver_for(prompt)
segments = resolver.resolve_positive(sampler)
positive = merge_prompt_segments(segments)
assert positive == "real positive prompt"
assert "control_image.png" not in positive
assert "0.3" not in positive
assert "control_net" not in positive
assert "CR Apply ControlNet 150" in resolver.debug_trace
def test_resolves_cr_apply_controlnet_complex_upstream_text_chain():
prompt = {
"1": {"class_type": "KSampler", "inputs": {"positive": ["150", 0]}},
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": ["3", 0]}},
"3": {
"class_type": "StringFunction|pysssss",
"inputs": {
"action": "append",
"text_a": "role_lora_trigger, character_name,",
"text_b": ["4", 0],
"text_c": ["5", 0],
},
},
"4": {
"class_type": "ShowText|pysssss",
"inputs": {"text_0": "1girl, solo, stage lights"},
},
"5": {
"class_type": "DeepTranslatorTextNode",
"inputs": {"text": "nude, huge breasts"},
},
"150": {
"class_type": "CR Apply ControlNet",
"inputs": {
"conditioning": ["2", 0],
"image": "control_image.png",
"strength": 0.3,
},
},
}
resolver, sampler = resolver_for(prompt)
positive = merge_prompt_segments(resolver.resolve_positive(sampler))
assert "role_lora_trigger" in positive
assert "character_name" in positive
assert "1girl, solo, stage lights" in positive
assert "nude, huge breasts" in positive
assert "control_image.png" not in positive
assert "0.3" not in positive
def test_resolves_inpaint_model_conditioning_passthrough():
prompt = {
"1": {"class_type": "KSampler", "inputs": {"positive": ["3", 0]}},
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": "inpaint positive"}},
"3": {
"class_type": "InpaintModelConditioning",
"inputs": {"positive": ["2", 0]},
},
}
resolver, sampler = resolver_for(prompt)
assert segment_texts(resolver.resolve_positive(sampler)) == ["inpaint positive"]
def test_conditioning_combine_merges_both_branches():
prompt = {
"1": {"class_type": "KSampler", "inputs": {"positive": ["4", 0]}},
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": "first branch"}},
"3": {"class_type": "CLIPTextEncode", "inputs": {"text": "second branch"}},
"4": {
"class_type": "ConditioningCombine",
"inputs": {"conditioning_1": ["2", 0], "conditioning_2": ["3", 0]},
},
}
resolver, sampler = resolver_for(prompt)
segments = resolver.resolve_positive(sampler)
assert segment_texts(segments) == ["first branch", "second branch"]
assert merge_prompt_segments(segments) == "first branch, second branch"
def test_conditioning_concat_merges_multiple_clip_text_nodes_into_positive():
prompt = {
"1": {"class_type": "KSampler", "inputs": {"positive": ["4", 0]}},
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": "subject tags"}},
"3": {"class_type": "CLIPTextEncode", "inputs": {"text": "style tags"}},
"4": {
"class_type": "ConditioningConcat",
"inputs": {"conditioning_to": ["2", 0], "conditioning_from": ["3", 0]},
},
}
resolver, sampler = resolver_for(prompt)
assert segment_texts(resolver.resolve_positive(sampler)) == [
"subject tags",
"style tags",
]
def test_resolves_negative_chain_separately():
prompt = {
"1": {
"class_type": "KSampler",
"inputs": {"positive": ["2", 0], "negative": ["3", 0]},
},
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": "positive text"}},
"3": {"class_type": "CLIPTextEncode", "inputs": {"text": "negative text"}},
}
resolver, sampler = resolver_for(prompt)
assert segment_texts(resolver.resolve_negative(sampler)) == ["negative text"]
def test_unknown_conditioning_node_returns_unresolved_without_prompt_text():
prompt = {
"1": {"class_type": "KSampler", "inputs": {"positive": ["9", 0]}},
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": "should not guess"}},
"9": {
"class_type": "UnknownConditioningRouter",
"inputs": {"mystery": ["2", 0]},
},
}
resolver, sampler = resolver_for(prompt)
assert resolver.resolve_positive(sampler) == []
assert resolver.unresolved[0]["node_id"] == "9"
assert resolver.unresolved[0]["class_type"] == "UnknownConditioningRouter"
assert resolver.unresolved[0]["field"] == "mystery"
assert resolver.unresolved[0]["role"] == "positive"
assert "UnknownConditioningRouter 9" in resolver.debug_trace
def test_cycle_guard_prevents_infinite_recursion():
prompt = {
"1": {"class_type": "KSampler", "inputs": {"positive": ["4", 0]}},
"4": {"class_type": "ControlNetApply", "inputs": {"conditioning": ["5", 0]}},
"5": {"class_type": "ControlNetApply", "inputs": {"conditioning": ["4", 0]}},
}
resolver, sampler = resolver_for(prompt, max_depth=10)
assert resolver.resolve_positive(sampler) == []
assert resolver.unresolved[0]["reason"] == "cycle"
assert resolver.unresolved[0]["role"] == "positive"
def bundle_for_prompt(prompt, workflow=None, filename="sample.png", width=1200, height=1600):
return MetadataBundle(
filename=filename,
width=width,
height=height,
prompt_raw=json.dumps(prompt),
workflow_raw=json.dumps(workflow) if workflow is not None else None,
parameters_raw=None,
source_format="ComfyUI prompt/workflow",
)
def test_parse_metadata_bundle_resolves_full_comfyui_graph_end_to_end():
prompt = {
"1": {"class_type": "SaveImage", "inputs": {"images": ["2", 0]}},
"2": {
"class_type": "VAEDecode",
"inputs": {"samples": ["3", 0], "vae": ["4", 0]},
},
"3": {
"class_type": "KSampler",
"inputs": {
"model": ["6", 0],
"clip": ["6", 1],
"positive": ["10", 0],
"negative": ["20", 0],
"seed": 123456789,
"steps": 30,
"cfg": 7.0,
"sampler_name": "euler",
"scheduler": "normal",
"denoise": 1.0,
},
},
"4": {"class_type": "VAELoader", "inputs": {"vae_name": "final.vae.safetensors"}},
"5": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "anime_model.safetensors"},
},
"6": {
"class_type": "LoraLoader",
"inputs": {
"model": ["5", 0],
"clip": ["5", 1],
"lora_name": "role_lora.safetensors",
"strength_model": 0.8,
"strength_clip": 1.0,
},
},
"10": {
"class_type": "ControlNetApply",
"inputs": {
"conditioning": ["11", 0],
"image": "control image filename should not enter prompt",
"strength": 0.7,
"control_net": "control_v11p_sd15",
},
},
"11": {"class_type": "CLIPTextEncode", "inputs": {"text": ["12", 0]}},
"12": {
"class_type": "StringFunction|pysssss",
"inputs": {
"action": "append",
"text_a": "role_lora_trigger, character_name, NSFW",
"text_b": ["13", 0],
"text_c": ["14", 0],
},
},
"13": {
"class_type": "ShowText|pysssss",
"inputs": {
"text": ["15", 0],
"text_0": "1girl, solo, squatting, stage lights, anime style",
},
},
"14": {
"class_type": "DeepTranslatorTextNode",
"inputs": {"text": "translated supplement, dramatic lighting"},
},
"15": {
"class_type": "GeminiChatNode",
"inputs": {
"system_instruction": "You are an anime image prompt extraction assistant",
"prompt": "Analyze this image and convert it into a prompt template",
},
},
"20": {"class_type": "CLIPTextEncode", "inputs": {"text": ["21", 0]}},
"21": {
"class_type": "StringFunction|pysssss",
"inputs": {"action": "append", "text_a": "bad hands, low quality"},
},
"50": {
"class_type": "GeminiChatNode",
"inputs": {"prompt": "unconnected prompt template must be ignored"},
},
"51": {
"class_type": "PrimitiveString",
"inputs": {"value": "unconnected primitive text must be ignored"},
},
}
result = parse_metadata_bundle(bundle_for_prompt(prompt))
assert "role_lora_trigger" in result.positive
assert "character_name" in result.positive
assert "NSFW" in result.positive
assert "1girl, solo, squatting, stage lights, anime style" in result.positive
assert "translated supplement, dramatic lighting" in result.positive
assert result.negative == "bad hands, low quality"
assert "Analyze this image" not in result.positive
assert "control_v11p_sd15" not in result.positive
assert "control image filename" not in result.positive
assert "unconnected prompt template" not in result.positive
assert "unconnected primitive text" not in result.positive
assert result.seed == 123456789
assert result.steps == 30
assert result.cfg == 7.0
assert result.sampler_name == "euler"
assert result.scheduler == "normal"
assert result.model_name == "anime_model.safetensors"
assert result.vae_name == "final.vae.safetensors"
assert [lora.lora_name for lora in result.loras] == ["role_lora.safetensors"]
assert result.status_message == "OK"
assert result.confidence >= 0.9
assert "Seed: 123456789" in result.setting
assert "Model: anime_model.safetensors" in result.setting
assert "SAMPLER_SELECTION" in result.debug_trace
assert "POSITIVE_TRACE" in result.debug_trace
assert "NEGATIVE_TRACE" in result.debug_trace
def test_parse_metadata_bundle_uses_a1111_parameters_when_comfyui_graph_cannot_start():
bundle = MetadataBundle(
filename="a1111.png",
width=0,
height=0,
prompt_raw=None,
workflow_raw=None,
parameters_raw=(
"role_lora_trigger, NSFW, cinematic lighting\n"
"Negative prompt: low quality, bad hands\n"
"Steps: 25, Sampler: Euler a, CFG scale: 7.5, "
"Seed: 12345, Size: 768x1024, Model: demo_model"
),
source_format="A1111 parameters",
)
result = parse_metadata_bundle(bundle)
assert result.positive == "role_lora_trigger, NSFW, cinematic lighting"
assert result.negative == "low quality, bad hands"
assert result.seed == 12345
assert result.steps == 25
assert result.cfg == 7.5
assert result.width == 768
assert result.height == 1024
assert result.model_name == "demo_model"
assert result.status_message == "A1111 parameters fallback"
assert "Source: A1111 parameters" in result.setting
def test_parse_metadata_bundle_omits_raw_json_when_requested_but_still_parses():
prompt = {
"1": {"class_type": "SaveImage", "inputs": {"images": ["2", 0]}},
"2": {"class_type": "VAEDecode", "inputs": {"samples": ["3", 0]}},
"3": {
"class_type": "KSampler",
"inputs": {"positive": ["4", 0], "negative": ["5", 0]},
},
"4": {"class_type": "CLIPTextEncode", "inputs": {"text": "visible positive"}},
"5": {"class_type": "CLIPTextEncode", "inputs": {"text": "visible negative"}},
}
result = parse_metadata_bundle(bundle_for_prompt(prompt), include_raw_json=False)
assert result.positive == "visible positive"
assert result.negative == "visible negative"
assert result.raw_prompt_json == ""
assert result.raw_workflow_json == ""
def test_parse_metadata_bundle_keeps_partial_result_for_unknown_conditioning_node():
prompt = {
"1": {"class_type": "SaveImage", "inputs": {"images": ["2", 0]}},
"2": {"class_type": "VAEDecode", "inputs": {"samples": ["3", 0]}},
"3": {
"class_type": "KSampler",
"inputs": {"positive": ["9", 0], "negative": ["5", 0]},
},
"4": {"class_type": "CLIPTextEncode", "inputs": {"text": "do not guess"}},
"5": {"class_type": "CLIPTextEncode", "inputs": {"text": "negative ok"}},
"9": {
"class_type": "UnknownConditioningRouter",
"inputs": {"positive_conditioning": ["4", 0]},
},
"10": {
"class_type": "PrimitiveString",
"inputs": {"value": "nearby text must not be scanned"},
},
}
result = parse_metadata_bundle(bundle_for_prompt(prompt))
assert result.positive == ""
assert result.negative == "negative ok"
assert "do not guess" not in result.positive
assert "nearby text" not in result.positive
assert result.status_message == "PARTIAL"
assert result.confidence <= 0.6
assert result.partial_result["unresolved"][0]["node_id"] == "9"
assert "Unresolved:" in result.setting
def test_parse_metadata_bundle_ignores_unconnected_text_and_llm_nodes():
prompt = {
"1": {"class_type": "SaveImage", "inputs": {"images": ["2", 0]}},
"2": {"class_type": "VAEDecode", "inputs": {"samples": ["3", 0]}},
"3": {
"class_type": "KSampler",
"inputs": {"positive": ["4", 0], "negative": ["5", 0]},
},
"4": {"class_type": "CLIPTextEncode", "inputs": {"text": "real prompt"}},
"5": {"class_type": "CLIPTextEncode", "inputs": {"text": ""}},
"6": {
"class_type": "GeminiChatNode",
"inputs": {
"system_instruction": "unconnected system prompt",
"prompt": "unconnected user prompt template",
},
},
"7": {"class_type": "PrimitiveString", "inputs": {"value": "unconnected text"}},
}
result = parse_metadata_bundle(bundle_for_prompt(prompt))
assert result.positive == "real prompt"
assert "unconnected system prompt" not in result.positive
assert "unconnected user prompt template" not in result.positive
assert "unconnected text" not in result.positive
def test_parse_metadata_bundle_controlnet_parameters_do_not_enter_prompt():
prompt = {
"1": {"class_type": "SaveImage", "inputs": {"images": ["2", 0]}},
"2": {"class_type": "VAEDecode", "inputs": {"samples": ["3", 0]}},
"3": {
"class_type": "KSampler",
"inputs": {"positive": ["6", 0], "negative": ["5", 0]},
},
"4": {"class_type": "CLIPTextEncode", "inputs": {"text": "real control prompt"}},
"5": {"class_type": "CLIPTextEncode", "inputs": {"text": ""}},
"6": {
"class_type": "ControlNetApply",
"inputs": {
"conditioning": ["4", 0],
"image": "pose_control_image.png",
"strength": 0.55,
"control_net": "controlnet-depth.safetensors",
},
},
}
result = parse_metadata_bundle(bundle_for_prompt(prompt))
assert result.positive == "real control prompt"
assert "pose_control_image" not in result.positive
assert "0.55" not in result.positive
assert "controlnet-depth" not in result.positive
def test_parse_metadata_bundle_conditioning_combine_merges_both_branches():
prompt = {
"1": {"class_type": "SaveImage", "inputs": {"images": ["2", 0]}},
"2": {"class_type": "VAEDecode", "inputs": {"samples": ["3", 0]}},
"3": {
"class_type": "KSampler",
"inputs": {"positive": ["8", 0], "negative": ["7", 0]},
},
"4": {"class_type": "CLIPTextEncode", "inputs": {"text": "branch alpha"}},
"5": {"class_type": "CLIPTextEncode", "inputs": {"text": "branch beta"}},
"7": {"class_type": "CLIPTextEncode", "inputs": {"text": ""}},
"8": {
"class_type": "ConditioningCombine",
"inputs": {"conditioning_1": ["4", 0], "conditioning_2": ["5", 0]},
},
}
result = parse_metadata_bundle(bundle_for_prompt(prompt))
assert result.positive == "branch alpha, branch beta"
def test_llm_template_upstream_of_showtext_without_cache_is_not_used():
prompt = {
"1": {"class_type": "SaveImage", "inputs": {"images": ["2", 0]}},
"2": {"class_type": "VAEDecode", "inputs": {"samples": ["3", 0]}},
"3": {
"class_type": "KSampler",
"inputs": {"positive": ["4", 0], "negative": ["7", 0]},
},
"4": {"class_type": "CLIPTextEncode", "inputs": {"text": ["5", 0]}},
"5": {"class_type": "ShowText|pysssss", "inputs": {"text": ["6", 0]}},
"6": {
"class_type": "GeminiChatNode",
"inputs": {
"system_instruction": "You are an anime image prompt extraction assistant",
"prompt": "Analyze this image and convert it into a final prompt",
},
},
"7": {"class_type": "CLIPTextEncode", "inputs": {"text": ""}},
}
result = parse_metadata_bundle(bundle_for_prompt(prompt))
assert "You are an anime image prompt extraction assistant" not in result.positive
assert "Analyze this image" not in result.positive
assert result.positive == ""
assert "LLM template input skipped, no cached generated output found" in result.debug_trace
def test_showtext_text_0_cache_beats_upstream_llm_template():
prompt = {
"1": {"class_type": "SaveImage", "inputs": {"images": ["2", 0]}},
"2": {"class_type": "VAEDecode", "inputs": {"samples": ["3", 0]}},
"3": {
"class_type": "KSampler",
"inputs": {"positive": ["4", 0], "negative": ["7", 0]},
},
"4": {"class_type": "CLIPTextEncode", "inputs": {"text": ["5", 0]}},
"5": {
"class_type": "ShowText|pysssss",
"inputs": {
"text": ["6", 0],
"text_0": "1girl, solo, squatting, stage lights, anime style",
},
},
"6": {
"class_type": "GeminiChatNode",
"inputs": {
"system_instruction": "system prompt template should stay hidden",
"prompt": "user prompt template should stay hidden",
},
},
"7": {"class_type": "CLIPTextEncode", "inputs": {"text": ""}},
}
result = parse_metadata_bundle(bundle_for_prompt(prompt))
assert result.positive == "1girl, solo, squatting, stage lights, anime style"
assert "system prompt template" not in result.positive
assert "user prompt template" not in result.positive