From 99cc71714ef778546fa322a76ba1c8f4887b7e69 Mon Sep 17 00:00:00 2001 From: Dominik Bargiel Date: Wed, 23 Sep 2026 16:07:10 +0200 Subject: [PATCH] Resolve fixed switch selections during preflight --- README.md | 4 +- plugins/ComfyUI/ComfyUI.py | 67 ++++++++++++++++++++++++++++++-- tests/test_headless_preflight.py | 35 +++++++++++++++++ 3 files changed, 100 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 8042cc0..3d88371 100644 --- a/README.md +++ b/README.md @@ -100,13 +100,13 @@ Every variation is checked on the selected Deadline worker after input staging, - validates every `class_type` against that worker's live `/object_info`; - rejects missing staged inputs and known browser-interactive nodes; -- removes fixed `ImpactSwitch`, `LatentSwitch`, and `SEGSSwitch` pass-through nodes by reconnecting consumers to the selected upstream input; +- resolves fixed selections from literal values or safe primitive value nodes, then removes `ImpactSwitch`, `LatentSwitch`, and `SEGSSwitch` pass-through nodes by reconnecting consumers to the selected upstream input; - sends the complete submitted `extra_pnginfo.workflow` to metadata-aware nodes; - verifies that ComfyUI history reports the expected output nodes and that every reported file exists; - permits the exact `DeadlineWorkerRegistration` control prompt when both worker and distributed modes are enabled; this prompt completes through history and intentionally produces no file; - turns prompt-validation errors, execution exceptions, early process exits, HTTP timeouts, missing output, and Deadline task timeouts into task failures. -Dynamic/connected switch selections are not rewritten. Browser-driven chooser/picker/preview-bridge nodes (`FL_ImagePicker`, `easy imageChooser`, `ImageChooser`, `PreviewChooser`, `PreviewBridge`, and `ImpactPreviewBridge`) are rejected because a farm render has no user to answer them. Other nodes using `PROMPT`, `DYNPROMPT`, `UNIQUE_ID`, or `EXTRA_PNGINFO` remain supported when the worker has the node and the submission includes full workflow metadata. Unknown third-party interactive nodes cannot be identified from `/object_info` alone; add their exact `class_type` to `KNOWN_UI_DEPENDENT_NODE_TYPES` after confirming that they wait for frontend state. +Dynamic switch selections are accepted when their selector is driven by a safe fixed-value node such as `PrimitiveInt`; the selected branch is resolved while submitting. Selectors driven by runtime or interactive nodes are rejected because a farm render has no user to answer them. Browser-driven chooser/picker/preview-bridge nodes (`FL_ImagePicker`, `easy imageChooser`, `ImageChooser`, `PreviewChooser`, `PreviewBridge`, and `ImpactPreviewBridge`) are rejected for the same reason. Other nodes using `PROMPT`, `DYNPROMPT`, `UNIQUE_ID`, or `EXTRA_PNGINFO` remain supported when the worker has the node and the submission includes full workflow metadata. Unknown third-party interactive nodes cannot be identified from `/object_info` alone; add their exact `class_type` to `KNOWN_UI_DEPENDENT_NODE_TYPES` after confirming that they wait for frontend state. ## Maintenance Scripts diff --git a/plugins/ComfyUI/ComfyUI.py b/plugins/ComfyUI/ComfyUI.py index fa4e17d..f229e34 100644 --- a/plugins/ComfyUI/ComfyUI.py +++ b/plugins/ComfyUI/ComfyUI.py @@ -84,6 +84,21 @@ FIXED_SWITCH_SPECS = { "SEGSSwitch": {"select": "select", "input": "input{index}"}, } +# These nodes expose a literal value through output 0. They are safe to +# evaluate during submission because their value is part of the API prompt; +# they do not depend on frontend interaction or worker runtime state. +FIXED_VALUE_NODE_TYPES = { + "PrimitiveBoolean", + "PrimitiveFloat", + "PrimitiveInt", + "PrimitiveString", + "PrimitiveNode", + "easy boolean", + "easy float", + "easy int", + "easy string", +} + FILE_OUTPUT_GROUPS = ("images", "gifs", "videos", "audio") def get_distributed_config_for_plugin(plugin) -> Tuple[bool, bool, bool]: @@ -1516,8 +1531,44 @@ sys.exit(0 if result.get('success') else 1) def _is_link(value): return isinstance(value, (list, tuple)) and len(value) == 2 and str(value[0]) != "" + def _resolve_fixed_value(self, prompt, value, workflow_metadata=None, visited=None): + """Resolve a switch selector connected to a literal-value node.""" + if not self._is_link(value): + return value, None + + source_id = str(value[0]) + try: + output_index = int(value[1]) + except (TypeError, ValueError): + return None, None + if output_index != 0: + return None, None + + visited = set() if visited is None else set(visited) + if source_id in visited: + return None, None + visited.add(source_id) + + source = prompt.get(source_id) + if not isinstance(source, dict) or source.get("class_type") not in FIXED_VALUE_NODE_TYPES: + return None, None + + source_inputs = source.get("inputs", {}) + if not isinstance(source_inputs, dict) or "value" not in source_inputs: + return None, None + + resolved, resolved_from = self._resolve_fixed_value( + prompt, + source_inputs["value"], + workflow_metadata, + visited, + ) + if resolved_from is None and resolved is not None: + resolved_from = source_id + return resolved, resolved_from + def _resolve_fixed_switches(self, prompt, workflow_metadata=None): - """Remove supported constant-selection switches and reconnect consumers.""" + """Remove supported fixed-selection switches and reconnect consumers.""" for switch_id, switch in list(prompt.items()): if not isinstance(switch, dict): continue @@ -1527,13 +1578,21 @@ sys.exit(0 if result.get('success') else 1) continue inputs = switch.get("inputs", {}) raw_select = inputs.get(spec["select"]) - if self._is_link(raw_select): + selected_index, selected_from = self._resolve_fixed_value( + prompt, raw_select, workflow_metadata + ) + if selected_from is not None: + self.LogInfo( + f"Headless preflight resolved {class_type} node {switch_id} " + f"selection from fixed value node {selected_from}: {selected_index}." + ) + elif self._is_link(raw_select): raise ComfyUIError( f"Headless preflight rejected dynamic switch {self._node_context(switch_id, prompt, workflow_metadata)}: " - "the selection is connected and cannot be proven fixed." + "the selection is connected to a non-fixed value." ) try: - selected_index = int(raw_select) + selected_index = int(selected_index) except (TypeError, ValueError): raise ComfyUIError( f"Headless preflight rejected {self._node_context(switch_id, prompt, workflow_metadata)}: " diff --git a/tests/test_headless_preflight.py b/tests/test_headless_preflight.py index d310238..839c186 100644 --- a/tests/test_headless_preflight.py +++ b/tests/test_headless_preflight.py @@ -31,6 +31,10 @@ def object_info(): "input": {"required": {}, "hidden": {"extra_pnginfo": "EXTRA_PNGINFO", "unique_id": "UNIQUE_ID"}}, "output_node": False, }, + "PrimitiveInt": { + "input": {"required": {"value": [["INT", {}]]}, "hidden": {}}, + "output_node": False, + }, "FL_ImagePicker": {"input": {"required": {}, "hidden": {"unique_id": "UNIQUE_ID"}}, "output_node": False}, "Explode": {"input": {"required": {}, "hidden": {}}, "output_node": False}, "DeadlineWorkerRegistration": { @@ -79,6 +83,37 @@ class HeadlessPreflightTests(unittest.TestCase): self.assertEqual(result["2"]["inputs"]["images"], ["4", 0]) self.assertEqual(result["2"]["inputs"]["label"], "input2") + def test_switch_selection_from_fixed_primitive_is_resolved(self): + with tempfile.TemporaryDirectory() as temp: + plugin = self.make_plugin(Path(temp)) + prompt = { + "3": {"class_type": "Explode", "inputs": {}}, + "4": {"class_type": "Explode", "inputs": {}}, + "5": { + "class_type": "ImpactSwitch", + "inputs": {"select": ["6", 0], "input1": ["3", 0], "input2": ["4", 0]}, + }, + "6": {"class_type": "PrimitiveInt", "inputs": {"value": 2}}, + "2": {"class_type": "SaveImage", "inputs": {"images": ["5", 0]}}, + } + result, _ = plugin._preflight_worker_payload(prompt, WORKFLOW) + self.assertNotIn("5", result) + self.assertEqual(result["2"]["inputs"]["images"], ["4", 0]) + + def test_dynamic_switch_value_node_is_still_rejected(self): + with tempfile.TemporaryDirectory() as temp: + plugin = self.make_plugin(Path(temp)) + with self.assertRaisesRegex(ComfyUIError, "connected to a non-fixed value"): + plugin._preflight_worker_payload({ + "3": {"class_type": "Explode", "inputs": {}}, + "4": {"class_type": "Explode", "inputs": {}}, + "5": { + "class_type": "ImpactSwitch", + "inputs": {"select": ["3", 0], "input1": ["3", 0], "input2": ["4", 0]}, + }, + "2": {"class_type": "SaveImage", "inputs": {"images": ["5", 0]}}, + }, WORKFLOW) + def test_gui_dependent_node_is_rejected_with_context(self): with tempfile.TemporaryDirectory() as temp: plugin = self.make_plugin(Path(temp))