diff --git a/tests/js/comfy-metadata.test.js b/tests/js/comfy-metadata.test.js index efe004d..984ac7e 100644 --- a/tests/js/comfy-metadata.test.js +++ b/tests/js/comfy-metadata.test.js @@ -258,7 +258,7 @@ test("stage walks fail on unlisted sampler-shaped nodes like the resolver", () = ]; for (const inputs of shaped) { const graph = detailerFactGraph(); - graph.efficient = { class_type: "KSampler (Efficient)", inputs: { ...inputs, image: ["decode", 0] } }; + graph.efficient = { class_type: "MysterySampler", inputs: { ...inputs, image: ["decode", 0] } }; graph.d.inputs.image = ["efficient", 0]; for (const sampler_id of ["d", null]) { const facts = factsFor(graph, { resolution: { state: "ambiguous", sampler_id } }); @@ -304,7 +304,7 @@ test("stage walks follow only the resolver's links", () => { // Review M-2: `["eff", -1]` is not a link to the server, so it is not walked. for (const slot of [-1, 0.5, "0"]) { const graph = detailerFactGraph(); - graph.eff = { class_type: "KSampler (Efficient)", inputs: { positive: ["model", 0], model: ["model", 0] } }; + graph.eff = { class_type: "MysterySampler", inputs: { positive: ["model", 0], model: ["model", 0] } }; graph.decode.inputs.extra = ["eff", slot]; assert.equal(factsFor(graph, { resolution: { state: "ambiguous", sampler_id: "d" } }).seed.status, "ambiguous", String(slot)); const shaped = detailerFactGraph(); @@ -313,7 +313,7 @@ test("stage walks follow only the resolver's links", () => { assert.equal(factsFor(shaped, { resolution: { state: "ambiguous", sampler_id: "d" } }).seed.status, "ambiguous", `shaped ${slot}`); } const linked = detailerFactGraph(); - linked.eff = { class_type: "KSampler (Efficient)", inputs: { positive: ["model", 0], model: ["model", 0] } }; + linked.eff = { class_type: "MysterySampler", inputs: { positive: ["model", 0], model: ["model", 0] } }; linked.decode.inputs.extra = ["eff", 0]; assert.equal(factsFor(linked, { resolution: { state: "ambiguous", sampler_id: "d" } }).seed.status, "unresolved"); }); diff --git a/tests/js/inspection-legacy-ports.test.js b/tests/js/inspection-legacy-ports.test.js index f0d1c22..b51a3df 100644 --- a/tests/js/inspection-legacy-ports.test.js +++ b/tests/js/inspection-legacy-ports.test.js @@ -96,3 +96,24 @@ test("R6: upscale and SEGS detailers supply stage facts like FaceDetailer", () = assert.deepEqual([merged.seed[0], merged.steps[0], merged.cfg], ["ambiguous", "ambiguous", ["known", "7"]], class_type); } }); + +test("R6: upstream-verified samplers supply facts unless an override input is set", () => { + for (const [class_type, overrides] of [["ClownsharKSampler_Beta", ["options", "sigmas"]], ["KSampler (Efficient)", ["script"]], ["ttN KSampler_v2", ["adv_xyPlot"]]]) { + const graph = modelGraph("CR Apply LoRA Stack"); + graph.s.class_type = class_type; + assert.deepEqual(graphFacts(graph), { + seed: ["known", "5"], steps: ["known", "20"], cfg: ["known", "7"], sampler: ["known", "euler"], + scheduler: ["known", "normal"], model: ["known", "base.safetensors"], + }, class_type); + for (const name of overrides) { + const overridden = structuredClone(graph); + overridden.s.inputs[name] = ["script", 0]; + overridden.script = { class_type: "SomeScript", inputs: {} }; + assert.deepEqual(graphFacts(overridden), ALL_UNRESOLVED, `${class_type} ${name}`); + for (const unset of [null, ""]) { + overridden.s.inputs[name] = unset; + assert.equal(graphFacts(overridden).seed[0], "known", `${class_type} ${name} ${unset}`); + } + } + } +}); diff --git a/tests/js/inspection-semantics-sync.test.js b/tests/js/inspection-semantics-sync.test.js index 86d5808..bbbbd38 100644 --- a/tests/js/inspection-semantics-sync.test.js +++ b/tests/js/inspection-semantics-sync.test.js @@ -38,8 +38,12 @@ test("the facts tables are not part of the legacy page contract", () => { // (which classes are samplers, their facts shape and a detailer's image input). test("sampler facts table equals semantics.samplers", () => { assert.equal(typeof metadata._samplerTable, "object", "comfy-metadata exposes its sampler table for this test"); - const expected = Object.fromEntries(Object.entries(semantics.samplers).map(([name, entry]) => [name, - entry.image_stage === undefined ? { facts: entry.facts } : { facts: entry.facts, image_stage: entry.image_stage }])); + // Task 2.7b: plus `fact_overrides`; `prompt_overrides` is read only by the resolver. + const expected = Object.fromEntries(Object.entries(semantics.samplers).map(([name, entry]) => [name, { + facts: entry.facts, + ...(entry.image_stage === undefined ? {} : { image_stage: entry.image_stage }), + ...(entry.fact_overrides === undefined ? {} : { fact_overrides: entry.fact_overrides }), + }])); assert.deepStrictEqual(plain(metadata._samplerTable), expected); assert.ok(Object.isFrozen(metadata._samplerTable)); }); diff --git a/tests/test_inspection_legacy_ports.py b/tests/test_inspection_legacy_ports.py index 1651624..143026e 100644 --- a/tests/test_inspection_legacy_ports.py +++ b/tests/test_inspection_legacy_ports.py @@ -625,5 +625,194 @@ class TestInstalledNodeSemantics(unittest.TestCase): ) +def _sampled(class_type: str, latent: str = "latent_image", **inputs) -> dict: + """``f02`` with its KSampler (node 3) replaced by ``class_type``.""" + prompt = _f02() + sampler = dict(prompt["3"]["inputs"], **inputs) + sampler[latent] = sampler.pop("latent_image") + prompt["3"] = {"class_type": class_type, "inputs": sampler} + return prompt + + +SAMPLERS = ( + ("ClownsharKSampler_Beta", "latent_image", "options"), + ("KSampler (Efficient)", "latent_image", "script"), + ("ttN KSampler_v2", "latent", "adv_xyPlot"), +) + + +class TestUpstreamVerifiedNodeSemantics(unittest.TestCase): + """R6 entries for nodes not installed here, read from their pinned sources. + + Each source is the commit the maintainer's saved workflows name in the + node's ``cnr_id``/``ver`` properties (the report lists repository, commit, + file and line). + """ + + def test_ksampler_shaped_samplers_resolve(self): + for class_type, latent, _ in SAMPLERS: + with self.subTest(node=class_type): + result = _inspect({"prompt": _sampled(class_type, latent)}) + self.assertEqual(result["state"], "resolved") + self.assertEqual(result["sampler_id"], "3") + self.assertEqual(_texts(result), (parity.POS, parity.NEG)) + + def test_inputs_that_replace_the_prompt_are_not_followed(self): + # RES4LYF options may carry a guider; Efficiency scripts and the ttN + # XY plot re-run the sampler with other settings. + for class_type, latent, override in SAMPLERS: + with self.subTest(node=class_type): + prompt = _sampled(class_type, latent, **{override: ["30", 0]}) + prompt["30"] = {"class_type": "SomeScript", "inputs": {}} + result = _inspect({"prompt": prompt}) + self.assertEqual(result["reason_code"], "unsupported-node") + self.assertEqual( + result["node"], {"id": "30", "class_type": "SomeScript"} + ) + + def test_chained_samplers_are_stages(self): + prompt = _sampled("ClownsharKSampler_Beta") + prompt["31"] = dict(prompt["3"], inputs=dict(prompt["3"]["inputs"])) + prompt["31"]["inputs"]["latent_image"] = ["3", 0] + prompt["8"]["inputs"]["samples"] = ["31", 0] + # Unanchored, two listed samplers are two branches, as for KSampler. + self.assertEqual(_inspect({"prompt": prompt})["state"], "ambiguous") + result = _inspect({"prompt": prompt}, output_node_id="9") + self.assertEqual(result["reason"], resolver.MULTISTAGE_UNATTRIBUTED_REASON) + + def test_seed_variance_enhancer_passes_its_conditioning(self): + prompt = _f02( + **{ + "20": { + "class_type": "SeedVarianceEnhancer", + "inputs": {"conditioning": ["6", 0], "seed": 1, "strength": 20.0}, + } + } + ) + prompt["3"]["inputs"]["positive"] = ["20", 0] + self.assertEqual(_texts(_inspect({"prompt": prompt})), (parity.POS, parity.NEG)) + + def test_overwritten_text_behind_seed_variance_is_still_refused(self): + # R1 before R6: the recon's 24 files differ from this only in names. + metadata = _overwritten() + metadata["prompt"]["20"] = { + "class_type": "SeedVarianceEnhancer", + "inputs": {"conditioning": ["17", 0], "seed": 1}, + } + metadata["prompt"]["3"]["inputs"]["positive"] = ["20", 0] + result = _inspect(metadata) + self.assertEqual(result["state"], "unresolved") + self.assertEqual(result["reason"], resolver.OVERWRITTEN_PROMPT_REASON) + + def test_instantid_advanced_outputs(self): + prompt = _f02( + **{ + "20": { + "class_type": "ApplyInstantIDAdvanced", + "inputs": { + "model": ["4", 0], + "positive": ["6", 0], + "negative": ["7", 0], + "ip_weight": 0.8, + }, + } + } + ) + prompt["3"]["inputs"].update(positive=["20", 1], negative=["20", 2]) + self.assertEqual(_texts(_inspect({"prompt": prompt})), (parity.POS, parity.NEG)) + prompt["3"]["inputs"]["positive"] = ["20", 0] # output 0 is the MODEL + self.assertEqual( + _inspect({"prompt": prompt})["reason_code"], "unsupported-node" + ) + + def test_crystools_conditioning_switch(self): + def switch(condition) -> dict: + prompt = _f02( + **{ + "11": _pm_encoder("a dog"), + "20": { + "class_type": "Switch conditioning [Crystools]", + "inputs": { + "on_true": ["6", 0], + "on_false": ["11", 0], + "boolean": condition, + }, + }, + "21": {"class_type": "PrimitiveBoolean", "inputs": {"value": True}}, + } + ) + prompt["3"]["inputs"]["positive"] = ["20", 0] + return _inspect({"prompt": prompt}) + + self.assertEqual(_texts(switch(True))[0], parity.POS) + self.assertEqual(_texts(switch(False))[0], "a dog") + result = switch(["21", 0]) + self.assertEqual(result["state"], "ambiguous") + self.assertEqual( + sorted(_texts(item)[0] for item in result["candidates"]), + sorted([parity.POS, "a dog"]), + ) + + def test_smz_encoder_reads_text_unless_sdxl_may_apply(self): + def smz(**inputs) -> dict: + node = { + "class_type": "smZ CLIPTextEncode", + "inputs": { + "clip": ["4", 1], + "text": "a cat", + "text_g": "a cat", + "text_l": "a cat", + "with_SDXL": False, + "parser": "comfy", + **inputs, + }, + } + return _inspect({"prompt": _f02(**{"6": node})}) + + self.assertEqual(_texts(smz(text_g="", text_l="x")), ("a cat", parity.NEG)) + # with_SDXL: the connected CLIP decides at run time which inputs are read. + self.assertEqual(_texts(smz(with_SDXL=True)), ("a cat", parity.NEG)) + for parts in ({"text_g": "x"}, {"text_l": ""}, {"text": "x"}): + with self.subTest(parts=parts): + result = smz(with_SDXL=True, **parts) + self.assertEqual(result["reason_code"], "runtime-value") + self.assertEqual( + result["node"], {"id": "6", "class_type": "smZ CLIPTextEncode"} + ) + linked = smz(with_SDXL=["21", 0]) + self.assertEqual(linked["reason_code"], "unsupported-node") + for broken in ({"with_SDXL": "yes"}, {"with_SDXL": None}): + with self.subTest(flag=broken): + self.assertEqual(smz(**broken)["reason_code"], "malformed") + + def test_string_sources(self): + cases = ( + ("ttN text", "text"), + ("CR Text", "text"), + ("CR Prompt Text", "prompt"), + ("TextBoxMira", "text"), + ) + for class_type, name in cases: + with self.subTest(node=class_type): + prompt = _f02( + **{"10": {"class_type": class_type, "inputs": {name: parity.POS}}} + ) + prompt["6"]["inputs"]["text"] = ["10", 0] + self.assertEqual( + _texts(_inspect({"prompt": prompt})), (parity.POS, parity.NEG) + ) + # Output 1 of the Comfyroll nodes is their help URL. + prompt["6"]["inputs"]["text"] = ["10", 1] + self.assertEqual( + _inspect({"prompt": prompt})["reason_code"], "unsupported-node" + ) + + def test_default_profile_keeps_them_unsupported(self): + for class_type, latent, _ in SAMPLERS: + with self.subTest(node=class_type): + result = resolve_prompt_pair({"prompt": _sampled(class_type, latent)}) + self.assertEqual(result["reason"], "No supported sampler pair found") + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_inspection_semantics.py b/tests/test_inspection_semantics.py index d3caa7a..73ba753 100644 --- a/tests/test_inspection_semantics.py +++ b/tests/test_inspection_semantics.py @@ -304,7 +304,6 @@ class SpecEntriesTest(unittest.TestCase): "ImpactWildcardProcessor", "RegexReplace", "LoRAfromText", - "TextBoxMira", "XYZPrompt", ): self.assertNotIn(name, table.all_classes) @@ -431,6 +430,7 @@ class SpecEntriesTest(unittest.TestCase): "ModelPatchTorchSettings", "CFGNorm", "PerturbedAttentionGuidance", + "CR Apply LoRA Stack", ), ) self.assertEqual( @@ -494,7 +494,9 @@ class ValidationTest(_TableCase): "conditioning": frozenset({"passthrough", "zeroed"}), "selectors": frozenset({"boolean-select"}), "composites": frozenset({"combine"}), - "encoders": frozenset({"single", "parts-equal", "pm-compose"}), + "encoders": frozenset( + {"single", "parts-equal", "pm-compose", "flag-parts"} + ), "strings": frozenset( {"literal", "strip-newlines", "join-multi", "concat"} ), @@ -714,6 +716,100 @@ def _depth(value, level=1): class SamplerOptionsTest(_TableCase): """Task 2.7b: optional sampler lists are input names, or the table fails.""" + def test_upstream_verified_ports_are_listed_with_their_source_inputs(self): + # Not installed here: read from the pinned upstream commits the saved + # workflows name (the report lists repository, commit, file and line). + table = load_semantics() + pair = {"positive": "positive", "negative": "negative"} + for name, latent, prompt_overrides, fact_overrides in ( + ( + "ClownsharKSampler_Beta", + "latent_image", + ("options",), + ("options", "sigmas"), + ), + ("KSampler (Efficient)", "latent_image", ("script",), ("script",)), + ("ttN KSampler_v2", "latent", ("adv_xyPlot",), ("adv_xyPlot",)), + ): + self.assertEqual( + table.samplers[name], + { + "strategy": "ksampler", + "conditioning": pair, + "latent": latent, + "facts": "ksampler", + "prompt_overrides": prompt_overrides, + "fact_overrides": fact_overrides, + }, + name, + ) + for (name, slot), source in { + ("SeedVarianceEnhancer", 0): "conditioning", + ("ApplyInstantIDAdvanced", 1): "positive", + ("ApplyInstantIDAdvanced", 2): "negative", + }.items(): + self.assertEqual( + table.conditioning[(name, slot)], + {"strategy": "passthrough", "input": source}, + ) + self.assertNotIn(("ApplyInstantIDAdvanced", 0), table.conditioning) + self.assertEqual( + table.selectors["Switch conditioning [Crystools]"], + table.selectors["easy ifElse"], + ) + self.assertEqual( + table.encoders[("smZ CLIPTextEncode", 0)], + { + "strategy": "flag-parts", + "inputs": ("text", "text_g", "text_l"), + "params": {"flag": "with_SDXL"}, + }, + ) + for name, source in ( + ("ttN text", "text"), + ("CR Text", "text"), + ("CR Prompt Text", "prompt"), + ("TextBoxMira", "text"), + ): + self.assertEqual( + table.strings[(name, 0)], {"strategy": "literal", "inputs": (source,)} + ) + self.assertNotIn((name, 1), table.strings) + + def test_flag_parts_needs_a_flag_and_two_inputs(self): + doc = _minimal() + entry = { + "class": "smZ CLIPTextEncode", + "slot": 0, + "strategy": "flag-parts", + "inputs": ["text", "text_g"], + "params": {"flag": "with_SDXL"}, + } + doc["encoders"].append(entry) + self.assertEqual( + self.load_doc(doc).encoders[("smZ CLIPTextEncode", 0)]["params"], + {"flag": "with_SDXL"}, + ) + for change in ( + {"params": {}}, + {"params": {"flag": "x", "other": "y"}}, + {"params": {"flag": ""}}, + {"params": None}, + {"inputs": ["text"]}, + {"compose": True}, + ): + with self.subTest(change=change): + broken = _minimal() + broken["encoders"].append({**entry, **change}) + with self.assertRaises(SemanticsError): + self.load_doc(broken) + broken = _minimal() + broken["encoders"].append( + {key: item for key, item in entry.items() if key != "params"} + ) + with self.assertRaises(SemanticsError): + self.load_doc(broken) + def test_prompt_overrides_are_input_names(self): doc = _minimal() doc["samplers"]["KSampler"]["prompt_overrides"] = ["wildcard"] @@ -725,6 +821,13 @@ class SamplerOptionsTest(_TableCase): with self.assertRaises(SemanticsError): self.load_doc(doc) doc["samplers"]["KSampler"].pop("prompt_overrides") + doc["samplers"]["KSampler"]["fact_overrides"] = ["script", "sigmas"] + sampler = self.load_doc(doc).samplers["KSampler"] + self.assertEqual(sampler["fact_overrides"], ("script", "sigmas")) + doc["samplers"]["KSampler"]["fact_overrides"] = ["script", "script"] + with self.assertRaises(SemanticsError): + self.load_doc(doc) + doc["samplers"]["KSampler"].pop("fact_overrides") doc["samplers"]["KSampler"]["overrides"] = ["wildcard"] self.assertRejected(doc, "unknown field") diff --git a/tests/test_prompt_pair_resolver.py b/tests/test_prompt_pair_resolver.py index ebcb137..1ba3f8b 100644 --- a/tests/test_prompt_pair_resolver.py +++ b/tests/test_prompt_pair_resolver.py @@ -2031,7 +2031,8 @@ class TestEncoderAndStringSemantics(unittest.TestCase): self.assertEqual(result["reason_code"], "malformed") def test_unverified_text_nodes_stay_unsupported_and_named(self): - for name in ("LoRAfromText", "RegexReplace", "XYZPrompt", "TextBoxMira"): + # TextBoxMira was verified in its source in Task 2.7b and is listed. + for name in ("LoRAfromText", "RegexReplace", "XYZPrompt"): with self.subTest(node=name): node = {"class_type": name, "inputs": {"text": parity.POS}} result = _inspect(_string_positive(node)) diff --git a/utils/inspection_semantics.py b/utils/inspection_semantics.py index 8905be9..802d2f1 100644 --- a/utils/inspection_semantics.py +++ b/utils/inspection_semantics.py @@ -41,7 +41,7 @@ STRATEGIES: Mapping[str, FrozenSet[str]] = MappingProxyType( "conditioning": frozenset({"passthrough", "zeroed"}), "selectors": frozenset({"boolean-select"}), "composites": frozenset({"combine"}), - "encoders": frozenset({"single", "parts-equal", "pm-compose"}), + "encoders": frozenset({"single", "parts-equal", "pm-compose", "flag-parts"}), "strings": frozenset({"literal", "strip-newlines", "join-multi", "concat"}), "savers": frozenset({"comfy-counter", "vhs-counter"}), } @@ -54,7 +54,7 @@ SAVER_PATTERNS = MappingProxyType( "vhs-counter": "{name}_{counter:05}.{ext}", } ) -_SAMPLER_OPTIONS = frozenset({"prompt_overrides"}) +_SAMPLER_OPTIONS = frozenset({"prompt_overrides", "fact_overrides"}) _CONFIGURATION_KINDS = frozenset({"noise", "sampler", "sigmas", "guider"}) _SCALAR_KINDS = frozenset({"number", "string"}) _TYPE_FIELDS = frozenset({"type_fields"}) @@ -183,8 +183,10 @@ def _check_sampler(entry: dict, strategy: str, where: str) -> None: _name(entry[stage], where) # Inputs that, when set, replace the prompt the sampler's conditioning # inputs carry (a detailer's wildcard); the prompt is then not read. - if "prompt_overrides" in entry: - _names(entry["prompt_overrides"], where + ".prompt_overrides") + # ``fact_overrides`` (read by comfy-metadata.js): inputs that, when set, + # replace the settings the facts would read (a script or an XY plot). + for option in sorted(_SAMPLER_OPTIONS & entry.keys()): + _names(entry[option], f"{where}.{option}") keys = {"guider"} if strategy == "custom-advanced" else {"positive", "negative"} conditioning = entry["conditioning"] _fields(conditioning, where + ".conditioning", frozenset(keys), frozenset()) @@ -246,6 +248,16 @@ def _check_conditioning(entry: dict, strategy: str, where: str) -> None: def _check_encoder(entry: dict, strategy: str, where: str) -> None: + if strategy == "flag-parts": + # A literal boolean ``flag`` false reads the first input; true needs + # every input equal (smZ CLIPTextEncode's with_SDXL). + _fields(entry, where, frozenset({"strategy", "inputs", "params"}), frozenset()) + params = entry["params"] + if not isinstance(params, dict) or set(params) != {"flag"}: + raise SemanticsError(f"{where}: params must be exactly ['flag']") + _name(params["flag"], where + ".params") + _names(entry["inputs"], where, minimum=2) + return _fields(entry, where, frozenset({"strategy", "inputs"}), frozenset({"compose"})) _names(entry["inputs"], where, minimum=2 if strategy == "parts-equal" else 1) if "compose" in entry and not isinstance(entry["compose"], bool): diff --git a/utils/prompt_pair_resolver.py b/utils/prompt_pair_resolver.py index 5e2b29a..6bb67a2 100644 --- a/utils/prompt_pair_resolver.py +++ b/utils/prompt_pair_resolver.py @@ -759,6 +759,8 @@ class _GraphResolver: names = entry["inputs"] if entry["strategy"] == "single": return self.text(inputs.get(names[0]), "string", depth + 1) + if entry["strategy"] == "flag-parts": + return self.flagged(node_id, entry, inputs, depth) if entry["strategy"] != "parts-equal": raise self.unsupported("Unsupported encoder strategy", node_id) parts = [self.text(inputs.get(name), "string", depth + 1) for name in names] @@ -771,6 +773,24 @@ class _GraphResolver: return self.joined_paths(filled, parts) raise self.named("Encoder parts have different texts", "composite", node_id) + def flagged( + self, node_id: str, entry: Mapping, inputs: dict, depth: int + ) -> Tuple[str, list, str]: + """smZ CLIPTextEncode: ``text`` unless its SDXL flag may apply. + + With the literal flag false the first input is encoded. With it true, + the connected CLIP's model class decides at run time which inputs are + read, so the prompt is known only when every input is the same text. + """ + names = entry["inputs"] + if not self.setting(node_id, inputs, entry["params"]["flag"], bool): + return self.text(inputs.get(names[0]), "string", depth + 1) + parts = [self.text(inputs.get(name), "string", depth + 1) for name in names] + self.scan(sum(len(part[0]) for part in parts)) + if len({part[0] for part in parts}) == 1: + return self.joined_paths(parts, parts) + raise self.named(_RUNTIME_REASON, "runtime-value", node_id) + def stringed( self, node_id: str, entry: Mapping, inputs: dict, depth: int ) -> Tuple[str, list, str]: diff --git a/web/js/comfy-metadata.js b/web/js/comfy-metadata.js index 6fe331d..dcacb28 100644 --- a/web/js/comfy-metadata.js +++ b/web/js/comfy-metadata.js @@ -417,6 +417,10 @@ DetailerForEach: { facts: "ksampler", image_stage: "image" }, UltimateSDUpscale: { facts: "ksampler", image_stage: "image" }, UltimateSDUpscaleNoUpscale: { facts: "ksampler", image_stage: "upscaled_image" }, + // `fact_overrides`: inputs that, when set, replace the settings read here. + ClownsharKSampler_Beta: { facts: "ksampler", fact_overrides: ["options", "sigmas"] }, + "KSampler (Efficient)": { facts: "ksampler", fact_overrides: ["script"] }, + "ttN KSampler_v2": { facts: "ksampler", fact_overrides: ["adv_xyPlot"] }, }); // Stages merged for one result: the resolver's candidate cap. Together with // MAX_VISITS per candidate walk these are deliberately tighter than the server's @@ -436,7 +440,9 @@ "JoinStringMulti", "PromptSearchList", "PreviewImage", "PreviewAny", "SaveImageWebsocket", "Image Comparer (rgthree)", "DetailerForEach", "UltimateSDUpscale", "UltimateSDUpscaleNoUpscale", "ControlNetInpaintingAliMamaApply", "LTXVConditioning", "FluxKontextMultiReferenceLatentMethod", "Text to Conditioning", - "TextInput", "DisplayText", + "TextInput", "DisplayText", "ClownsharKSampler_Beta", "KSampler (Efficient)", "ttN KSampler_v2", + "SeedVarianceEnhancer", "ApplyInstantIDAdvanced", "Switch conditioning [Crystools]", "smZ CLIPTextEncode", "ttN text", + "CR Text", "CR Prompt Text", "TextBoxMira", ]); // The table's `stage_inputs` (the resolver reads the same lists): inputs that make an // unlisted node a possible stage, and those that do so beside a linked `model`. @@ -456,6 +462,7 @@ const MODEL_PASSTHROUGHS = new Set([ "LoraLoader", "LoraLoaderModelOnly", "ModelSamplingFlux", "ModelSamplingAuraFlow", "ModelSamplingSD3", "Lora Loader (LoraManager)", "Power Lora Loader (rgthree)", "ModelPatchTorchSettings", "CFGNorm", "PerturbedAttentionGuidance", + "CR Apply LoRA Stack", ]); const CONFIGURATION_OUTPUTS = Object.freeze({ RandomNoise: { kind: "noise", fields: ["noise_seed"] }, @@ -579,7 +586,11 @@ function samplerFacts(ctx, samplerId, source) { const sampler = ctx.graph[samplerId]; const inputs = sampler.inputs; - const shape = SAMPLERS[sampler.class_type].facts; + const { facts: shape, fact_overrides: overrides = [] } = SAMPLERS[sampler.class_type]; + // A set override (a script, an XY plot, custom sigmas) replaces the settings below. + if (overrides.some(name => Object.hasOwn(inputs, name) && inputs[name] !== null && inputs[name] !== "")) { + return Object.fromEntries(FACT_FIELDS.map(name => [name, fact("unresolved", source, samplerId)])); + } const advanced = shape === "custom-advanced"; const custom = advanced || shape === "custom"; let settings = inputs; diff --git a/web/js/inspection-semantics.json b/web/js/inspection-semantics.json index 183ce13..5d770d8 100644 --- a/web/js/inspection-semantics.json +++ b/web/js/inspection-semantics.json @@ -50,6 +50,30 @@ "conditioning": { "positive": "positive", "negative": "negative" }, "image_stage": "upscaled_image", "facts": "ksampler" + }, + "ClownsharKSampler_Beta": { + "strategy": "ksampler", + "conditioning": { "positive": "positive", "negative": "negative" }, + "latent": "latent_image", + "facts": "ksampler", + "prompt_overrides": ["options"], + "fact_overrides": ["options", "sigmas"] + }, + "KSampler (Efficient)": { + "strategy": "ksampler", + "conditioning": { "positive": "positive", "negative": "negative" }, + "latent": "latent_image", + "facts": "ksampler", + "prompt_overrides": ["script"], + "fact_overrides": ["script"] + }, + "ttN KSampler_v2": { + "strategy": "ksampler", + "conditioning": { "positive": "positive", "negative": "negative" }, + "latent": "latent", + "facts": "ksampler", + "prompt_overrides": ["adv_xyPlot"], + "fact_overrides": ["adv_xyPlot"] } }, "guiders": { @@ -82,6 +106,9 @@ { "class": "LTXVConditioning", "slot": 0, "strategy": "passthrough", "input": "positive" }, { "class": "LTXVConditioning", "slot": 1, "strategy": "passthrough", "input": "negative" }, { "class": "FluxKontextMultiReferenceLatentMethod", "slot": 0, "strategy": "passthrough", "input": "conditioning" }, + { "class": "SeedVarianceEnhancer", "slot": 0, "strategy": "passthrough", "input": "conditioning" }, + { "class": "ApplyInstantIDAdvanced", "slot": 1, "strategy": "passthrough", "input": "positive" }, + { "class": "ApplyInstantIDAdvanced", "slot": 2, "strategy": "passthrough", "input": "negative" }, { "class": "ConditioningZeroOut", "slot": 0, "strategy": "zeroed", "input": "conditioning" } ], "selectors": { @@ -89,6 +116,11 @@ "strategy": "boolean-select", "condition": "boolean", "branches": { "true": "on_true", "false": "on_false" } + }, + "Switch conditioning [Crystools]": { + "strategy": "boolean-select", + "condition": "boolean", + "branches": { "true": "on_true", "false": "on_false" } } }, "composites": { @@ -106,12 +138,23 @@ { "class": "TextEncodeQwenImage21", "slot": 0, "strategy": "single", "inputs": ["prompt"] }, { "class": "TextEncodeQwenImage21", "slot": 1, "strategy": "single", "inputs": ["negative_prompt"] }, { "class": "KikoTextEncode", "slot": 0, "strategy": "single", "inputs": ["text"] }, - { "class": "Text to Conditioning", "slot": 0, "strategy": "single", "inputs": ["text"] } + { "class": "Text to Conditioning", "slot": 0, "strategy": "single", "inputs": ["text"] }, + { + "class": "smZ CLIPTextEncode", + "slot": 0, + "strategy": "flag-parts", + "inputs": ["text", "text_g", "text_l"], + "params": { "flag": "with_SDXL" } + } ], "strings": [ { "class": "StringConstant", "slot": 0, "strategy": "literal", "inputs": ["string"] }, { "class": "TextInput", "slot": 0, "strategy": "literal", "inputs": ["text"] }, { "class": "DisplayText", "slot": 0, "strategy": "literal", "inputs": ["text"] }, + { "class": "ttN text", "slot": 0, "strategy": "literal", "inputs": ["text"] }, + { "class": "CR Text", "slot": 0, "strategy": "literal", "inputs": ["text"] }, + { "class": "CR Prompt Text", "slot": 0, "strategy": "literal", "inputs": ["prompt"] }, + { "class": "TextBoxMira", "slot": 0, "strategy": "literal", "inputs": ["text"] }, { "class": "StringConstantMultiline", "slot": 0, @@ -214,7 +257,8 @@ "Power Lora Loader (rgthree)", "ModelPatchTorchSettings", "CFGNorm", - "PerturbedAttentionGuidance" + "PerturbedAttentionGuidance", + "CR Apply LoRA Stack" ], "scalar_outputs": { "PrimitiveInt": "number",