From 7b00a551d9050945759f779fa3fc2f3dc7cae20d Mon Sep 17 00:00:00 2001 From: malkuthro <166633396+malkuthro@users.noreply.github.com> Date: Sun, 19 Jul 2026 23:34:30 +0200 Subject: [PATCH] feat(publish): independent output type for published setups; fix(pipeline): auto-versioning re-runs each queue (#267) Publish: Koolook_PublishOutput output_mode/input_switch/switch; setupSurface.app.outputSwitch; routers auto-bound as the output selector; unwired output types hidden; 'Same as input' offered only when every input type has a wired writer; result path follows the output switch. Pipeline: EasyAIPipeline IS_CHANGED keeps version=auto/next perpetually dirty so the vNNN disk scan re-runs every queue; literal versions stay cacheable. --- CHANGELOG.md | 26 ++ .../published-setup-external-ui-contract.md | 60 +++- .../koolook_publish/publish_contract_nodes.md | 26 +- k_ai_pipeline.py | 21 ++ k_publish_contract.py | 84 ++++- koolook_setup_runner.py | 221 +++++++----- koolook_setups.py | 225 ++++++++++-- tests/nodes/test_easy_ai_pipeline.py | 54 +++ tests/nodes/test_publish_contract.py | 31 +- tests/server/test_published_setup_registry.py | 228 ++++++++++++- tests/server/test_published_setup_runner.py | 320 +++++++++++++++++- tests/sidebar/test_published_surface.py | 13 + tests/sidebar/test_setup_runner_simulator.py | 80 +++++ web/setup_runner_simulator.js | 91 ++++- web/sidebar/published_surface.js | 35 +- 15 files changed, 1389 insertions(+), 126 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d246691..d51c577 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,32 @@ The format is inspired by Keep a Changelog and SemVer. ## [Unreleased] ### Added +- **Independent output type for published setups.** A `Koolook_PublishRouter` + is auto-detected as the setup's **output selector**: whichever writer branches + you wire become the app's "Output type" options, and the external user can pick + any of them (defaulting to follow-input, or the `Koolook_PublishOutput.output_mode` + widget). No dedicated output-switch wiring is required — the router is bound to + the output control regardless of what its `selector` is wired from. So a setup + can read one type and write another (EXR sequence in, QT movie out). + `Koolook_PublishOutput` gains `output_mode` / `input_switch` / `switch` for + authors who want explicit control, but they're optional. The app surface + exposes `setupSurface.app.outputSwitch`; output types with no wired writer are + hidden (`visible: false` from the execution map's `writerNodes`), and the + default falls back to the first wired type so an EXR-in/QT-only setup defaults + to QT. The runner prunes/validates writer branches by the chosen output type, + and a mode-switched `Koolook_PublishResult` reports the branch of whichever + switch drives its result index-switch, so a divergent EXR-in/QT-out setup + reports the QT movie path it actually wrote. The setup runner simulator renders + the "Output type" control and offers only wired types. The runner enforces the + contract for direct API callers too: hidden (`visible: false`) switch options + are rejected with a 400 listing only the visible choices, and omitting + `output_switch` when the default is the "Same as input" sentinel resolves + server-side to the input type instead of failing validation. +- **Auto-versioning re-runs each queue (`EasyAIPipeline`).** Added an + `IS_CHANGED` that marks the node dirty while `version` is `auto`/`next`, so + the next-free-`vNNN` disk scan runs every queue instead of being memoized on + the first run (which froze the version and made saves skip as "already + exists"). Literal versions stay cacheable. - **Koolook Matte nodes (`KoolookMatteLoader` / `KoolookMatteSampler` / `KoolookMatteFocusCrop` / `KoolookMatteFocusStitch`).** First-party, mask-guided one-step video matting — an original GPL-3.0 reimplementation of the published diff --git a/docs/maintainers/published-setup-external-ui-contract.md b/docs/maintainers/published-setup-external-ui-contract.md index 896c16f..640ff4f 100644 --- a/docs/maintainers/published-setup-external-ui-contract.md +++ b/docs/maintainers/published-setup-external-ui-contract.md @@ -149,6 +149,9 @@ naming/location controls for the external app and downstream writer/path nodes: | `folder` | Destination folder for generated outputs. | | `name` | Base output name. | | `version` | Version token/number used by the graph's naming logic. | +| `output_mode` | Output type control: `Same as input`, `EXR`, `QT`, `Img`. Default `Same as input` follows the input switch; a concrete choice overrides it. | +| `input_switch` (input slot) | Wired from `Koolook_PublishInput.switch`; only consulted when `output_mode` is `Same as input`. | +| `switch` (output slot) | Resolved output-type index (0=EXR, 1=QT, 2=Img). Wire into `Koolook_PublishRouter.selector` to route the writer branch by output type. | The external frontend can expose these as editable output controls when the setup author wants the user to choose where generated files should land. @@ -156,6 +159,49 @@ setup author wants the user to choose where generated files should land. the final result and should not own a mode-specific `result` field. Its outputs feed the writer/path-building nodes for each supported branch. +The `switch` output makes output type **independent of input type**: a setup can +read an EXR sequence and write a QT movie. When `output_mode` is `Same as input` +the node passes the wired `input_switch` through unchanged, so setups that don't +need divergence behave exactly as before. The inferred app surface exposes this +as `setupSurface.app.outputSwitch` (key `output_switch`), a switch whose options +are `EXR`/`QT`/`Img` (no `Prompt`), carrying `sameAsInput: true` so the frontend +can offer a "Same as input" choice. The frontend must resolve "Same as input" to +the concrete input-switch value **before submitting** `output_switch` — the +runner validates the submitted value against the concrete option list, so an +explicit `-1` sentinel is rejected. Omitting `output_switch` entirely is valid: +when the published default is the `-1` "Same as input" sentinel, the runner +resolves it server-side to the input switch's selected value, so direct API +clients that skip the key get the same follow-input behavior the simulator +pre-resolves in the browser. + +A `Koolook_PublishRouter` is inherently the **output selector** — its slots are +named EXR/QT/Img/Prompt. So when a setup exposes an output switch, the publisher +auto-binds every router to it (keying the execution-map router with `switchKey: +"output_switch"`) regardless of what the router's `selector` is physically wired +from. The author does **not** need to wire `Koolook_PublishOutput.switch` into +the router — just wire the writers to the router's branches. Whatever branches +have writers become the offered output types; the app user picks among them. + +Each `outputSwitch` option carries a `visible` flag: the publisher marks an +output type visible only when some router has a wired writer branch for it (from +the execution map's `writerNodes`). The frontend renders only visible options, +so a user can't pick an output type that would write nothing. The runner +enforces this server-side too: a submitted switch value whose option is +`visible: false` is rejected with a 400 before queueing (the error lists only +the visible choices), so a direct API caller can't bypass the frontend and +queue a run with no writer. + +`sameAsInput` is now conditional: "Same as input" is offered **only when every +selectable input type has a matching wired output writer** (a format-preserving +setup — EXR→EXR, QT→QT, Img→Img all wired). Otherwise it could resolve to a type +with no writer (an EXR source in a QT-only setup), so it is dropped and the +control offers only the concrete wired types. The `default` is the author's +`output_mode` widget when it names a wired type; else "Same as input" when safe; +else the first wired type — so an EXR-in / QT-only setup defaults to QT. +`output_mode`/`input_switch` on `Koolook_PublishOutput` remain optional: +`output_mode` sets the preferred default, and nothing needs wiring for the +output control to appear. + ### `Koolook_PublishRouter` Placed in the `Koolook Output` area when one setup switch selects between @@ -164,7 +210,7 @@ each `Koolook_PublishInput.switch` value: | Field/output | Purpose | |---|---| -| `selector` | INT input wired from `Koolook_PublishInput.switch`. | +| `selector` | INT input wired from `Koolook_PublishInput.switch` (output type follows input) **or** `Koolook_PublishOutput.switch` (independent output type). The publisher tags the resulting execution-map router with `switchKey` `switch` or `output_switch` accordingly. | | `payload` | Main setup payload, such as the mask/image result from the body graph. | | `EXR` | Output slot 0, connected to the EXR writer branch. | | `QT` | Output slot 1, connected to the QuickTime/movie writer branch. | @@ -197,6 +243,12 @@ the path based on extension/type. When the result node is omitted, the runner can still execute the selected writer branch through `Koolook_PublishRouter`; the frontend should display returned writer/history items. +When the result is fed by a mode-switch node, the runner reports the branch for +whichever switch drives that node — the input switch **or** the independent +output switch. So a divergent EXR-in/QT-out setup whose result index-switch is +wired from `Koolook_PublishOutput.switch` reports the QT movie path, matching +the file actually written. + Decision: the first public result contract is the resolved path string. The important requirement is that the string matches what the real setup resolved inside ComfyUI. A frontend may optionally preview images or other known file @@ -220,6 +272,7 @@ If QT: If Img: Single file: +Output type: [Same as input] [EXR] [QT] [Img] Output folder: Output name: Version: @@ -234,6 +287,7 @@ The frontend should read this from `setupSurface.app`, not from arbitrary ComfyUI node classes: - `setupSurface.app.switch` +- `setupSurface.app.outputSwitch` (optional; independent output type) - `setupSurface.app.inputs` - `setupSurface.app.outputs` - `setupSurface.app.results` @@ -331,6 +385,10 @@ Implemented: - `setupSurface.app` support in the server schema and runner summaries. - Switch-selected writer execution maps let the runner prune unselected writer branches and report the selected result/writer output. +- Independent output type: `Koolook_PublishOutput.output_mode`/`switch`, the + `setupSurface.app.outputSwitch` surface, `output_switch`-keyed execution-map + routers, result-path reporting that follows the output switch, and output-type + options auto-hidden when their writer branch is unwired. - Publish contract nodes: - `Koolook_PublishInput` - `Koolook_PublishOutput` diff --git a/docs/user_guide/nodes/koolook_publish/publish_contract_nodes.md b/docs/user_guide/nodes/koolook_publish/publish_contract_nodes.md index e5f85b8..c964641 100644 --- a/docs/user_guide/nodes/koolook_publish/publish_contract_nodes.md +++ b/docs/user_guide/nodes/koolook_publish/publish_contract_nodes.md @@ -19,8 +19,9 @@ The nodes are not image processors. They are contract markers: - `Koolook_PublishInput` declares source inputs such as an EXR sequence folder, QuickTime file, single image/file, or prompt. -- `Koolook_PublishOutput` declares output folder/name/version controls. -- `Koolook_PublishRouter` connects the selected source mode to matching writer +- `Koolook_PublishOutput` declares output folder/name/version controls, plus an + optional independent **output type** (so an EXR source can write a QT movie). +- `Koolook_PublishRouter` connects the selected mode to matching writer branches so only the chosen branch runs. - `Koolook_PublishResult` optionally reports the final resolved path or status string back to the external app. @@ -57,16 +58,20 @@ The publisher records nearby nodes for review, but only recognized | `folder` | Destination folder for generated outputs. | | `name` | Base output name. | | `version` | Version token or number. | +| `output_mode` | Output type: `Same as input`, `EXR`, `QT`, or `Img`. `Same as input` (default) follows the input type; a concrete choice writes a different type than the source. `Prompt` is not an output format. | +| `input_switch` (input) | Wire `Koolook_PublishInput.switch` here. Used only when `output_mode` is `Same as input`, so the output type mirrors the chosen input type. | +| `switch` (output) | Resolved output-type index. Wire into `Koolook_PublishRouter.selector` to drive the writer branch. | ### `Koolook_PublishRouter` -Wire `Koolook_PublishInput.switch` into `selector`, and wire the workflow's -main payload into `payload`. Connect each router output to the writer branch -for that source mode: +The router **is** the output selector. Wire the workflow's main payload into +`payload`, wire something into `selector` (typically the input switch — its +value is the "Same as input" default), and connect each output slot to the +writer branch for that type: ```text -Koolook_PublishInput.switch -> Koolook_PublishRouter.selector workflow payload -> Koolook_PublishRouter.payload +Koolook_PublishInput.switch -> Koolook_PublishRouter.selector # default follows input Koolook_PublishRouter.EXR -> EXR writer branch Koolook_PublishRouter.QT -> video writer branch @@ -74,8 +79,13 @@ Koolook_PublishRouter.Img -> image writer branch Koolook_PublishRouter.Prompt -> prompt/no-op branch if needed ``` -When the setup runs from the external app, Koolook keeps the selected writer -branch and prunes the unselected branches from the queued prompt. +Whichever branches you wire to writers become the **Output type** options the +external app offers — a user can override to any wired type, defaulting to the +input type (or the `Koolook_PublishOutput.output_mode` widget if you set one). +You do **not** need to wire `Koolook_PublishOutput.switch` anywhere; the router +is auto-detected as the output control. Put the writer nodes inside the +`Koolook Output` group so they're recognized. When the setup runs, Koolook keeps +the chosen writer branch and prunes the rest. ### `Koolook_PublishResult` diff --git a/k_ai_pipeline.py b/k_ai_pipeline.py index 10cb826..401c3f8 100644 --- a/k_ai_pipeline.py +++ b/k_ai_pipeline.py @@ -202,6 +202,27 @@ class EasyAIPipeline: CATEGORY = "Koolook/Pipeline" OUTPUT_NODE = True # Marks it as an output node for workflow integration + @classmethod + def IS_CHANGED(cls, version="", **kwargs): + """Force a re-run whenever auto-versioning is active. + + ``auto``/``next`` resolves the next free ``vNNN`` by scanning the + output folder on disk (``build_pipeline_outputs`` -> ``next_version_token``). + That result depends on filesystem state, but every widget input is a + constant, so ComfyUI would memoize this node and reuse the *first* + run's path for the whole session — the scan never re-runs, the version + never advances, and the downstream save node then refuses to overwrite + the already-written file. Returning NaN (which never compares equal to + itself) marks the node perpetually dirty so the scan runs every queue. + + For a literal/explicit version (``v001``, ``final``, a wired token) the + node stays cacheable — ComfyUI's normal input hashing already + invalidates it when the token changes, so we return a stable value. + """ + if is_auto_version(version): + return float("nan") + return False + def generate_pipeline(self, shot_duration, seed_value, instruction, base_directory_path, extension, shot_name, ai_method, version, disable_versioning, enable_overwrite, no_subfolders): return build_pipeline_outputs( shot_duration=shot_duration, diff --git a/k_publish_contract.py b/k_publish_contract.py index 0d84d7b..1d1f194 100644 --- a/k_publish_contract.py +++ b/k_publish_contract.py @@ -10,6 +10,15 @@ from __future__ import annotations INPUT_MODES: tuple[str, ...] = ("EXR", "QT", "Img", "Prompt") INPUT_MODE_TO_INDEX = {name: index for index, name in enumerate(INPUT_MODES)} +# Output type is a strict subset of the input modes: ``Prompt`` is a source / +# no-op branch, never a real output format. ``Same as input`` is the default — +# it passes the wired input switch straight through so an unmodified setup +# writes the same type it reads; picking a concrete type overrides that. +SAME_AS_INPUT = "Same as input" +OUTPUT_MODES: tuple[str, ...] = (SAME_AS_INPUT, "EXR", "QT", "Img") +OUTPUT_MODE_TO_INDEX = {"EXR": 0, "QT": 1, "Img": 2} +_DEFAULT_SWITCH_INDEX = INPUT_MODE_TO_INDEX["Img"] # 2 + def _resolve_mode_index(mode) -> int: if isinstance(mode, bool): @@ -24,6 +33,46 @@ def _resolve_mode_index(mode) -> int: return INPUT_MODE_TO_INDEX.get(text, INPUT_MODE_TO_INDEX["Img"]) +def _coerce_switch_index(value, default: int = _DEFAULT_SWITCH_INDEX) -> int: + """Coerce a wired/typed switch value to an int branch index. + + ``bool`` is rejected first (``True``/``False`` are ints in Python but never + a real branch index); everything else falls back to ``default`` (Img). + """ + if isinstance(value, bool): + return default + if isinstance(value, int): + return value + text = str(value).strip() + if text.isdigit(): + return int(text) + return default + + +def _resolve_output_switch(output_mode, input_switch) -> int: + """Resolve the writer-branch index emitted by ``Koolook_PublishOutput``. + + ``Same as input`` (the default) passes the wired ``input_switch`` through, + so a setup writes the same type it reads unless the author/app overrides it. + A concrete ``EXR``/``QT``/``Img`` — as the combo label OR the numeric index + the external runner injects into ``output_mode`` — overrides it. ``Prompt`` + is not an output format, so an index of ``3`` is treated as "no override" + and falls back to the input switch too. + """ + if not isinstance(output_mode, bool): + if isinstance(output_mode, int): + if output_mode in OUTPUT_MODE_TO_INDEX.values(): + return output_mode + else: + text = str(output_mode).strip() + if text in OUTPUT_MODE_TO_INDEX: + return OUTPUT_MODE_TO_INDEX[text] + if text.isdigit() and int(text) in OUTPUT_MODE_TO_INDEX.values(): + return int(text) + # "Same as input" / Prompt / anything unrecognized -> follow the input switch. + return _coerce_switch_index(input_switch) + + class Koolook_PublishInput: """App-facing setup inputs with stable output names for maintainers.""" @@ -127,17 +176,42 @@ class Koolook_PublishOutput: "tooltip": "Output version token or number exposed to the external app.", }, ), + "output_mode": ( + list(OUTPUT_MODES), + { + "default": SAME_AS_INPUT, + "tooltip": "Writer branch to route the payload into. 'Same as input' passes the wired input switch through (output type follows input type); pick EXR/QT/Img to write a different type than the source. The 'switch' output wires into Koolook Publish Router's selector.", + }, + ), + }, + "optional": { + "input_switch": ( + "INT", + { + "default": _DEFAULT_SWITCH_INDEX, + "min": 0, + "max": len(INPUT_MODES) - 1, + "tooltip": "Wire Koolook Publish Input's 'switch' output here. Used only when output_mode is 'Same as input' — it makes the output type follow the chosen input type. Ignored when a concrete output_mode is selected.", + }, + ), }, } - RETURN_TYPES = ("STRING", "STRING", "STRING") - RETURN_NAMES = ("folder", "name", "version") + RETURN_TYPES = ("STRING", "STRING", "STRING", "INT") + RETURN_NAMES = ("folder", "name", "version", "switch") CATEGORY = "Koolook/Publish" FUNCTION = "run" - DESCRIPTION = "Declare output folder, name, and version controls for a published setup." + DESCRIPTION = "Declare output folder, name, version, and output-type switch for a published setup." - def run(self, folder: str, name: str, version: str): - return (folder, name, version) + def run( + self, + folder: str, + name: str, + version: str, + output_mode: str = SAME_AS_INPUT, + input_switch: int = _DEFAULT_SWITCH_INDEX, + ): + return (folder, name, version, _resolve_output_switch(output_mode, input_switch)) class Koolook_PublishResult: diff --git a/koolook_setup_runner.py b/koolook_setup_runner.py index 003b9fb..cb65d48 100644 --- a/koolook_setup_runner.py +++ b/koolook_setup_runner.py @@ -241,15 +241,22 @@ def _declared_input_fields(setup: dict[str, Any]) -> dict[str, dict[str, Any]]: target = field.get("target") if isinstance(key, str) and key and isinstance(target, dict): fields.setdefault(key, field) - switch = app.get("switch") - if isinstance(switch, dict): - key = switch.get("key") - target = switch.get("target") - if isinstance(key, str) and key and isinstance(target, dict): - fields.setdefault(key, switch) + for switch_key in ("switch", "outputSwitch"): + switch = app.get(switch_key) + if isinstance(switch, dict): + key = switch.get("key") + target = switch.get("target") + if isinstance(key, str) and key and isinstance(target, dict): + fields.setdefault(key, switch) return fields +def _is_switch_field(field: Any) -> bool: + """A declared field is a switch (input type OR output type) when it carries + an ``options`` list — the two output/input path fields never do.""" + return isinstance(field, dict) and isinstance(field.get("options"), list) + + def _validate_run_inputs(input_fields: dict[str, dict[str, Any]], inputs: dict[str, Any]) -> list[str]: errors: list[str] = [] for key in inputs: @@ -257,7 +264,7 @@ def _validate_run_inputs(input_fields: dict[str, dict[str, Any]], inputs: dict[s errors.append(f"input '{key}' is not declared by this setup") continue field = input_fields[key] - if field.get("key") == "switch" and _switch_option_value(field, inputs[key]) is None: + if _is_switch_field(field) and _switch_option_value(field, inputs[key]) is None: errors.append(_switch_value_error(key, field)) for key, field in input_fields.items(): if field.get("required") is True and key not in inputs: @@ -266,7 +273,7 @@ def _validate_run_inputs(input_fields: dict[str, dict[str, Any]], inputs: dict[s def _prompt_value_for_field(field: dict[str, Any], value: Any) -> Any: - if field.get("key") != "switch": + if not _is_switch_field(field): return value selected_value = _switch_option_value(field, value) for option in field.get("options", []): @@ -283,6 +290,11 @@ def _switch_option_value(switch: dict[str, Any], selected: Any) -> int | None: for option in switch.get("options", []): if not isinstance(option, dict): continue + # Hidden options (e.g. output types with no wired writer branch) are + # not selectable through the API either — the frontend never offers + # them, and accepting one here would queue a run that writes nothing. + if option.get("visible") is False: + continue value = option.get("value") if isinstance(value, bool) or not isinstance(value, int): continue @@ -296,7 +308,7 @@ def _switch_option_value(switch: dict[str, Any], selected: Any) -> int | None: def _switch_value_error(key: str, switch: dict[str, Any]) -> str: choices: list[str] = [] for option in switch.get("options", []): - if not isinstance(option, dict): + if not isinstance(option, dict) or option.get("visible") is False: continue value = option.get("value") label = option.get("label") @@ -512,6 +524,23 @@ def _prune_prompt_for_execution_map( ) +def _app_switch_by_key(app: Any, key: Any) -> dict[str, Any] | None: + """Return the app switch (input or output) whose ``key`` matches. + + Routers are tagged with a ``switchKey`` at publish time; a writer router + driven by the output-type control carries ``output_switch`` while a + source router carries ``switch``. Resolve either so validation and pruning + read the right submitted value. + """ + if not isinstance(app, dict) or not isinstance(key, str): + return None + for candidate_key in ("switch", "outputSwitch"): + switch = app.get(candidate_key) + if isinstance(switch, dict) and switch.get("key") == key: + return switch + return None + + def _execution_map_selected_value( setup: dict[str, Any], router: dict[str, Any], @@ -519,9 +548,9 @@ def _execution_map_selected_value( ) -> int | None: key = router.get("switchKey") app = setup.get("setupSurface", {}).get("app", {}) - switch = app.get("switch") if isinstance(app, dict) else None - if isinstance(switch, dict) and switch.get("key") == key: - return _selected_switch_value(switch, run_inputs) + switch = _app_switch_by_key(app, key) + if isinstance(switch, dict): + return _selected_switch_value(switch, run_inputs, app) selected = run_inputs.get(key) if isinstance(key, str) else None if isinstance(selected, bool): return None @@ -542,15 +571,15 @@ def _validate_execution_map_inputs(setup: dict[str, Any], run_inputs: dict[str, api_prompt = setup.get("apiPrompt") prompt_node_ids = set(api_prompt) if isinstance(api_prompt, dict) else set() app = setup.get("setupSurface", {}).get("app", {}) - switch = app.get("switch") if isinstance(app, dict) else None errors: list[str] = [] for router in routers: if not isinstance(router, dict): continue selected_value = _execution_map_selected_value(setup, router, run_inputs) key = router.get("switchKey") + switch = _app_switch_by_key(app, key) if selected_value is None: - if isinstance(switch, dict) and switch.get("key") == key and isinstance(key, str): + if isinstance(switch, dict) and isinstance(key, str): if key not in run_inputs: errors.append(_switch_value_error(key, switch)) elif isinstance(key, str): @@ -677,19 +706,18 @@ def _selected_result_switches( api_prompt: dict[str, Any], run_inputs: dict[str, Any], ) -> dict[str, tuple[str, int]]: - app = setup.get("setupSurface", {}).get("app", {}) - if not isinstance(app, dict): - return {} - switch = app.get("switch") - if not isinstance(switch, dict): - return {} - selected_value = _selected_switch_value(switch, run_inputs) - if selected_value is None: - return {} selected: dict[str, tuple[str, int]] = {} for field in _app_result_fields(setup): - result_node_id = _result_switch_node_id(setup, field, run_inputs, api_prompt) - if result_node_id is not None: + found = _result_switch_node_and_switch(setup, field, run_inputs, api_prompt) + if found is None: + continue + result_node_id, switch = found + # Use the value of the switch that actually drives this result switch + # (input OR output), so a divergent EXR-in/QT-out setup reports the QT + # path rather than the input-type path. + app = setup.get("setupSurface", {}).get("app", {}) + selected_value = _selected_switch_value(switch, run_inputs, app) + if selected_value is not None: selected[result_node_id] = (result_node_id, selected_value) return selected @@ -702,28 +730,34 @@ def _selected_app_switches( app = setup.get("setupSurface", {}).get("app", {}) if not isinstance(app, dict): return {} - switch = app.get("switch") - if not isinstance(switch, dict): - return {} - switch_target = switch.get("target") - if not isinstance(switch_target, dict): - return {} - selected_value = _selected_switch_value(switch, run_inputs) - if selected_value is None: + # Both the input switch and the (independent) output switch can drive + # index-switch nodes in the graph; resolve each so writer-side branches + # selected by output type are pruned as precisely as source-side ones. + switches = [ + switch + for switch in (app.get("switch"), app.get("outputSwitch")) + if isinstance(switch, dict) and isinstance(switch.get("target"), dict) + ] + if not switches: return {} selected: dict[str, tuple[str, int]] = {} - for node_id, node in api_prompt.items(): - if not isinstance(node, dict): + for switch in switches: + switch_target = switch["target"] + selected_value = _selected_switch_value(switch, run_inputs, app) + if selected_value is None: continue - inputs = node.get("inputs") - if not isinstance(inputs, dict): - continue - selector_ref = _switch_selector_ref(inputs) - if not _selector_matches_switch_target(selector_ref, switch_target, api_prompt): - continue - if _is_api_ref(inputs.get(f"value{selected_value}")): - selected[str(node_id)] = (str(node_id), selected_value) + for node_id, node in api_prompt.items(): + if not isinstance(node, dict): + continue + inputs = node.get("inputs") + if not isinstance(inputs, dict): + continue + selector_ref = _switch_selector_ref(inputs) + if not _selector_matches_switch_target(selector_ref, switch_target, api_prompt): + continue + if _is_api_ref(inputs.get(f"value{selected_value}")): + selected[str(node_id)] = (str(node_id), selected_value) return selected @@ -788,20 +822,30 @@ def _app_result_fields(setup: dict[str, Any]) -> list[dict[str, Any]]: return [field for field in app["results"] if isinstance(field, dict)] -def _result_switch_node_id( +def _result_switch_node_and_switch( setup: dict[str, Any], result_field: dict[str, Any], run_inputs: dict[str, Any], api_prompt: dict[str, Any] | None = None, -) -> str | None: +) -> tuple[str, dict[str, Any]] | None: + """Find the mode-switch node feeding a result field, and the app switch that + drives it. + + A result field (e.g. ``Koolook_PublishResult.result``) is often fed by an + index-switch node so the displayed path matches the selected branch. That + switch may be driven by the input switch OR the independent output switch — + match against whichever one is wired to its selector so the reported result + follows the type actually written, not the source type. + """ app = setup.get("setupSurface", {}).get("app", {}) if not isinstance(app, dict): return None - switch = app.get("switch") - if not isinstance(switch, dict): - return None - selected_value = _selected_switch_value(switch, run_inputs) - if selected_value is None: + candidate_switches = [ + switch + for switch in (app.get("switch"), app.get("outputSwitch")) + if isinstance(switch, dict) and isinstance(switch.get("target"), dict) + ] + if not candidate_switches: return None api_prompt = api_prompt if isinstance(api_prompt, dict) else setup.get("apiPrompt") @@ -828,16 +872,17 @@ def _result_switch_node_id( if not isinstance(switch_inputs, dict): return None selector_ref = _switch_selector_ref(switch_inputs) - switch_target = switch.get("target") - if isinstance(switch_target, dict) and not _selector_matches_switch_target( - selector_ref, - switch_target, - api_prompt, - ): - return None - if not _is_api_ref(switch_inputs.get(f"value{selected_value}")): - return None - return switch_node_id + # Prefer whichever switch the result index-switch selector is wired from. + for switch in candidate_switches: + if not _selector_matches_switch_target(selector_ref, switch["target"], api_prompt): + continue + selected_value = _selected_switch_value(switch, run_inputs, app) + if selected_value is None: + continue + if not _is_api_ref(switch_inputs.get(f"value{selected_value}")): + continue + return (switch_node_id, switch) + return None def _selected_result_branch_ref( @@ -848,27 +893,43 @@ def _selected_result_branch_ref( api_prompt = setup.get("apiPrompt") if not isinstance(api_prompt, dict): return None - switch_node_id = _result_switch_node_id(setup, result_field, run_inputs, api_prompt) - if switch_node_id is None: + found = _result_switch_node_and_switch(setup, result_field, run_inputs, api_prompt) + if found is None: return None + switch_node_id, switch = found switch_node = api_prompt.get(switch_node_id) if not isinstance(switch_node, dict): return None switch_inputs = switch_node.get("inputs") if not isinstance(switch_inputs, dict): return None - switch = setup.get("setupSurface", {}).get("app", {}).get("switch") - selected_value = _selected_switch_value(switch, run_inputs) if isinstance(switch, dict) else None + app = setup.get("setupSurface", {}).get("app", {}) + selected_value = _selected_switch_value(switch, run_inputs, app) if selected_value is None: return None branch_ref = switch_inputs.get(f"value{selected_value}") return branch_ref if _is_api_ref(branch_ref) else None -def _selected_switch_value(switch: dict[str, Any], run_inputs: dict[str, Any]) -> int | None: +def _selected_switch_value( + switch: dict[str, Any], + run_inputs: dict[str, Any], + app: Any = None, +) -> int | None: key = switch.get("key") - selected = run_inputs.get(key) if isinstance(key, str) and key in run_inputs else switch.get("default") - return _switch_option_value(switch, selected) + submitted = isinstance(key, str) and key in run_inputs + selected = run_inputs[key] if submitted else switch.get("default") + value = _switch_option_value(switch, selected) + # A sameAsInput switch may carry the "Same as input" sentinel (-1) as its + # default, which is not a concrete option. When the caller omitted the key, + # follow the input switch — that is what the sentinel means. Submitted + # values are never resolved this way: the contract requires clients to + # submit a concrete option, and validation rejects the sentinel. + if value is None and not submitted and switch.get("sameAsInput") and isinstance(app, dict): + input_switch = app.get("switch") + if isinstance(input_switch, dict) and input_switch is not switch: + return _selected_switch_value(input_switch, run_inputs) + return value def _selector_matches_switch_target(selector_ref: Any, switch_target: dict[str, Any], api_prompt: dict[str, Any]) -> bool: @@ -892,15 +953,23 @@ def _switch_selector_ref(switch_inputs: dict[str, Any]) -> Any: def _publish_input_output_slots(api_node: dict[str, Any]) -> dict[str, int]: - if api_node.get("class_type") != "Koolook_PublishInput": - return {} - return { - "sequence_folder": 0, - "qt_file": 1, - "single_file": 2, - "prompt": 3, - "switch": 4, - } + class_type = api_node.get("class_type") + if class_type == "Koolook_PublishInput": + return { + "sequence_folder": 0, + "qt_file": 1, + "single_file": 2, + "prompt": 3, + "switch": 4, + } + if class_type == "Koolook_PublishOutput": + return { + "folder": 0, + "name": 1, + "version": 2, + "switch": 3, + } + return {} def _resolve_prompt_value(prompt: dict[str, Any], value: Any) -> Any: diff --git a/koolook_setups.py b/koolook_setups.py index b2aa44b..e6818b4 100644 --- a/koolook_setups.py +++ b/koolook_setups.py @@ -49,6 +49,16 @@ PUBLISH_INPUT_MODES = ( (2, "Img", "single_file"), (3, "Prompt", "prompt"), ) +# Output-type modes are a subset of the input modes (no Prompt — it isn't a +# real output format). The indices deliberately match PUBLISH_INPUT_MODES so a +# router slot means the same type on either switch. -1 is the "Same as input" +# sentinel the author leaves in place to keep output type following input type. +PUBLISH_OUTPUT_MODES = ( + (0, "EXR"), + (1, "QT"), + (2, "Img"), +) +PUBLISH_OUTPUT_SAME_AS_INPUT = -1 PUBLISH_OUTPUT_FIELDS = ( ("folder", "Output folder", True), ("name", "Output name", True), @@ -60,7 +70,7 @@ PUBLISH_RESULT_FIELDS = ( WIDGET_ONLY_INPUTS_BY_CLASS = { "Text Multiline": ("text",), "Koolook_PublishInput": ("mode", "sequence_folder", "qt_file", "single_file", "prompt"), - "Koolook_PublishOutput": ("folder", "name", "version"), + "Koolook_PublishOutput": ("folder", "name", "version", "output_mode"), "Koolook_PublishResult": ("result",), "Koolook_PublishRouter": (), "EasyAIPipeline": ( @@ -81,6 +91,12 @@ WIDGET_ONLY_INPUT_DEFAULTS = { "EasyAIPipeline": { "no_subfolders": False, }, + # Older published setups authored their Koolook_PublishOutput with only + # folder/name/version widgets. Default the new output_mode to "Same as + # input" so those setups keep validating and behave exactly as before. + "Koolook_PublishOutput": { + "output_mode": "Same as input", + }, } REQUIRED_SETUP_KEYS = ( @@ -397,9 +413,91 @@ def _build_draft_setup( execution_map = _build_execution_map(setup) if execution_map is not None: setup["executionMap"] = execution_map + _reconcile_output_switch(setup, execution_map) return setup +def _reconcile_output_switch( + setup: dict[str, Any], + execution_map: dict[str, Any], +) -> None: + """Expose only the output types that have a wired writer branch, decide + whether "Same as input" is safe, and pick a valid default. + + A ``Koolook_PublishRouter`` is the output selector; the execution map records + ``writerNodes`` per branch. An output type is offered (``visible``) when some + output router has a non-empty writer branch for it. + + "Same as input" is only offered (``sameAsInput``) when **every selectable + input type has a wired output writer** — otherwise it could resolve to a type + with no writer (e.g. an EXR source in a setup that only wired a QT writer), + so it is dropped. The default is the author's ``output_mode`` when that names + a wired type; else "Same as input" when safe; else the first wired type. + """ + app = setup.get("setupSurface", {}).get("app") + if not isinstance(app, dict): + return + output_switch = app.get("outputSwitch") + if not isinstance(output_switch, dict): + return + options = output_switch.get("options") + if not isinstance(options, list): + return + + available: set[int] = set() + routers = execution_map.get("routers") if isinstance(execution_map, dict) else None + if isinstance(routers, list): + for router in routers: + if not isinstance(router, dict) or router.get("switchKey") != "output_switch": + continue + branches = router.get("branches") + if not isinstance(branches, dict): + continue + for branch in branches.values(): + if not isinstance(branch, dict): + continue + writer_nodes = branch.get("writerNodes") + value = branch.get("output") + if isinstance(writer_nodes, list) and writer_nodes and isinstance(value, int): + available.add(value) + + ordered_available = [ + option.get("value") + for option in options + if isinstance(option, dict) and option.get("value") in available + ] + for option in options: + if not isinstance(option, dict): + continue + option["visible"] = option.get("value") in available + + # "Same as input" is safe only when every input type the user can pick has a + # matching wired output writer (a format-preserving setup). Otherwise it + # could select an unwritable type, so drop it. + input_switch = app.get("switch") + selectable_inputs: set[int] = set() + if isinstance(input_switch, dict) and isinstance(input_switch.get("options"), list): + for option in input_switch["options"]: + if ( + isinstance(option, dict) + and option.get("visible") is not False + and isinstance(option.get("value"), int) + ): + selectable_inputs.add(option["value"]) + same_as_input_safe = bool(selectable_inputs) and selectable_inputs <= available + output_switch["sameAsInput"] = same_as_input_safe + + # Default: keep the author's concrete wired choice; else "Same as input" when + # safe; else the first wired type. + current_default = output_switch.get("default") + if isinstance(current_default, int) and current_default in available: + pass + elif same_as_input_safe: + output_switch["default"] = PUBLISH_OUTPUT_SAME_AS_INPUT + elif ordered_available: + output_switch["default"] = ordered_available[0] + + def _infer_setup_surface(visual_graph: dict[str, Any]) -> dict[str, Any]: return { "sourceInputs": _nodes_in_named_groups(visual_graph, "Koolook Input"), @@ -427,6 +525,8 @@ def _infer_app_surface(visual_graph: dict[str, Any]) -> dict[str, Any]: } if input_node is not None: app["switch"] = _publish_input_switch(input_node, inputs) + if output_node is not None: + app["outputSwitch"] = _publish_output_switch(output_node) return app @@ -546,14 +646,49 @@ def _publish_input_mode_index(value: Any) -> int: return 2 +def _publish_output_switch(node: dict[str, Any]) -> dict[str, Any]: + """App-surface descriptor for the independent output-type switch. + + Mirrors ``_publish_input_switch`` but for ``Koolook_PublishOutput``. The + ``target`` is the node's ``output_mode`` input (where the runner injects the + submitted value). ``sameAsInput`` tells the external frontend to offer a + "Same as input" choice that follows the input switch; ``default`` is + ``-1`` when the author left the widget on "Same as input". + """ + return { + "key": "output_switch", + "label": "Output type", + "visible": True, + "sameAsInput": True, + "target": {"node": str(node.get("id")), "input": "output_mode"}, + "default": _publish_output_mode_index(_app_widget_value(node, "output_mode")), + "options": [ + {"value": value, "label": label, "visible": True} + for value, label in PUBLISH_OUTPUT_MODES + ], + } + + +def _publish_output_mode_index(value: Any) -> int: + if isinstance(value, int): + return value + text = str(value).strip().lower() + for mode_value, label in PUBLISH_OUTPUT_MODES: + if text == label.lower(): + return mode_value + return PUBLISH_OUTPUT_SAME_AS_INPUT + + def _build_execution_map(setup: dict[str, Any]) -> dict[str, Any] | None: api_prompt = setup.get("apiPrompt") if not isinstance(api_prompt, dict): return None app = setup.get("setupSurface", {}).get("app", {}) - switch = app.get("switch") if isinstance(app, dict) else None - switch_target = switch.get("target") if isinstance(switch, dict) else None - if not isinstance(switch_target, dict): + input_switch = app.get("switch") if isinstance(app, dict) else None + output_switch = app.get("outputSwitch") if isinstance(app, dict) else None + has_output_switch = isinstance(output_switch, dict) and isinstance(output_switch.get("target"), dict) + has_input_switch = isinstance(input_switch, dict) and isinstance(input_switch.get("target"), dict) + if not has_output_switch and not has_input_switch: return None output_surface_ids = _setup_output_surface_node_ids(setup) if not output_surface_ids: @@ -567,7 +702,18 @@ def _build_execution_map(setup: dict[str, Any]) -> dict[str, Any] | None: continue selector_ref = inputs.get("selector") payload_ref = inputs.get("payload") - if not _api_ref_matches_target_output(selector_ref, switch_target, api_prompt, "switch"): + # A Koolook_PublishRouter is inherently an OUTPUT selector — its slots + # are named EXR/QT/Img/Prompt. So when the setup exposes an output + # switch, key every router to it and let the app user pick the output + # type (defaulting to follow-input), regardless of what the selector is + # physically wired from. The wired branches become the offered output + # types. Legacy setups with no output switch fall back to the input + # switch, and only if the selector is actually wired from it. + if has_output_switch: + switch_key = str(output_switch.get("key", "output_switch")) + elif _api_ref_matches_target_output(selector_ref, input_switch["target"], api_prompt, "switch"): + switch_key = str(input_switch.get("key", "switch")) + else: continue branches: dict[str, dict[str, Any]] = {} for value, label, _input_key in PUBLISH_INPUT_MODES: @@ -583,10 +729,11 @@ def _build_execution_map(setup: dict[str, Any]) -> dict[str, Any] | None: } router: dict[str, Any] = { "node": str(node_id), - "switchKey": str(switch.get("key", "switch")), - "selector": {"node": str(selector_ref[0]), "output": int(selector_ref[1])}, + "switchKey": switch_key, "branches": branches, } + if _is_api_ref(selector_ref): + router["selector"] = {"node": str(selector_ref[0]), "output": int(selector_ref[1])} if _is_api_ref(payload_ref): router["payload"] = {"node": str(payload_ref[0]), "output": int(payload_ref[1])} routers.append(router) @@ -675,15 +822,30 @@ def _api_ref_matches_target_output( def _publish_input_output_slots(api_node: dict[str, Any]) -> dict[str, int]: - if api_node.get("class_type") != PUBLISH_INPUT_CLASS: - return {} - return { - "sequence_folder": 0, - "qt_file": 1, - "single_file": 2, - "prompt": 3, - "switch": 4, - } + """Output-slot name -> index for a publish contract node. + + Both ``Koolook_PublishInput`` and ``Koolook_PublishOutput`` expose a + ``switch`` output that a router's ``selector`` can be wired from; the + executionMap matcher uses this to tie a router to whichever switch drives + it. + """ + class_type = api_node.get("class_type") + if class_type == PUBLISH_INPUT_CLASS: + return { + "sequence_folder": 0, + "qt_file": 1, + "single_file": 2, + "prompt": 3, + "switch": 4, + } + if class_type == PUBLISH_OUTPUT_CLASS: + return { + "folder": 0, + "name": 1, + "version": 2, + "switch": 3, + } + return {} def _is_api_ref(value: Any) -> bool: @@ -845,6 +1007,18 @@ def _validate_surface_app( api_prompt, input_keys, ) + if "outputSwitch" in app: + # The output switch selects a writer branch, not a source field, so its + # options carry no `input` mapping (require_option_input=False). + _validate_surface_app_switch( + app["outputSwitch"], + f"{path}.outputSwitch", + diagnostics, + node_by_id, + api_prompt, + input_keys, + require_option_input=False, + ) def _validate_surface_app_fields( @@ -883,6 +1057,8 @@ def _validate_surface_app_switch( node_by_id: dict[str, dict[str, Any]], api_prompt: dict[str, Any] | None, input_keys: set[str], + *, + require_option_input: bool = True, ) -> None: if not isinstance(switch, dict): diagnostics.append(f"{path} must be a JSON object") @@ -913,14 +1089,15 @@ def _validate_surface_app_switch( if isinstance(option.get("value"), bool) or not isinstance(option.get("value"), int): diagnostics.append(f"{option_path}.value must be an integer") _validate_non_empty_text(option.get("label"), f"{option_path}.label", diagnostics) - _validate_non_empty_text(option.get("input"), f"{option_path}.input", diagnostics) - if ( - isinstance(option.get("input"), str) - and option["input"].strip() - and input_keys - and option["input"] not in input_keys - ): - diagnostics.append(f"{option_path}.input must match a setupSurface.app.inputs key") + if require_option_input: + _validate_non_empty_text(option.get("input"), f"{option_path}.input", diagnostics) + if ( + isinstance(option.get("input"), str) + and option["input"].strip() + and input_keys + and option["input"] not in input_keys + ): + diagnostics.append(f"{option_path}.input must match a setupSurface.app.inputs key") if "visible" in option and not isinstance(option["visible"], bool): diagnostics.append(f"{option_path}.visible must be a boolean") diff --git a/tests/nodes/test_easy_ai_pipeline.py b/tests/nodes/test_easy_ai_pipeline.py index a4034e4..53a6ca1 100644 --- a/tests/nodes/test_easy_ai_pipeline.py +++ b/tests/nodes/test_easy_ai_pipeline.py @@ -15,6 +15,7 @@ end-to-end ``generate_pipeline`` behavior so the bug can't regress. """ from __future__ import annotations +import math from pathlib import Path import pytest @@ -771,3 +772,56 @@ class TestStringVersionField: assert version_string == "" assert output_directory == f"{canonical}/shot" assert name == "shot.exr" + + +# --------------------------------------------------------------------------- +# IS_CHANGED — cache invalidation for auto-versioning +# --------------------------------------------------------------------------- +# +# ``version="auto"`` (or ``"next"``) resolves the next free vNNN by scanning +# the output folder on disk. That result depends on filesystem state, but +# every widget input is a constant — so without an IS_CHANGED signal ComfyUI +# memoizes the node, runs the scan exactly once per session, and freezes on +# the first run's path. The version never advances and the downstream save +# node then refuses to overwrite the already-written file. IS_CHANGED must +# report the node as perpetually dirty while auto-versioning is active, and +# stay cacheable for literal tokens. + + +class TestIsChangedAutoVersion: + @pytest.mark.parametrize("token", ["auto", "next", "AUTO", "Next", " auto "]) + def test_auto_tokens_force_rerun(self, token: str): + """Auto-detection modes must return NaN so ComfyUI treats the node as + always-dirty and re-scans the disk on every queue.""" + result = EasyAIPipeline.IS_CHANGED(version=token) + assert isinstance(result, float) and math.isnan(result), ( + f"version={token!r} should force a re-run (NaN), got {result!r}" + ) + + @pytest.mark.parametrize("token", ["v001", "final", "2", "", "v007"]) + def test_literal_tokens_stay_cacheable(self, token: str): + """Explicit/literal versions must NOT return NaN — ComfyUI's normal + input hashing already invalidates the node when the token changes, so + forcing a re-run every queue would just waste a rescan.""" + result = EasyAIPipeline.IS_CHANGED(version=token) + assert not (isinstance(result, float) and math.isnan(result)), ( + f"version={token!r} should stay cacheable, got NaN" + ) + + def test_full_input_kwargs_accepted(self): + """ComfyUI calls IS_CHANGED with the node's full input set as kwargs; + the signature must tolerate all of them, not just ``version``.""" + result = EasyAIPipeline.IS_CHANGED( + shot_duration=81, + seed_value=1, + instruction="(ignored)", + base_directory_path="n:/foo", + extension=".exr", + shot_name="shot", + ai_method="", + version="auto", + disable_versioning=False, + enable_overwrite=False, + no_subfolders=False, + ) + assert math.isnan(result) diff --git a/tests/nodes/test_publish_contract.py b/tests/nodes/test_publish_contract.py index 51ded6c..827eb20 100644 --- a/tests/nodes/test_publish_contract.py +++ b/tests/nodes/test_publish_contract.py @@ -9,6 +9,7 @@ from k_publish_contract import ( Koolook_PublishRouter, NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS, + _resolve_output_switch, ) @@ -57,15 +58,37 @@ def test_publish_input_accepts_published_numeric_switch_values() -> None: def test_publish_output_exposes_stable_fields() -> None: - spec = Koolook_PublishOutput.INPUT_TYPES()["required"] + spec = Koolook_PublishOutput.INPUT_TYPES() + required = spec["required"] + optional = spec.get("optional", {}) - assert list(spec) == ["folder", "name", "version"] - assert Koolook_PublishOutput.RETURN_NAMES == ("folder", "name", "version") + assert list(required) == ["folder", "name", "version", "output_mode"] + assert required["output_mode"][0] == ["Same as input", "EXR", "QT", "Img"] + assert list(optional) == ["input_switch"] + assert Koolook_PublishOutput.RETURN_NAMES == ("folder", "name", "version", "switch") + + # Default output_mode ("Same as input") passes the wired input switch through. assert Koolook_PublishOutput().run( folder="/out", name="mask", version="1", - ) == ("/out", "mask", "1") + input_switch=1, + ) == ("/out", "mask", "1", 1) + + +def test_publish_output_switch_resolution() -> None: + """output_mode picks the writer branch; 'Same as input' follows input_switch.""" + # Concrete override by label -> that branch index (EXR=0, QT=1, Img=2). + assert Koolook_PublishOutput().run("/o", "n", "1", "QT", 0)[-1] == 1 + assert Koolook_PublishOutput().run("/o", "n", "1", "EXR", 2)[-1] == 0 + # Concrete override by numeric index (external runner injects an int). + assert Koolook_PublishOutput().run("/o", "n", "1", 2, 0)[-1] == 2 + # "Same as input" (and the Prompt index 3, which is not an output type) + # both fall back to the input switch. + assert Koolook_PublishOutput().run("/o", "n", "1", "Same as input", 1)[-1] == 1 + assert _resolve_output_switch(3, 1) == 1 + # Unwired input_switch defaults to Img (2). + assert _resolve_output_switch("Same as input", None) == 2 def test_publish_result_exposes_resolved_result() -> None: diff --git a/tests/server/test_published_setup_registry.py b/tests/server/test_published_setup_registry.py index 4067504..0b345b2 100644 --- a/tests/server/test_published_setup_registry.py +++ b/tests/server/test_published_setup_registry.py @@ -1112,6 +1112,19 @@ def test_publish_setup_infers_app_contract_from_publish_nodes() -> None: {"value": 3, "label": "Prompt", "visible": False, "input": "prompt"}, ], }, + "outputSwitch": { + "key": "output_switch", + "label": "Output type", + "visible": True, + "sameAsInput": True, + "target": {"node": "200", "input": "output_mode"}, + "default": -1, + "options": [ + {"value": 0, "label": "EXR", "visible": True}, + {"value": 1, "label": "QT", "visible": True}, + {"value": 2, "label": "Img", "visible": True}, + ], + }, } @@ -1136,7 +1149,7 @@ def test_publish_setup_stores_execution_map_from_publish_router() -> None: "pos": [520, 60], "size": [360, 240], "inputs": [], - "widgets_values": ["/out", "mask", "1"], + "widgets_values": ["/out", "mask", "1", "Same as input"], }, { "id": 400, @@ -1171,7 +1184,7 @@ def test_publish_setup_stores_execution_map_from_publish_router() -> None: "prompt": "prompt", }, }, - "200": {"class_type": "Koolook_PublishOutput", "inputs": {"folder": "/out", "name": "mask", "version": "1"}}, + "200": {"class_type": "Koolook_PublishOutput", "inputs": {"folder": "/out", "name": "mask", "version": "1", "output_mode": "Same as input", "input_switch": ["100", 4]}}, "300": {"class_type": "RMBG", "inputs": {"image": ["100", 2]}}, "301": {"class_type": "EasyAIPipeline", "inputs": {"base_directory_path": ["200", 0], "extension": ".exr"}}, "302": {"class_type": "EasyAIPipeline", "inputs": {"base_directory_path": ["200", 0], "extension": ".mov"}}, @@ -1194,12 +1207,16 @@ def test_publish_setup_stores_execution_map_from_publish_router() -> None: assert result.valid is True setup = registry.getSetup("router-contract-flow") assert setup is not None + # A Koolook_PublishRouter is auto-detected as the output selector: even + # though its selector is physically wired from the input switch, the setup + # exposes an output switch, so the router is keyed to output_switch and its + # wired branches become the offered output types. assert setup["executionMap"] == { "version": 1, "routers": [ { "node": "400", - "switchKey": "switch", + "switchKey": "output_switch", "selector": {"node": "100", "output": 4}, "payload": {"node": "300", "output": 0}, "branches": { @@ -1211,6 +1228,211 @@ def test_publish_setup_stores_execution_map_from_publish_router() -> None: } ], } + # All three writer branches are wired and all three input types are + # selectable, so "Same as input" is safe (format-preserving) and stays the + # default; every output type is offered. + output_switch = setup["setupSurface"]["app"]["outputSwitch"] + assert [opt["visible"] for opt in output_switch["options"]] == [True, True, True] + assert output_switch["sameAsInput"] is True + assert output_switch["default"] == -1 + + +def test_publish_setup_execution_map_uses_output_switch_when_router_is_output_driven() -> None: + """An EXR-in / QT-out setup wires the writer router from the OUTPUT switch + (Koolook_PublishOutput.switch, slot 3) instead of the input switch. The + executionMap must tag that router with switchKey 'output_switch' so the + runner prunes writer branches by the independently-chosen output type.""" + storage = StaticSetupStorage([]) + registry = PublishedSetupRegistry(storage) + visual_graph = { + "nodes": [ + { + "id": 100, + "type": "Koolook_PublishInput", + "title": "Koolook Publish Input", + "pos": [40, 60], + "size": [360, 320], + "inputs": [], + "widgets_values": ["EXR", "/seq", "/movie.mov", "/image.png", "prompt"], + }, + { + "id": 200, + "type": "Koolook_PublishOutput", + "title": "Koolook Publish Output", + "pos": [520, 60], + "size": [360, 240], + "inputs": [], + "widgets_values": ["/out", "mask", "1", "QT"], + }, + { + "id": 400, + "type": "Koolook_PublishRouter", + "title": "Koolook Publish Router", + "pos": [520, 340], + "size": [360, 220], + "inputs": [], + "widgets_values": [], + }, + {"id": 311, "type": "SaveEXRFrames", "title": "EXR writer", "pos": [920, 80], "size": [220, 120]}, + {"id": 312, "type": "Easy_VideoCombine", "title": "QT writer", "pos": [920, 260], "size": [220, 120]}, + {"id": 313, "type": "SaveImageAndPromptExact", "title": "Img writer", "pos": [920, 440], "size": [220, 120]}, + ], + "links": [], + "groups": [ + {"title": "Koolook Input", "bounding": [20, 20, 420, 420]}, + {"title": "Koolook Output", "bounding": [500, 20, 700, 620]}, + ], + } + + result = registry.publishSetup( + visualGraph=visual_graph, + apiPrompt={ + "100": { + "class_type": "Koolook_PublishInput", + "inputs": { + "mode": "EXR", + "sequence_folder": "/seq", + "qt_file": "/movie.mov", + "single_file": "/image.png", + "prompt": "prompt", + }, + }, + "200": { + "class_type": "Koolook_PublishOutput", + # input_switch wired from the input switch so "Same as input" + # setups still work; here output_mode overrides to QT. + "inputs": {"folder": "/out", "name": "mask", "version": "1", "output_mode": "QT", "input_switch": ["100", 4]}, + }, + "300": {"class_type": "RMBG", "inputs": {"image": ["100", 2]}}, + "301": {"class_type": "EasyAIPipeline", "inputs": {"base_directory_path": ["200", 0], "extension": ".exr"}}, + "302": {"class_type": "EasyAIPipeline", "inputs": {"base_directory_path": ["200", 0], "extension": ".mov"}}, + "303": {"class_type": "EasyAIPipeline", "inputs": {"base_directory_path": ["200", 0], "extension": ".png"}}, + # Router selector wired from the OUTPUT switch (node 200, slot 3). + "400": {"class_type": "Koolook_PublishRouter", "inputs": {"selector": ["200", 3], "payload": ["300", 0]}}, + "311": {"class_type": "SaveEXRFrames", "inputs": {"filepath": ["301", 0], "images": ["400", 0]}}, + "312": {"class_type": "Easy_VideoCombine", "inputs": {"filepath": ["302", 0], "images": ["400", 1]}}, + "313": {"class_type": "SaveImageAndPromptExact", "inputs": {"filepath": ["303", 0], "image": ["400", 2]}}, + }, + metadata={ + "id": "divergent-output-flow", + "title": "Divergent Output Flow", + "description": "EXR input, QT output via an output-driven router.", + }, + inputContract={"inputs": []}, + outputContract={"outputs": []}, + source={"kind": "sidebar-workflow", "path": "Demos/Divergent Output"}, + ) + + assert result.valid is True + setup = registry.getSetup("divergent-output-flow") + assert setup is not None + # The independent output switch is exposed and defaults to QT (author's widget). + assert setup["setupSurface"]["app"]["outputSwitch"]["default"] == 1 + router = setup["executionMap"]["routers"][0] + assert router["switchKey"] == "output_switch" + assert router["selector"] == {"node": "200", "output": 3} + assert router["branches"]["1"]["writerNodes"] == ["312"] # QT writer + # All three output types have a wired writer here, so all stay selectable. + assert [opt["visible"] for opt in setup["setupSurface"]["app"]["outputSwitch"]["options"]] == [True, True, True] + + +def test_publish_setup_hides_output_types_without_a_wired_writer_branch() -> None: + """The external app must only offer output types that will actually write a + file. When the output-switch router only has a QT writer wired, EXR and Img + output options are marked invisible.""" + storage = StaticSetupStorage([]) + registry = PublishedSetupRegistry(storage) + visual_graph = { + "nodes": [ + {"id": 100, "type": "Koolook_PublishInput", "title": "In", "pos": [40, 60], "size": [360, 320], "inputs": [], "widgets_values": ["Img", "/seq", "/movie.mov", "/image.png", "prompt"]}, + {"id": 200, "type": "Koolook_PublishOutput", "title": "Out", "pos": [520, 60], "size": [360, 240], "inputs": [], "widgets_values": ["/out", "mask", "1", "QT"]}, + {"id": 400, "type": "Koolook_PublishRouter", "title": "Router", "pos": [520, 340], "size": [360, 220], "inputs": [], "widgets_values": []}, + {"id": 312, "type": "Easy_VideoCombine", "title": "QT writer", "pos": [920, 260], "size": [220, 120]}, + ], + "links": [], + "groups": [ + {"title": "Koolook Input", "bounding": [20, 20, 420, 420]}, + {"title": "Koolook Output", "bounding": [500, 20, 700, 620]}, + ], + } + + result = registry.publishSetup( + visualGraph=visual_graph, + apiPrompt={ + "100": {"class_type": "Koolook_PublishInput", "inputs": {"mode": "Img", "sequence_folder": "/seq", "qt_file": "/movie.mov", "single_file": "/image.png", "prompt": "prompt"}}, + "200": {"class_type": "Koolook_PublishOutput", "inputs": {"folder": "/out", "name": "mask", "version": "1", "output_mode": "QT", "input_switch": ["100", 4]}}, + "300": {"class_type": "RMBG", "inputs": {"image": ["100", 2]}}, + "302": {"class_type": "EasyAIPipeline", "inputs": {"base_directory_path": ["200", 0], "extension": ".mov"}}, + "400": {"class_type": "Koolook_PublishRouter", "inputs": {"selector": ["200", 3], "payload": ["300", 0]}}, + # Only the QT output slot (1) feeds a writer; EXR (0) and Img (2) are unwired. + "312": {"class_type": "Easy_VideoCombine", "inputs": {"filepath": ["302", 0], "images": ["400", 1]}}, + }, + metadata={"id": "qt-only-output", "title": "QT Only Output", "description": "Only QT output is wired."}, + inputContract={"inputs": []}, + outputContract={"outputs": []}, + source={"kind": "sidebar-workflow", "path": "Demos/QT Only"}, + ) + + assert result.valid is True + setup = registry.getSetup("qt-only-output") + assert setup is not None + options = setup["setupSurface"]["app"]["outputSwitch"]["options"] + visible_by_label = {opt["label"]: opt["visible"] for opt in options} + assert visible_by_label == {"EXR": False, "QT": True, "Img": False} + + +def test_publish_setup_auto_detects_output_from_input_wired_router() -> None: + """Reproduces the maintainer's setup: the writer router's selector is wired + from the INPUT switch and output_mode is left on 'Same as input', with only + the QT branch wired. The router is still auto-detected as the output + selector, so the app offers QT (EXR/Img hidden) and defaults to QT — no + dedicated output-switch wiring required.""" + storage = StaticSetupStorage([]) + registry = PublishedSetupRegistry(storage) + visual_graph = { + "nodes": [ + {"id": 100, "type": "Koolook_PublishInput", "title": "In", "pos": [40, 60], "size": [360, 320], "inputs": [], "widgets_values": ["EXR", "/seq", "/movie.mov", "/image.png", "prompt"]}, + {"id": 200, "type": "Koolook_PublishOutput", "title": "Out", "pos": [520, 60], "size": [360, 240], "inputs": [], "widgets_values": ["/out", "mask", "auto", "Same as input", 2]}, + {"id": 400, "type": "Koolook_PublishRouter", "title": "Router", "pos": [520, 340], "size": [360, 220], "inputs": [], "widgets_values": []}, + {"id": 312, "type": "Easy_VideoCombine", "title": "QT writer", "pos": [920, 260], "size": [220, 120]}, + ], + "links": [], + "groups": [ + {"title": "Koolook Input", "bounding": [20, 20, 420, 420]}, + {"title": "Koolook Output", "bounding": [500, 20, 700, 620]}, + ], + } + + result = registry.publishSetup( + visualGraph=visual_graph, + apiPrompt={ + "100": {"class_type": "Koolook_PublishInput", "inputs": {"mode": "EXR", "sequence_folder": "/seq", "qt_file": "/movie.mov", "single_file": "/image.png", "prompt": "prompt"}}, + "200": {"class_type": "Koolook_PublishOutput", "inputs": {"folder": "/out", "name": "mask", "version": "auto", "output_mode": "Same as input"}}, + "300": {"class_type": "RMBG", "inputs": {"image": ["100", 2]}}, + "302": {"class_type": "EasyAIPipeline", "inputs": {"base_directory_path": ["200", 0], "extension": ".mov"}}, + # Router selector wired from the INPUT switch (node 100, slot 4) — the + # old "output follows input" wiring — with only the QT writer wired. + "400": {"class_type": "Koolook_PublishRouter", "inputs": {"selector": ["100", 4], "payload": ["300", 0]}}, + "312": {"class_type": "Easy_VideoCombine", "inputs": {"filepath": ["302", 0], "images": ["400", 1]}}, + }, + metadata={"id": "input-wired-router", "title": "Input Wired Router", "description": "Router selector from input switch, QT only."}, + inputContract={"inputs": []}, + outputContract={"outputs": []}, + source={"kind": "sidebar-workflow", "path": "Demos/Input Wired"}, + ) + + assert result.valid is True + setup = registry.getSetup("input-wired-router") + assert setup is not None + # Router is keyed to the output switch despite the input-wired selector. + assert setup["executionMap"]["routers"][0]["switchKey"] == "output_switch" + output_switch = setup["setupSurface"]["app"]["outputSwitch"] + visible_by_label = {opt["label"]: opt["visible"] for opt in output_switch["options"]} + assert visible_by_label == {"EXR": False, "QT": True, "Img": False} + # EXR/Img inputs have no matching output writer, so "Same as input" is unsafe + # and dropped; the control offers only QT and defaults to it. + assert output_switch["sameAsInput"] is False + assert output_switch["default"] == 1 @pytest.mark.parametrize( diff --git a/tests/server/test_published_setup_runner.py b/tests/server/test_published_setup_runner.py index 9894292..67d18e3 100644 --- a/tests/server/test_published_setup_runner.py +++ b/tests/server/test_published_setup_runner.py @@ -619,8 +619,324 @@ def test_run_setup_uses_execution_map_router_to_keep_selected_writer() -> None: asyncio.run(exercise()) -@pytest.mark.parametrize("switch_value", [True, 99]) +def test_get_run_result_path_follows_output_switch_not_input() -> None: + """When a mode-switched Koolook_PublishResult is driven by the OUTPUT switch, + the reported result path follows the chosen output type. An EXR source + (input switch 0) with QT output (output_switch 1) must report the QT movie + path from the result index-switch's value1 branch, not the EXR value0.""" + async def exercise() -> None: + setup = _valid_setup() + setup["inputContract"] = {"inputs": []} + setup["outputContract"] = {"outputs": []} + setup["visualGraph"] = { + "nodes": [ + {"id": 188, "type": "Koolook_PublishInput", "inputs": [{"name": "mode", "widget": {"name": "mode"}}]}, + {"id": 200, "type": "Koolook_PublishOutput", "inputs": [{"name": "output_mode", "widget": {"name": "output_mode"}}]}, + {"id": 205, "type": "Koolook_PublishResult", "inputs": [{"name": "result", "link": 900}]}, + ], + "links": [[900, 179, 0, 205, 0, "STRING"]], + "groups": [], + } + setup["apiPrompt"] = { + "188": { + "class_type": "Koolook_PublishInput", + "inputs": {"mode": "EXR", "sequence_folder": "/shots/source/frames", "qt_file": "", "single_file": "", "prompt": ""}, + }, + "200": { + "class_type": "Koolook_PublishOutput", + "inputs": {"folder": "/shots/output", "name": "publish-OUT", "version": "2", "output_mode": "QT", "input_switch": ["188", 4]}, + }, + # Result index-switch driven by the OUTPUT switch (node 200, slot 3). + "179": { + "class_type": "Switch any [Crystools]", + "inputs": {"index": ["200", 3], "value0": ["301", 0], "value1": ["302", 0], "value2": ["303", 0]}, + }, + "301": {"class_type": "EasyAIPipeline", "inputs": {"WRITE_file_path": "/shots/output/publish-OUT_v002.exr"}}, + "302": {"class_type": "Easy_VideoCombine", "inputs": {"video_path": "/shots/output/publish-OUT_v002.mov"}}, + "303": {"class_type": "EasyAIPipeline", "inputs": {"WRITE_file_path": "/shots/output/publish-OUT_v002.png"}}, + "205": {"class_type": "Koolook_PublishResult", "inputs": {"result": ["179", 0]}}, + } + setup["setupSurface"] = { + "sourceInputs": [ + {"group": "Koolook Input", "nodes": [{"id": "188", "type": "Koolook_PublishInput", "title": "In"}]} + ], + "outputs": [ + { + "group": "Koolook Output", + "nodes": [ + {"id": "200", "type": "Koolook_PublishOutput", "title": "Out"}, + {"id": "205", "type": "Koolook_PublishResult", "title": "Result"}, + {"id": "301", "type": "EasyAIPipeline", "title": "EXR path"}, + {"id": "302", "type": "Easy_VideoCombine", "title": "QT path"}, + {"id": "303", "type": "EasyAIPipeline", "title": "Img path"}, + ], + } + ], + "controls": [], + "app": { + "inputs": [ + {"key": "sequence_folder", "label": "Sequence folder", "visible": True, "target": {"node": "188", "input": "sequence_folder"}, "default": "/shots/source/frames"} + ], + "outputs": [], + "results": [ + {"key": "result", "label": "Result", "visible": True, "target": {"node": "205", "input": "result"}, "default": ""} + ], + "switch": { + "key": "switch", + "label": "Input type", + "visible": True, + "target": {"node": "188", "input": "mode"}, + "default": 0, + "options": [ + {"value": 0, "label": "EXR", "visible": True, "input": "sequence_folder"}, + {"value": 1, "label": "QT", "visible": True, "input": "sequence_folder"}, + {"value": 2, "label": "Img", "visible": True, "input": "sequence_folder"}, + ], + }, + "outputSwitch": { + "key": "output_switch", + "label": "Output type", + "visible": True, + "sameAsInput": True, + "target": {"node": "200", "input": "output_mode"}, + "default": -1, + "options": [ + {"value": 0, "label": "EXR", "visible": True}, + {"value": 1, "label": "QT", "visible": True}, + {"value": 2, "label": "Img", "visible": True}, + ], + }, + }, + } + registry = PublishedSetupRegistry(StaticSetupStorage([setup])) + comfy = FakeComfyClient( + history={ + "comfy-prompt-1": { + "status": {"completed": True, "status_str": "success"}, + # History carries paths for both branch nodes; the runner must + # pick the QT one because output_switch selected it. + "outputs": { + "301": {"text": ["/shots/output/publish-OUT_v002.exr"]}, + "302": {"text": ["/shots/output/publish-OUT_v002.mov"]}, + }, + } + } + ) + runner = PublishedSetupRunner(registry, comfy) + + queued = await runner.runSetup("ltx-director-demo", {"switch": 0, "output_switch": 1}) + status = await runner.getRun(queued["runId"]) + + result_output = next(o for o in status["outputs"] if o["key"] == "result") + values = [item["value"] for item in result_output["items"]] + assert values == ["/shots/output/publish-OUT_v002.mov"], ( + f"result should follow output_switch (QT .mov), got {values!r}" + ) + + asyncio.run(exercise()) + + +def _divergent_output_setup( + *, + output_default: int = 1, + output_options: list[dict[str, object]] | None = None, + same_as_input: bool = True, +) -> dict: + """Setup with an EXR/QT/Img writer router driven by the output switch.""" + setup = _valid_setup() + setup["inputContract"] = {"inputs": []} + setup["outputContract"] = {"outputs": []} + setup["visualGraph"] = { + "nodes": [ + {"id": 100, "type": "Koolook_PublishInput", "inputs": [{"name": "mode", "widget": {"name": "mode"}}]}, + {"id": 200, "type": "Koolook_PublishOutput", "inputs": [{"name": "output_mode", "widget": {"name": "output_mode"}}]}, + ], + "links": [], + "groups": [], + } + setup["apiPrompt"] = { + "100": { + "class_type": "Koolook_PublishInput", + "inputs": { + "mode": "EXR", + "sequence_folder": "/shots/source/frames", + "qt_file": "", + "single_file": "", + "prompt": "", + }, + }, + "200": { + "class_type": "Koolook_PublishOutput", + "inputs": {"folder": "/shots/output", "name": "publish-OUT", "version": "2", "output_mode": "QT", "input_switch": ["100", 4]}, + }, + "300": {"class_type": "RMBG", "inputs": {"image": ["100", 0]}}, + "301": {"class_type": "EasyAIPipeline", "inputs": {"base_directory_path": ["200", 0], "extension": ".exr"}}, + "302": {"class_type": "EasyAIPipeline", "inputs": {"base_directory_path": ["200", 0], "extension": ".mov"}}, + "303": {"class_type": "EasyAIPipeline", "inputs": {"base_directory_path": ["200", 0], "extension": ".png"}}, + "400": {"class_type": "Koolook_PublishRouter", "inputs": {"selector": ["200", 3], "payload": ["300", 0]}}, + "311": {"class_type": "SaveEXRFrames", "inputs": {"filepath": ["301", 0], "images": ["400", 0]}}, + "312": {"class_type": "Easy_VideoCombine", "inputs": {"filepath": ["302", 0], "images": ["400", 1]}}, + "313": {"class_type": "SaveImageAndPromptExact", "inputs": {"filepath": ["303", 0], "image": ["400", 2]}}, + } + setup["setupSurface"] = { + "sourceInputs": [ + {"group": "Koolook Input", "nodes": [{"id": "100", "type": "Koolook_PublishInput", "title": "In"}]} + ], + "outputs": [ + { + "group": "Koolook Output", + "nodes": [ + {"id": "200", "type": "Koolook_PublishOutput", "title": "Out"}, + {"id": "400", "type": "Koolook_PublishRouter", "title": "Router"}, + {"id": "311", "type": "SaveEXRFrames", "title": "EXR writer"}, + {"id": "312", "type": "Easy_VideoCombine", "title": "QT writer"}, + {"id": "313", "type": "SaveImageAndPromptExact", "title": "Image writer"}, + ], + } + ], + "controls": [], + "app": { + "inputs": [ + {"key": "sequence_folder", "label": "Sequence folder", "visible": True, "target": {"node": "100", "input": "sequence_folder"}, "default": "/shots/source/frames"} + ], + "outputs": [ + {"key": "folder", "label": "Output folder", "visible": True, "target": {"node": "200", "input": "folder"}, "default": "/shots/output"}, + ], + "results": [], + "switch": { + "key": "switch", + "label": "Input type", + "visible": True, + "target": {"node": "100", "input": "mode"}, + "default": 0, + "options": [ + {"value": 0, "label": "EXR", "visible": True, "input": "sequence_folder"}, + {"value": 1, "label": "QT", "visible": False, "input": "sequence_folder"}, + {"value": 2, "label": "Img", "visible": False, "input": "sequence_folder"}, + ], + }, + "outputSwitch": { + "key": "output_switch", + "label": "Output type", + "visible": True, + "sameAsInput": same_as_input, + "target": {"node": "200", "input": "output_mode"}, + "default": output_default, + "options": output_options + if output_options is not None + else [ + {"value": 0, "label": "EXR", "visible": True}, + {"value": 1, "label": "QT", "visible": True}, + {"value": 2, "label": "Img", "visible": True}, + ], + }, + }, + } + setup["executionMap"] = { + "version": 1, + "routers": [ + { + "node": "400", + "switchKey": "output_switch", + "selector": {"node": "200", "output": 3}, + "payload": {"node": "300", "output": 0}, + "branches": { + "0": {"label": "EXR", "output": 0, "writerNodes": ["311"]}, + "1": {"label": "QT", "output": 1, "writerNodes": ["312"]}, + "2": {"label": "Img", "output": 2, "writerNodes": ["313"]}, + }, + } + ], + } + return setup + + +def test_run_setup_output_switch_selects_writer_independently_of_input() -> None: + """Decoupled output type: the writer router is driven by the OUTPUT switch, + so an EXR source (input switch 0) can still write a QT movie (output switch + 1). The runner must keep the QT writer and prune the EXR/Img writers, proving + the writer branch follows output_switch, not the input switch.""" + async def exercise() -> None: + setup = _divergent_output_setup() + registry = PublishedSetupRegistry(StaticSetupStorage([setup])) + comfy = FakeComfyClient() + runner = PublishedSetupRunner(registry, comfy) + + # EXR in, QT out — the two switches disagree on purpose. + await runner.runSetup( + "ltx-director-demo", + {"switch": 0, "output_switch": 1, "sequence_folder": "/shots/source/frames", "folder": "/shots/output"}, + ) + + kept = set(comfy.submitted_prompts[0]) + assert "312" in kept, "QT writer (selected by output_switch) must be kept" + assert "311" not in kept and "313" not in kept, "non-selected writers must be pruned" + + asyncio.run(exercise()) + + +def test_run_setup_rejects_hidden_output_switch_option() -> None: + """An output type marked visible: False (no wired writer branch) is not + selectable through the API either — a direct caller submitting it gets a + 400 listing only the visible choices, and nothing is queued.""" + async def exercise() -> None: + setup = _divergent_output_setup( + output_default=1, + same_as_input=False, + output_options=[ + {"value": 0, "label": "EXR", "visible": False}, + {"value": 1, "label": "QT", "visible": True}, + {"value": 2, "label": "Img", "visible": False}, + ], + ) + registry = PublishedSetupRegistry(StaticSetupStorage([setup])) + comfy = FakeComfyClient() + runner = PublishedSetupRunner(registry, comfy) + + with pytest.raises(SetupRunError) as exc_info: + await runner.runSetup( + "ltx-director-demo", + {"switch": 0, "output_switch": 0, "sequence_folder": "/shots/source/frames"}, + ) + + assert exc_info.value.status_code == 400 + assert exc_info.value.errors == [ + "input 'output_switch' must be one of: 1 (QT)" + ] + assert comfy.submitted_prompts == [] + + asyncio.run(exercise()) + + +def test_run_setup_omitted_output_switch_follows_input_when_default_is_same_as_input() -> None: + """When the registry leaves outputSwitch.default at the -1 "Same as input" + sentinel, a direct API caller that omits output_switch must still get a + valid run: the runner resolves the sentinel to the input switch's value + and keeps that writer branch (the browser simulator pre-resolves this; + the server must handle it too).""" + async def exercise() -> None: + setup = _divergent_output_setup(output_default=-1, same_as_input=True) + registry = PublishedSetupRegistry(StaticSetupStorage([setup])) + comfy = FakeComfyClient() + runner = PublishedSetupRunner(registry, comfy) + + # EXR in, no output_switch submitted — output follows input. + await runner.runSetup( + "ltx-director-demo", + {"switch": 0, "sequence_folder": "/shots/source/frames", "folder": "/shots/output"}, + ) + + kept = set(comfy.submitted_prompts[0]) + assert "311" in kept, "EXR writer (output follows input) must be kept" + assert "312" not in kept and "313" not in kept, "non-selected writers must be pruned" + + asyncio.run(exercise()) + + +@pytest.mark.parametrize("switch_value", [True, 99, 0, 1]) def test_run_setup_rejects_invalid_switch_values_before_queueing(switch_value: object) -> None: + """Out-of-range values AND hidden options (0/1 are visible: False here) are + rejected before queueing; the error offers only the selectable choices.""" async def exercise() -> None: setup = _valid_setup() setup["inputContract"] = {"inputs": []} @@ -689,7 +1005,7 @@ def test_run_setup_rejects_invalid_switch_values_before_queueing(switch_value: o assert exc_info.value.status_code == 400 assert exc_info.value.errors == [ - "input 'switch' must be one of: 0 (EXR), 1 (QT), 2 (Img)" + "input 'switch' must be one of: 2 (Img)" ] assert comfy.submitted_prompts == [] diff --git a/tests/sidebar/test_published_surface.py b/tests/sidebar/test_published_surface.py index 2b52f29..db14362 100644 --- a/tests/sidebar/test_published_surface.py +++ b/tests/sidebar/test_published_surface.py @@ -133,6 +133,19 @@ def test_infer_setup_surface_app_contract_from_publish_nodes() -> None: { value: 3, label: "Prompt", visible: false, input: "prompt" }, ], }, + outputSwitch: { + key: "output_switch", + label: "Output type", + visible: true, + sameAsInput: true, + target: { node: "200", input: "output_mode" }, + default: -1, + options: [ + { value: 0, label: "EXR", visible: true }, + { value: 1, label: "QT", visible: true }, + { value: 2, label: "Img", visible: true }, + ], + }, }); """ ) diff --git a/tests/sidebar/test_setup_runner_simulator.py b/tests/sidebar/test_setup_runner_simulator.py index a49882a..a376b24 100644 --- a/tests/sidebar/test_setup_runner_simulator.py +++ b/tests/sidebar/test_setup_runner_simulator.py @@ -81,6 +81,86 @@ def test_simulator_lists_setup_and_polls_public_run_routes() -> None: assert result.returncode == 0, result.stderr +def test_simulator_supports_independent_output_switch() -> None: + """An app surface with an outputSwitch submits an output_switch value. The + 'Same as input' sentinel (-1) follows the input switch; a concrete choice + overrides it — so an EXR source can be routed to a QT writer.""" + script = textwrap.dedent( + """ + import assert from "node:assert/strict"; + import { + defaultRunInputsFromSetup, + runInputsFromAppValues, + } from "./web/setup_runner_simulator.js"; + + const setup = { + setupSurface: { + app: { + switch: { + key: "switch", + default: 0, + options: [ + { value: 0, label: "EXR", input: "sequence_folder", visible: true }, + { value: 1, label: "QT", input: "qt_file", visible: true }, + { value: 2, label: "Img", input: "single_file", visible: true }, + ], + }, + outputSwitch: { + key: "output_switch", + default: -1, + sameAsInput: true, + options: [ + { value: 0, label: "EXR", visible: true }, + { value: 1, label: "QT", visible: true }, + { value: 2, label: "Img", visible: true }, + ], + }, + inputs: [{ key: "sequence_folder", default: "/plates/demo" }], + outputs: [{ key: "folder", default: "/renders/demo" }], + }, + }, + }; + + // Default: output follows input (EXR=0) via the "Same as input" sentinel. + assert.deepEqual(defaultRunInputsFromSetup(setup), { + switch: 0, + sequence_folder: "/plates/demo", + folder: "/renders/demo", + output_switch: 0, + }); + + // Explicit override: EXR source (switch 0), QT writer (output_switch 1). + assert.deepEqual(runInputsFromAppValues(setup, { + switch: 0, + output_switch: 1, + sequence_folder: "/plates/demo", + folder: "/renders/demo", + }), { + switch: 0, + sequence_folder: "/plates/demo", + folder: "/renders/demo", + output_switch: 1, + }); + + // "Same as input" (-1) submitted -> resolves to the chosen input (QT=1). + assert.deepEqual(runInputsFromAppValues(setup, { + switch: 1, + output_switch: -1, + sequence_folder: "/plates/demo", + folder: "/renders/demo", + }), { + switch: 1, + sequence_folder: "/plates/demo", + folder: "/renders/demo", + output_switch: 1, + }); + """ + ) + + result = run_node_scenario(script) + assert result.returncode == 0, result.stderr + + def test_simulator_times_out_when_active_status_never_progresses() -> None: script = textwrap.dedent( """ diff --git a/web/setup_runner_simulator.js b/web/setup_runner_simulator.js index da7f13c..93588a9 100644 --- a/web/setup_runner_simulator.js +++ b/web/setup_runner_simulator.js @@ -357,6 +357,24 @@ function fieldDefault(field) { return field?.default == null ? "" : field.default; } +// Resolve the concrete output-type index to submit. A negative / blank value is +// the "Same as input" sentinel: it follows the input switch so the writer type +// matches the source type unless the user explicitly overrode it. +function resolveOutputSwitchValue(app, values, selectedInputSwitch) { + const field = app?.outputSwitch; + if (!field || typeof field !== "object" || !field.key) return undefined; + const raw = field.key in (values || {}) ? values[field.key] : field.default; + const num = Number(raw); + if (!Number.isNaN(num) && num >= 0) return num; + if (selectedInputSwitch !== undefined && Number(selectedInputSwitch) >= 0) { + return Number(selectedInputSwitch); + } + const opts = Array.isArray(field.options) + ? field.options.filter(option => option && option.visible !== false) + : []; + return opts.length ? opts[0].value : undefined; +} + export function defaultRunInputsFromSetup(setup) { const app = appSurface(setup); const inputs = {}; @@ -387,6 +405,10 @@ export function defaultRunInputsFromSetup(setup) { if (field?.visible !== false && field?.key) inputs[field.key] = fieldDefault(field); } } + const outputSwitchValue = resolveOutputSwitchValue(app, {}, selectedSwitch); + if (app.outputSwitch?.key && outputSwitchValue !== undefined) { + inputs[app.outputSwitch.key] = outputSwitchValue; + } return inputs; } @@ -425,6 +447,10 @@ export function runInputsFromAppValues(setup, values = {}) { } } } + const outputSwitchValue = resolveOutputSwitchValue(app, values, selectedSwitch); + if (app.outputSwitch?.key && outputSwitchValue !== undefined) { + inputs[app.outputSwitch.key] = outputSwitchValue; + } return inputs; } @@ -480,6 +506,17 @@ function demoFetch(url, options = {}) { { value: 3, label: "Prompt", input: "prompt", visible: false }, ], }, + outputSwitch: { + key: "output_switch", + label: "Output type", + default: -1, + sameAsInput: true, + options: [ + { value: 0, label: "EXR", visible: true }, + { value: 1, label: "QT", visible: true }, + { value: 2, label: "Img", visible: true }, + ], + }, inputs: [ { key: "sequence_folder", label: "Sequence folder", visible: true, default: "/shots/demo/plates" }, { key: "qt_file", label: "QT file", visible: true, default: "/shots/demo/source.mov" }, @@ -516,7 +553,14 @@ function demoFetch(url, options = {}) { const name = String(demoRunInputs.name || "demo"); const rawVersion = String(demoRunInputs.version || "1"); const version = rawVersion.padStart(3, "0"); - const extension = Number(demoRunInputs.switch) === 1 ? "mov" : "png"; + // Output extension follows the OUTPUT switch (falling back to the input + // switch when the setup exposes no independent output type), so choosing + // "QT" output on an EXR/Img source yields a .mov here in the demo too. + const outTypeRaw = demoRunInputs.output_switch; + const outType = outTypeRaw == null || Number(outTypeRaw) < 0 + ? Number(demoRunInputs.switch) + : Number(outTypeRaw); + const extension = outType === 1 ? "mov" : outType === 0 ? "exr" : "png"; return ok({ ok: true, run: { @@ -671,11 +715,49 @@ function bindSimulator(documentRef, fetchImpl) { const visibleOutputs = Array.isArray(app.outputs) ? app.outputs.filter(field => field?.visible !== false && field?.key) : []; - if (visibleOutputs.length) { + const outputSwitch = app.outputSwitch && typeof app.outputSwitch === "object" + && Array.isArray(app.outputSwitch.options) + ? app.outputSwitch + : null; + // Only offer output types that have a wired writer branch (the contract + // marks the rest visible:false). With no real choice, output silently + // follows the input type and we skip the control entirely. + const visibleOutputOptions = outputSwitch + ? outputSwitch.options.filter(option => option && option.visible !== false) + : []; + const showOutputSwitch = Boolean(outputSwitch) && visibleOutputOptions.length > 0; + if (visibleOutputs.length || showOutputSwitch) { const outputHeading = documentRef.createElement("div"); outputHeading.className = "form-heading"; outputHeading.textContent = "Output"; appForm.appendChild(outputHeading); + if (showOutputSwitch) { + // Independent output type: lets an EXR source write a QT movie. + // "Same as input" keeps the writer type following the source. + const outLabel = documentRef.createElement("label"); + outLabel.textContent = outputSwitch.label || "Output type"; + const outSelect = documentRef.createElement("select"); + outSelect.dataset.appOutputSwitch = outputSwitch.key || "output_switch"; + const outDefault = outputSwitch.default; + const followsInput = outDefault == null || Number(outDefault) < 0; + if (outputSwitch.sameAsInput) { + const opt = documentRef.createElement("option"); + opt.value = "-1"; + opt.textContent = "Same as input"; + if (followsInput) opt.selected = true; + outSelect.appendChild(opt); + } + for (const option of visibleOutputOptions) { + const opt = documentRef.createElement("option"); + opt.value = String(option.value); + opt.textContent = option.label || String(option.value); + if (!followsInput && String(option.value) === String(outDefault)) opt.selected = true; + outSelect.appendChild(opt); + } + outSelect.addEventListener("change", updatePayloadPreview); + outLabel.appendChild(outSelect); + appForm.appendChild(outLabel); + } for (const field of visibleOutputs) { appForm.appendChild(createInputField(field, { value: fieldDefault(field) })); } @@ -691,6 +773,11 @@ function bindSimulator(documentRef, fetchImpl) { const numeric = Number(switchEl.value); values[switchEl.dataset.appSwitch] = Number.isNaN(numeric) ? switchEl.value : numeric; } + const outSwitchEl = appForm?.querySelector("[data-app-output-switch]"); + if (outSwitchEl) { + const numeric = Number(outSwitchEl.value); + values[outSwitchEl.dataset.appOutputSwitch] = Number.isNaN(numeric) ? outSwitchEl.value : numeric; + } for (const input of appForm?.querySelectorAll("[data-app-field]") || []) { values[input.dataset.appField] = input.value; } diff --git a/web/sidebar/published_surface.js b/web/sidebar/published_surface.js index 9595a8a..4ba445a 100644 --- a/web/sidebar/published_surface.js +++ b/web/sidebar/published_surface.js @@ -64,7 +64,7 @@ const PUBLISH_OUTPUT_CLASS = "Koolook_PublishOutput"; const PUBLISH_RESULT_CLASS = "Koolook_PublishResult"; const WIDGET_NAMES_BY_CLASS = { [PUBLISH_INPUT_CLASS]: ["mode", "sequence_folder", "qt_file", "single_file", "prompt"], - [PUBLISH_OUTPUT_CLASS]: ["folder", "name", "version"], + [PUBLISH_OUTPUT_CLASS]: ["folder", "name", "version", "output_mode"], [PUBLISH_RESULT_CLASS]: ["result"], }; const PUBLISH_INPUT_FIELDS = [ @@ -89,6 +89,15 @@ const PUBLISH_OUTPUT_FIELDS = [ ["name", "Output name", true], ["version", "Version", true], ]; +// Output-type modes are a subset of the input modes (no Prompt — not a real +// output format). Indices match PUBLISH_INPUT_MODES so a router slot means the +// same type on either switch. -1 is the "Same as input" sentinel default. +const PUBLISH_OUTPUT_MODES = [ + [0, "EXR"], + [1, "QT"], + [2, "Img"], +]; +const PUBLISH_OUTPUT_SAME_AS_INPUT = -1; const PUBLISH_RESULT_FIELDS = [ ["result", "Result", true], ]; @@ -179,6 +188,29 @@ function inputSwitch(node, inputs) { }; } +function outputModeIndex(value) { + if (Number.isInteger(value)) return value; + const text = String(value ?? "").trim().toLowerCase(); + const found = PUBLISH_OUTPUT_MODES.find(([_value, label]) => label.toLowerCase() === text); + return found ? found[0] : PUBLISH_OUTPUT_SAME_AS_INPUT; +} + +function outputSwitch(node) { + return { + key: "output_switch", + label: "Output type", + visible: true, + sameAsInput: true, + target: { node: String(node.id), input: "output_mode" }, + default: outputModeIndex(widgetValue(node, "output_mode")), + options: PUBLISH_OUTPUT_MODES.map(([value, label]) => ({ + value, + label, + visible: true, + })), + }; +} + function promptField(node) { if (!node) return null; const hint = widgetValue(node, "prompt"); @@ -209,6 +241,7 @@ function inferAppSurface(graph) { results: fieldSpecs(resultNode, PUBLISH_RESULT_FIELDS), }; if (inputNode) app.switch = inputSwitch(inputNode, inputs); + if (outputNode) app.outputSwitch = outputSwitch(outputNode); return app; }