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:
@@ -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`
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 == []
|
||||
|
||||
|
||||
@@ -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(
|
||||
"""
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user