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.
This commit is contained in:
malkuthro
2026-07-19 23:34:30 +02:00
committed by GitHub
parent 950f15e569
commit 7b00a551d9
15 changed files with 1389 additions and 126 deletions
+26
View File
@@ -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
@@ -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: <path>
Output type: [Same as input] [EXR] [QT] [Img]
Output folder: <path>
Output name: <name>
Version: <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`
@@ -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`
+21
View File
@@ -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,
+79 -5
View File
@@ -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:
+145 -76
View File
@@ -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:
+201 -24
View File
@@ -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")
+54
View File
@@ -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)
+27 -4
View File
@@ -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:
+225 -3
View File
@@ -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(
+318 -2
View File
@@ -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 == []
+13
View File
@@ -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 },
],
},
});
"""
)
@@ -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(
"""
+89 -2
View File
@@ -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;
}
+34 -1
View File
@@ -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;
}