feat(inspect): support common text encoders and string sources
This commit is contained in:
Vendored
+1
-1
@@ -15,7 +15,7 @@ Browser tests use the committed file; FFmpeg is not required to run them.
|
||||
|
||||
# Inspection parity fixtures
|
||||
|
||||
`inspection_parity/` holds small synthetic PNGs (`f01b.png` ... `f23.png`, each
|
||||
`inspection_parity/` holds small synthetic PNGs (`f01b.png` ... `f24.png`, each
|
||||
under 32 KiB) and `vectors.json`, used by the #240 inspection parity tests. Every
|
||||
graph, prompt text, model name and filename prefix in them is original synthetic
|
||||
content written for this repository. Nothing comes from user output. They are
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 989 B |
+187
-10
@@ -258,12 +258,12 @@
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported node semantics or output slot",
|
||||
"reason_code": "unsupported-node",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "blurry, lowres",
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
@@ -300,12 +300,12 @@
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported node semantics or output slot",
|
||||
"reason_code": "unsupported-node",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "blurry, lowres",
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
@@ -1007,6 +1007,90 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f21-inspection",
|
||||
"fixture": "f21",
|
||||
"profile": "inspection",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Text is decided at run time",
|
||||
"reason_code": "runtime-value",
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
"status": "known",
|
||||
"value": "42"
|
||||
},
|
||||
"steps": {
|
||||
"status": "known",
|
||||
"value": "25"
|
||||
},
|
||||
"cfg": {
|
||||
"status": "known",
|
||||
"value": "6.5"
|
||||
},
|
||||
"sampler": {
|
||||
"status": "known",
|
||||
"value": "euler"
|
||||
},
|
||||
"scheduler": {
|
||||
"status": "known",
|
||||
"value": "normal"
|
||||
},
|
||||
"model": {
|
||||
"status": "known",
|
||||
"value": "sdxl_base.safetensors"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f22-inspection",
|
||||
"fixture": "f22",
|
||||
"profile": "inspection",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Text is decided at run time",
|
||||
"reason_code": "runtime-value",
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
"status": "known",
|
||||
"value": "42"
|
||||
},
|
||||
"steps": {
|
||||
"status": "known",
|
||||
"value": "25"
|
||||
},
|
||||
"cfg": {
|
||||
"status": "known",
|
||||
"value": "6.5"
|
||||
},
|
||||
"sampler": {
|
||||
"status": "known",
|
||||
"value": "euler"
|
||||
},
|
||||
"scheduler": {
|
||||
"status": "known",
|
||||
"value": "normal"
|
||||
},
|
||||
"model": {
|
||||
"status": "known",
|
||||
"value": "sdxl_base.safetensors"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f23-inspection",
|
||||
"fixture": "f23",
|
||||
@@ -1049,6 +1133,48 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f24-inspection",
|
||||
"fixture": "f24",
|
||||
"profile": "inspection",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "blurry, lowres",
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
"status": "known",
|
||||
"value": "42"
|
||||
},
|
||||
"steps": {
|
||||
"status": "known",
|
||||
"value": "25"
|
||||
},
|
||||
"cfg": {
|
||||
"status": "known",
|
||||
"value": "6.5"
|
||||
},
|
||||
"sampler": {
|
||||
"status": "known",
|
||||
"value": "euler"
|
||||
},
|
||||
"scheduler": {
|
||||
"status": "known",
|
||||
"value": "normal"
|
||||
},
|
||||
"model": {
|
||||
"status": "known",
|
||||
"value": "sdxl_base.safetensors"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f01-default",
|
||||
"fixture": "f01",
|
||||
@@ -1457,6 +1583,40 @@
|
||||
"facts": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f21-default",
|
||||
"fixture": "f21",
|
||||
"profile": "default",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported node semantics or output slot",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"candidate_count": 0,
|
||||
"facts": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f22-default",
|
||||
"fixture": "f22",
|
||||
"profile": "default",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported node semantics or output slot",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"candidate_count": 0,
|
||||
"facts": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f23-default",
|
||||
"fixture": "f23",
|
||||
@@ -1473,5 +1633,22 @@
|
||||
"candidate_count": 0,
|
||||
"facts": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f24-default",
|
||||
"fixture": "f24",
|
||||
"profile": "default",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported node semantics or output slot",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"candidate_count": 0,
|
||||
"facts": null
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Every graph here is original synthetic content (CC0): neutral texts, generic model
|
||||
file names and invented filename prefixes. Nothing is copied from user outputs.
|
||||
The shapes reproduce the parity audit's fixture numbering (``f01``..``f23``).
|
||||
The shapes reproduce the parity audit's fixture numbering (``f01``..``f24``).
|
||||
|
||||
``build(name)`` returns ``{"prompt", "workflow", "extra_chunks"}``. ``write_png``
|
||||
writes the two records the way core ``SaveImage`` does (Pillow ``PngInfo.add_text``,
|
||||
@@ -68,7 +68,10 @@ COMMITTED = (
|
||||
"f17",
|
||||
"f19",
|
||||
"f20",
|
||||
"f21",
|
||||
"f22",
|
||||
"f23",
|
||||
"f24",
|
||||
)
|
||||
# Built in a temporary directory at test time (too large to commit).
|
||||
TEST_TIME = ("f01", "f04", "f05")
|
||||
@@ -103,6 +106,10 @@ def _enc(text):
|
||||
return {"class_type": "CLIPTextEncode", "inputs": {"text": text, "clip": ["4", 1]}}
|
||||
|
||||
|
||||
def _linked_enc(link):
|
||||
return {"class_type": "CLIPTextEncode", "inputs": {"text": link, "clip": ["4", 1]}}
|
||||
|
||||
|
||||
def _ksampler_graph(positive, negative, sampler="KSampler", latent=None, extra=None):
|
||||
graph = {
|
||||
"4": {
|
||||
@@ -732,6 +739,61 @@ def _f20():
|
||||
return _basic(_ksampler_graph(select, _enc(NEG), extra=extra))
|
||||
|
||||
|
||||
def _f21():
|
||||
"""``JoinStringMulti`` with ``return_list: true`` feeds the positive encoder."""
|
||||
join = {
|
||||
"class_type": "JoinStringMulti",
|
||||
"inputs": {
|
||||
"inputcount": 2,
|
||||
"delimiter": ", ",
|
||||
"return_list": True,
|
||||
"string_1": ["11", 0],
|
||||
"string_2": ["12", 0],
|
||||
},
|
||||
}
|
||||
extra = {
|
||||
"10": join,
|
||||
"11": {"class_type": "PrimitiveString", "inputs": {"value": "a red fox"}},
|
||||
"12": {"class_type": "PrimitiveString", "inputs": {"value": "in snow"}},
|
||||
}
|
||||
positive = _linked_enc(["10", 0])
|
||||
return _basic(_ksampler_graph(positive, _enc(NEG), extra=extra))
|
||||
|
||||
|
||||
def _f22():
|
||||
"""``PromptSearchList`` (database search at run time) feeds the positive."""
|
||||
search = {
|
||||
"class_type": "PromptSearchList",
|
||||
"inputs": {
|
||||
"text": "fox",
|
||||
"category": "",
|
||||
"tags": "",
|
||||
"min_rating": 0,
|
||||
"limit": 50,
|
||||
},
|
||||
}
|
||||
positive = _linked_enc(["10", 0])
|
||||
return _basic(_ksampler_graph(positive, _enc(NEG), extra={"10": search}))
|
||||
|
||||
|
||||
def _f24():
|
||||
"""``TextEncodeQwenImage21``: slot 0 is the positive, slot 1 the negative."""
|
||||
qwen = {
|
||||
"class_type": "TextEncodeQwenImage21",
|
||||
"inputs": {
|
||||
"clip": ["4", 1],
|
||||
"prompt": POS,
|
||||
"negative_prompt": NEG,
|
||||
"vae": ["4", 2],
|
||||
"resolution": 1024,
|
||||
},
|
||||
}
|
||||
graph = _ksampler_graph(qwen, None)
|
||||
del graph["7"]
|
||||
graph["3"]["inputs"]["negative"] = ["6", 1]
|
||||
return _basic(graph)
|
||||
|
||||
|
||||
def _f23():
|
||||
"""The negative is ``ConditioningZeroOut`` of the positive encoder."""
|
||||
zero = {"class_type": "ConditioningZeroOut", "inputs": {"conditioning": ["6", 0]}}
|
||||
@@ -763,7 +825,10 @@ _BUILDERS = {
|
||||
"f17": _f17,
|
||||
"f19": _f19,
|
||||
"f20": _f20,
|
||||
"f21": _f21,
|
||||
"f22": _f22,
|
||||
"f23": _f23,
|
||||
"f24": _f24,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
// Some answers are known to be wrong (f01b shows the preview-only detailer text
|
||||
// as both roles; f16 shows the last of two conflicting prompt chunks; f19 shows the
|
||||
// upstream sampler's prompt and settings, not the detailer's; f23 shows the positive
|
||||
// text as the zeroed negative). They are
|
||||
// text as the zeroed negative; f21 joins a list output; f22 shows PromptSearchList's
|
||||
// search text as the prompt). They are
|
||||
// recorded as-is and intentionally unchanged (spec section 11).
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
@@ -60,7 +61,10 @@ const EXPECTED = {
|
||||
f17: { positivePrompt: "No prompt found", negativePrompt: "No negative prompt found", checkpoint: "flux1-dev.safetensors", steps: "Unknown", cfgScale: "Unknown", sampler: "euler", seed: "Unknown" },
|
||||
f19: { positivePrompt: "a red fox in snow, detailed fur", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
|
||||
f20: { positivePrompt: "No prompt found", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
|
||||
f21: { positivePrompt: "a red fox, in snow", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
|
||||
f22: { positivePrompt: "fox", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
|
||||
f23: { positivePrompt: "a red fox in snow, detailed fur", negativePrompt: "a red fox in snow, detailed fur", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
|
||||
f24: { positivePrompt: "No prompt found", negativePrompt: "No negative prompt found", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
|
||||
};
|
||||
|
||||
test("legacy extractGenerationParams characterization covers every committed fixture", () => {
|
||||
|
||||
@@ -792,13 +792,17 @@ class TestPromptInspector(ImageAPITestCase):
|
||||
)
|
||||
|
||||
async def test_inspect_route_exposes_reason_code_and_blocking_node(self):
|
||||
self.parity_fixture("f06a")
|
||||
# f06a's encoder is supported since Task 2.4; an unknown encoder is not.
|
||||
built = parity.build("f06a")
|
||||
built["prompt"]["6"]["class_type"] = "FutureTextEncode"
|
||||
parity.write_png(self.output_dir / "f06a.png", built)
|
||||
_, result = await self.inspect("f06a.png")
|
||||
resolution = result["resolution"]
|
||||
self.assertEqual(resolution["state"], "unresolved")
|
||||
self.assertEqual(resolution["reason_code"], "unsupported-node")
|
||||
self.assertEqual(set(resolution["node"]), {"id", "class_type"})
|
||||
self.assertIsInstance(resolution["node"]["class_type"], str)
|
||||
self.assertEqual(
|
||||
resolution["node"], {"id": "6", "class_type": "FutureTextEncode"}
|
||||
)
|
||||
png(self.output_dir / "image.png", prompt="{broken")
|
||||
_, result = await self.inspect()
|
||||
self.assertEqual(result["resolution"]["reason_code"], "malformed")
|
||||
|
||||
@@ -1652,6 +1652,446 @@ class TestConditioningSemantics(unittest.TestCase):
|
||||
self.assertIsNone(result["reason_code"])
|
||||
|
||||
|
||||
def _primitive(value: str) -> dict:
|
||||
return {"class_type": "PrimitiveString", "inputs": {"value": value}}
|
||||
|
||||
|
||||
def _text_encoded(link: list) -> dict:
|
||||
"""A CLIPTextEncode (node 6 in ``_with_positive``) reading a linked string."""
|
||||
return {"class_type": "CLIPTextEncode", "inputs": {"text": link, "clip": ["4", 1]}}
|
||||
|
||||
|
||||
def _string_positive(node: dict, extra: dict = None) -> dict:
|
||||
"""``f02`` whose positive encoder reads string node 10 (``node``)."""
|
||||
return _with_positive(_text_encoded(["10", 0]), {"10": node, **(extra or {})})
|
||||
|
||||
|
||||
def _multiline(text, flag) -> dict:
|
||||
return {
|
||||
"class_type": "StringConstantMultiline",
|
||||
"inputs": {"string": text, "strip_newlines": flag},
|
||||
}
|
||||
|
||||
|
||||
def _join(count, delimiter, return_list=False, **strings) -> dict:
|
||||
return {
|
||||
"class_type": "JoinStringMulti",
|
||||
"inputs": {
|
||||
"inputcount": count,
|
||||
"delimiter": delimiter,
|
||||
"return_list": return_list,
|
||||
**strings,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _sd3(clip_l: str, clip_g: str, t5xxl: str) -> dict:
|
||||
return {
|
||||
"class_type": "CLIPTextEncodeSD3",
|
||||
"inputs": {
|
||||
"clip": ["4", 1],
|
||||
"clip_l": clip_l,
|
||||
"clip_g": clip_g,
|
||||
"t5xxl": t5xxl,
|
||||
"empty_padding": "none",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _positive(result: dict) -> tuple:
|
||||
return result["state"], _role_text_of(result, "positive"), result["reason_code"]
|
||||
|
||||
|
||||
def _role_text_of(result: dict, role: str):
|
||||
side = result[role]
|
||||
return side["text"] if isinstance(side, dict) else None
|
||||
|
||||
|
||||
F01B_POSITIVE = f"{parity.POS}, Scene 0: unique shot description soft winter light,"
|
||||
|
||||
|
||||
class TestEncoderAndStringSemantics(unittest.TestCase):
|
||||
"""Text encoders, string sources and runtime nodes (inspection, Task 2.4)."""
|
||||
|
||||
def test_sdxl_equal_parts_resolve(self):
|
||||
result = _inspect(parity.build("f06a")["prompt"])
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertIsNone(result["reason_code"])
|
||||
self.assertEqual(_sides(result), (parity.POS, parity.NEG))
|
||||
provenance = result["positive"]["provenance"]
|
||||
self.assertEqual(provenance["path"], [{"node_id": "6", "output_slot": 0}])
|
||||
self.assertEqual(provenance["text_source"], "graph-inferred")
|
||||
self.assertEqual(result["positive"]["storage_text"], parity.POS)
|
||||
# Linked parts are followed; equal texts still resolve, path deduplicated.
|
||||
source = parity.build("f06a")["prompt"]
|
||||
source["6"]["inputs"]["text_g"] = ["10", 0]
|
||||
source["6"]["inputs"]["text_l"] = ["10", 0]
|
||||
source["10"] = _primitive(parity.POS)
|
||||
result = _inspect(source)
|
||||
self.assertEqual(_sides(result), (parity.POS, parity.NEG))
|
||||
self.assertEqual(
|
||||
result["positive"]["provenance"]["path"],
|
||||
[{"node_id": "6", "output_slot": 0}, {"node_id": "10", "output_slot": 0}],
|
||||
)
|
||||
|
||||
def test_sdxl_distinct_parts_are_composite(self):
|
||||
source = parity.build("f06a")["prompt"]
|
||||
source["6"]["inputs"]["text_l"] = "a second style"
|
||||
result = _inspect(source)
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
self.assertEqual(result["reason_code"], "composite")
|
||||
self.assertEqual(result["reason"], "Encoder parts have different texts")
|
||||
self.assertEqual(
|
||||
result["node"], {"id": "6", "class_type": "CLIPTextEncodeSDXL"}
|
||||
)
|
||||
self.assertIsNone(result["positive"])
|
||||
# Whitespace is text: emptiness is exact, never trimmed into agreement.
|
||||
source["6"]["inputs"]["text_l"] = " "
|
||||
self.assertEqual(_inspect(source)["reason_code"], "composite")
|
||||
source["6"]["inputs"]["text_l"] = parity.POS + " "
|
||||
self.assertEqual(_inspect(source)["reason_code"], "composite")
|
||||
|
||||
def test_sdxl_single_nonempty_part_resolves(self):
|
||||
for empty in ("text_l", "text_g"):
|
||||
with self.subTest(empty=empty):
|
||||
source = parity.build("f06a")["prompt"]
|
||||
source["6"]["inputs"][empty] = ""
|
||||
result = _inspect(source)
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(_sides(result), (parity.POS, parity.NEG))
|
||||
self.assertEqual(result["positive"]["storage_text"], parity.POS)
|
||||
# A linked empty part counts as empty; both paths are recorded.
|
||||
source = parity.build("f06a")["prompt"]
|
||||
source["6"]["inputs"]["text_l"] = ["10", 0]
|
||||
source["10"] = _primitive("")
|
||||
result = _inspect(source)
|
||||
self.assertEqual(_sides(result), (parity.POS, parity.NEG))
|
||||
self.assertEqual(
|
||||
result["positive"]["provenance"]["path"],
|
||||
[{"node_id": "6", "output_slot": 0}, {"node_id": "10", "output_slot": 0}],
|
||||
)
|
||||
# All parts empty: an empty positive, never a resolved prompt.
|
||||
source["6"]["inputs"]["text_g"] = ""
|
||||
self.assertEqual(_inspect(source)["reason"], "Positive prompt is empty")
|
||||
# A missing part is malformed, never read as empty.
|
||||
source = parity.build("f06a")["prompt"]
|
||||
del source["6"]["inputs"]["text_l"]
|
||||
self.assertEqual(_inspect(source)["reason_code"], "malformed")
|
||||
|
||||
def test_flux_and_sd3_encoders(self):
|
||||
result = _inspect(parity.build("f06b")["prompt"])
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(_sides(result), (parity.POS, parity.NEG))
|
||||
source = parity.build("f06b")["prompt"]
|
||||
source["6"]["inputs"]["t5xxl"] = "a longer natural language description"
|
||||
result = _inspect(source)
|
||||
self.assertEqual(result["reason_code"], "composite")
|
||||
self.assertEqual(result["node"]["class_type"], "CLIPTextEncodeFlux")
|
||||
cases = [
|
||||
((parity.POS, parity.POS, parity.POS), ("resolved", parity.POS, None)),
|
||||
(("", "", parity.POS), ("resolved", parity.POS, None)),
|
||||
(("", parity.POS, ""), ("resolved", parity.POS, None)),
|
||||
((parity.POS, "", WOLF), ("unresolved", None, "composite")),
|
||||
((parity.POS, parity.POS, WOLF), ("unresolved", None, "composite")),
|
||||
# Spec 7.2: only "all equal" or "exactly one non-empty" resolve.
|
||||
((parity.POS, parity.POS, ""), ("unresolved", None, "composite")),
|
||||
]
|
||||
for parts, expected in cases:
|
||||
with self.subTest(parts=parts):
|
||||
self.assertEqual(
|
||||
_positive(_inspect(_with_positive(_sd3(*parts)))), expected
|
||||
)
|
||||
|
||||
def test_qwen_edit_plus_prompt(self):
|
||||
extra = {
|
||||
"13": {"class_type": "LoadImage", "inputs": {"image": "reference.png"}}
|
||||
}
|
||||
for name in ("TextEncodeQwenImageEditPlus", "TextEncodeQwenImageEdit"):
|
||||
with self.subTest(node=name):
|
||||
inputs = {"clip": ["4", 1], "prompt": parity.POS, "vae": ["4", 2]}
|
||||
images = ("image1", "image2") if name.endswith("Plus") else ("image",)
|
||||
inputs.update({image: ["13", 0] for image in images})
|
||||
node = {"class_type": name, "inputs": inputs}
|
||||
result = _inspect(_with_positive(node, extra))
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(_sides(result), (parity.POS, parity.NEG))
|
||||
self.assertEqual(
|
||||
result["positive"]["provenance"]["path"],
|
||||
[{"node_id": "6", "output_slot": 0}],
|
||||
)
|
||||
|
||||
def test_qwen_image21_two_outputs(self):
|
||||
source = parity.build("f24")["prompt"]
|
||||
result = _inspect(source)
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(_sides(result), (parity.POS, parity.NEG))
|
||||
self.assertEqual(
|
||||
result["negative"]["provenance"]["path"],
|
||||
[{"node_id": "6", "output_slot": 1}],
|
||||
)
|
||||
self.assertEqual(result["negative"]["storage_text"], parity.NEG)
|
||||
# Slot 2 is the latent output, never a prompt.
|
||||
source["3"]["inputs"]["negative"] = ["6", 2]
|
||||
result = _inspect(source)
|
||||
self.assertEqual(result["reason_code"], "unsupported-node")
|
||||
self.assertEqual(
|
||||
result["node"], {"id": "6", "class_type": "TextEncodeQwenImage21"}
|
||||
)
|
||||
|
||||
def test_kiko_text_encode_plain_text(self):
|
||||
node = {"class_type": "KikoTextEncode", "inputs": {"clip": ["4", 1]}}
|
||||
node["inputs"]["text"] = parity.POS
|
||||
result = _inspect(_with_positive(node))
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(_sides(result), (parity.POS, parity.NEG))
|
||||
self.assertEqual(result["positive"]["storage_text"], parity.POS)
|
||||
# No composition: a linked text arrives unchanged.
|
||||
node["inputs"]["text"] = ["10", 0]
|
||||
extra = {"10": {"class_type": "PrimitiveStringMultiline"}}
|
||||
extra["10"]["inputs"] = {"value": " " + parity.POS + "\n"}
|
||||
result = _inspect(_with_positive(node, extra))
|
||||
self.assertEqual(result["positive"]["text"], " " + parity.POS + "\n")
|
||||
|
||||
def test_string_constant_multiline_strip_newlines_exact(self):
|
||||
result = _inspect(_string_positive(_multiline("a\n b \n", True)))
|
||||
self.assertEqual(_positive(result), ("resolved", "a b", None))
|
||||
self.assertEqual(result["positive"]["storage_text"], "a b")
|
||||
self.assertEqual(
|
||||
result["positive"]["provenance"]["path"],
|
||||
[{"node_id": "6", "output_slot": 0}, {"node_id": "10", "output_slot": 0}],
|
||||
)
|
||||
result = _inspect(_string_positive(_multiline("a\n b \n", False)))
|
||||
self.assertEqual(_positive(result), ("resolved", "a\n b \n", None))
|
||||
# Only "\n" is removed; other whitespace inside the text is kept.
|
||||
result = _inspect(_string_positive(_multiline("\ta\r\n\tb\n\n", True)))
|
||||
self.assertEqual(result["positive"]["text"], "a\r\tb")
|
||||
# A linked text is followed; a linked flag is decided at run time.
|
||||
linked = _string_positive(
|
||||
_multiline(["11", 0], True), {"11": _primitive("x\ny")}
|
||||
)
|
||||
self.assertEqual(_inspect(linked)["positive"]["text"], "xy")
|
||||
flag = _string_positive(
|
||||
_multiline("a\nb", ["12", 0]),
|
||||
{"12": {"class_type": "PrimitiveBoolean", "inputs": {"value": True}}},
|
||||
)
|
||||
result = _inspect(flag)
|
||||
self.assertEqual(result["reason_code"], "unsupported-node")
|
||||
self.assertEqual(
|
||||
result["node"], {"id": "10", "class_type": "StringConstantMultiline"}
|
||||
)
|
||||
for value in ("true", 1, None):
|
||||
with self.subTest(flag=value):
|
||||
result = _inspect(_string_positive(_multiline("a\nb", value)))
|
||||
self.assertEqual(result["reason_code"], "malformed")
|
||||
missing = _multiline("a", True)
|
||||
del missing["inputs"]["strip_newlines"]
|
||||
self.assertEqual(
|
||||
_inspect(_string_positive(missing))["reason_code"], "malformed"
|
||||
)
|
||||
# StringConstant is the literal strategy: its string, unchanged.
|
||||
constant = {"class_type": "StringConstant", "inputs": {"string": " x\n"}}
|
||||
result = _inspect(_string_positive(constant))
|
||||
self.assertEqual(_positive(result), ("resolved", " x\n", None))
|
||||
|
||||
def test_join_string_multi_exact_semantics(self):
|
||||
strings = {
|
||||
"string_1": ["11", 0],
|
||||
"string_2": "b",
|
||||
"string_3": ["13", 0],
|
||||
"string_4": ["14", 0],
|
||||
"string_6": "beyond the count",
|
||||
}
|
||||
extra = {
|
||||
"11": _primitive(""),
|
||||
"13": _primitive(""),
|
||||
"14": _primitive(" d "),
|
||||
}
|
||||
join = _join(5, "\\n|", **strings)
|
||||
result = _inspect(_string_positive(join, extra))
|
||||
# string_1 is kept although empty; empty and missing later parts are
|
||||
# skipped; inputs past ``inputcount`` are ignored; the delimiter is
|
||||
# used exactly as written (no escape processing, no trimming).
|
||||
self.assertEqual(_positive(result), ("resolved", "\\n|b\\n| d ", None))
|
||||
self.assertEqual(result["positive"]["storage_text"], "\\n|b\\n| d ")
|
||||
self.assertEqual(
|
||||
result["positive"]["provenance"]["path"],
|
||||
[
|
||||
{"node_id": "6", "output_slot": 0},
|
||||
{"node_id": "10", "output_slot": 0},
|
||||
{"node_id": "11", "output_slot": 0},
|
||||
{"node_id": "13", "output_slot": 0},
|
||||
{"node_id": "14", "output_slot": 0},
|
||||
],
|
||||
)
|
||||
two = _join(2, " ", string_1="a red fox", string_2="in snow")
|
||||
self.assertEqual(
|
||||
_inspect(_string_positive(two))["positive"]["text"], "a red fox in snow"
|
||||
)
|
||||
# Settings must be literal values the node would accept.
|
||||
linked = {"12": {"class_type": "PrimitiveInt", "inputs": {"value": 2}}}
|
||||
for field, value, code in (
|
||||
("inputcount", ["12", 0], "unsupported-node"),
|
||||
("delimiter", ["12", 0], "unsupported-node"),
|
||||
("inputcount", 0, "malformed"),
|
||||
("inputcount", 1001, "malformed"),
|
||||
("inputcount", True, "malformed"),
|
||||
("inputcount", "2", "malformed"),
|
||||
("delimiter", 1, "malformed"),
|
||||
("return_list", "false", "malformed"),
|
||||
("return_list", 0, "malformed"),
|
||||
):
|
||||
with self.subTest(field=field, value=value):
|
||||
node = _join(2, " ", string_1="a", string_2="b")
|
||||
node["inputs"][field] = value
|
||||
result = _inspect(_string_positive(node, linked))
|
||||
self.assertEqual(result["reason_code"], code)
|
||||
if code == "unsupported-node":
|
||||
self.assertEqual(result["node"]["class_type"], "JoinStringMulti")
|
||||
for field in ("inputcount", "delimiter", "return_list", "string_1"):
|
||||
with self.subTest(missing=field):
|
||||
node = _join(2, " ", string_1="a", string_2="b")
|
||||
del node["inputs"][field]
|
||||
result = _inspect(_string_positive(node))
|
||||
self.assertEqual(result["reason_code"], "malformed")
|
||||
|
||||
def test_join_string_multi_return_list_is_runtime_value(self):
|
||||
source = parity.build("f21")["prompt"]
|
||||
result = _inspect(source)
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
self.assertEqual(result["reason_code"], "runtime-value")
|
||||
self.assertEqual(result["reason"], "Text is decided at run time")
|
||||
self.assertEqual(result["node"], {"id": "10", "class_type": "JoinStringMulti"})
|
||||
# A linked list flag is a run-time value too.
|
||||
source["10"]["inputs"]["return_list"] = ["20", 0]
|
||||
source["20"] = {"class_type": "PrimitiveBoolean", "inputs": {"value": False}}
|
||||
self.assertEqual(_inspect(source)["reason_code"], "runtime-value")
|
||||
# With the flag literally false the same parts join.
|
||||
source["10"]["inputs"]["return_list"] = False
|
||||
self.assertEqual(_inspect(source)["positive"]["text"], "a red fox, in snow")
|
||||
|
||||
def test_prompt_search_list_is_runtime_value(self):
|
||||
from utils.prompt_inspector import inspect_prompt_evidence
|
||||
|
||||
source = parity.build("f22")["prompt"]
|
||||
result = _inspect(source)
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
self.assertEqual(result["reason_code"], "runtime-value")
|
||||
self.assertEqual(result["node"], {"id": "10", "class_type": "PromptSearchList"})
|
||||
self.assertIsNone(result["positive"])
|
||||
# Never a list item: every output slot is decided at run time.
|
||||
source["6"]["inputs"]["text"] = ["10", 3]
|
||||
self.assertEqual(_inspect(source)["reason_code"], "runtime-value")
|
||||
evidence = inspect_prompt_evidence({"prompt": parity.build("f22")["prompt"]})
|
||||
self.assertEqual(evidence["prompts"]["positive"]["status"], "unresolved")
|
||||
self.assertIsNone(evidence["prompts"]["positive"]["text"])
|
||||
|
||||
@unittest.expectedFailure # needs Task 2.5 (EmptyFlux2LatentImage origin)
|
||||
def test_f01b_single_flux2_branch_resolves_with_absent_negative(self):
|
||||
result = _inspect(parity.build("f01b")["prompt"])
|
||||
self.assertEqual(result["state"], "absent")
|
||||
self.assertEqual(result["positive"]["text"], F01B_POSITIVE)
|
||||
self.assertIsNone(result["negative"])
|
||||
|
||||
def test_f01b_branch_text_is_the_exact_join(self):
|
||||
# Until Task 2.5 the flux2 latent origin is unsupported, so this swaps
|
||||
# it for an EmptyLatentImage and anchors at the branch's saver: the
|
||||
# join, multiline strings and encoder alone must give the exact text.
|
||||
source = parity.build("f01b")["prompt"]
|
||||
source["1300:7"] = {
|
||||
"class_type": "EmptyLatentImage",
|
||||
"inputs": {"width": 1024, "height": 1024, "batch_size": 1},
|
||||
}
|
||||
result = _inspect(source, output_node_id="1400")
|
||||
self.assertEqual(result["state"], "absent")
|
||||
self.assertEqual(result["reason"], "Guider has no negative input")
|
||||
self.assertEqual(result["positive"]["text"], F01B_POSITIVE)
|
||||
self.assertEqual(result["positive"]["storage_text"], F01B_POSITIVE)
|
||||
self.assertIsNone(result["negative"])
|
||||
|
||||
def test_encoder_and_string_outputs_keep_their_kinds(self):
|
||||
sdxl = parity.build("f06a")["prompt"]["6"]
|
||||
# A conditioning output read as a string, and a string output read as
|
||||
# conditioning, are malformed links, not unsupported nodes.
|
||||
join = _join(2, " ", string_1=["11", 0], string_2="b")
|
||||
result = _inspect(_string_positive(join, {"11": sdxl}))
|
||||
self.assertEqual(result["reason_code"], "malformed")
|
||||
self.assertEqual(result["reason"], "Output slot has an incompatible type")
|
||||
for node in (_multiline("a", True), _join(2, " ", string_1="a")):
|
||||
with self.subTest(node=node["class_type"]):
|
||||
result = _inspect(_with_positive(node))
|
||||
self.assertEqual(result["reason_code"], "malformed")
|
||||
|
||||
def test_unverified_text_nodes_stay_unsupported_and_named(self):
|
||||
for name in ("LoRAfromText", "RegexReplace", "XYZPrompt", "TextBoxMira"):
|
||||
with self.subTest(node=name):
|
||||
node = {"class_type": name, "inputs": {"text": parity.POS}}
|
||||
result = _inspect(_string_positive(node))
|
||||
self.assertEqual(result["reason_code"], "unsupported-node")
|
||||
self.assertEqual(result["node"], {"id": "10", "class_type": name})
|
||||
|
||||
def test_string_strategies_bound_every_intermediate(self):
|
||||
limited = replace(resolver.INSPECTION_PROFILE, max_text_length=10)
|
||||
join = _join(2, " ", string_1="abcdef", string_2="ghijk")
|
||||
result = resolve_prompt_pair(
|
||||
{"prompt": _string_positive(join)}, profile=limited
|
||||
)
|
||||
self.assertEqual(result["reason_code"], "text-limit")
|
||||
# Exactly at the limit is fine.
|
||||
join["inputs"]["string_2"] = "ghi"
|
||||
result = resolve_prompt_pair(
|
||||
{"prompt": _string_positive(join)}, profile=limited
|
||||
)
|
||||
self.assertEqual(result["positive"]["text"], "abcdef ghi")
|
||||
# The scanned-character budget covers stripping, joining and comparing.
|
||||
long_text = "x\n" * 5_000
|
||||
cases = {
|
||||
"strip": _string_positive(_multiline(long_text, True)),
|
||||
"join": _string_positive(
|
||||
_join(2, " ", string_1=long_text, string_2=long_text)
|
||||
),
|
||||
"parts": _with_positive(_sd3(long_text, long_text, long_text)),
|
||||
}
|
||||
scanned = replace(resolver.INSPECTION_PROFILE, max_scanned_units=8_000)
|
||||
for label, source in cases.items():
|
||||
with self.subTest(strategy=label):
|
||||
self.assertEqual(_inspect(source)["state"], "resolved")
|
||||
result = resolve_prompt_pair({"prompt": source}, profile=scanned)
|
||||
self.assertEqual(result["reason_code"], "work-limit")
|
||||
|
||||
def test_listed_strategies_without_code_here_are_unsupported(self):
|
||||
# ``pm-compose`` and ``concat`` are schema-valid but implemented by the
|
||||
# existing adapters only; a table row using them is never guessed at.
|
||||
table = inspection_semantics.load_semantics()
|
||||
encoders = dict(table.encoders)
|
||||
encoders[("CLIPTextEncodeSDXL", 0)] = {
|
||||
"strategy": "pm-compose",
|
||||
"inputs": ("text",),
|
||||
}
|
||||
strings = dict(table.strings)
|
||||
strings[("StringConstant", 0)] = {"strategy": "concat", "inputs": ("string",)}
|
||||
patched = replace(table, encoders=encoders, strings=strings)
|
||||
constant = {"class_type": "StringConstant", "inputs": {"string": "x"}}
|
||||
with mock.patch.object(resolver, "_inspection_semantics", return_value=patched):
|
||||
for source, name in (
|
||||
(parity.build("f06a")["prompt"], "CLIPTextEncodeSDXL"),
|
||||
(_string_positive(constant), "StringConstant"),
|
||||
):
|
||||
with self.subTest(node=name):
|
||||
result = _inspect(source)
|
||||
self.assertEqual(result["reason_code"], "unsupported-node")
|
||||
self.assertEqual(result["node"]["class_type"], name)
|
||||
|
||||
def test_default_profile_keeps_encoder_and_string_nodes_unsupported(self):
|
||||
for name in ("f06a", "f06b", "f21", "f22", "f24"):
|
||||
with self.subTest(fixture=name):
|
||||
result = resolve_prompt_pair({"prompt": parity.build(name)["prompt"]})
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
self.assertEqual(
|
||||
result["reason"], "Unsupported node semantics or output slot"
|
||||
)
|
||||
self.assertIsNone(result["reason_code"])
|
||||
|
||||
|
||||
def _detailed(same_prompt: bool = False) -> dict:
|
||||
"""``f19``: KSampler 3 -> VAEDecode 8 -> FaceDetailer 50 -> SaveImage 9."""
|
||||
source = parity.build("f19")["prompt"]
|
||||
|
||||
Reference in New Issue
Block a user