From 332840c7fd2594f81bed14976336c8dc8c2ef746 Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Sun, 30 Aug 2026 17:32:57 -0500 Subject: [PATCH 1/5] feat(nodes): port For loop execution and editor support --- .../content/docs/contributing/loop-nodes.md | 91 + .../features/Workflows/editor-interface.mdx | 3 + .../content/docs/features/Workflows/index.mdx | 6 + .../docs/features/Workflows/loop-nodes.mdx | 224 ++ invokeai/app/invocations/collections.py | 82 +- invokeai/app/invocations/fields.py | 19 +- invokeai/app/invocations/loops.py | 214 ++ invokeai/app/services/shared/graph.py | 1668 +++++++++++++- .../services/shared/workflow_graph_builder.py | 167 +- invokeai/frontend/web/openapi.json | 1367 ++++++++++- invokeai/frontend/web/public/locales/en.json | 26 + .../flow/AddNodeCmdk.mounted.test.tsx | 319 +++ .../flow/AddNodeCmdk/AddNodeCmdk.test.ts | 162 ++ .../flow/AddNodeCmdk/AddNodeCmdk.tsx | 279 ++- .../features/nodes/components/flow/Flow.tsx | 16 +- .../flow/LoopBodyBoundaryOverlay.test.tsx | 129 ++ .../flow/LoopBodyBoundaryOverlay.tsx | 84 + .../flow/edges/InvocationLoopLinkageEdge.tsx | 65 + .../flow/nodes/Connector/ConnectorNode.tsx | 8 +- .../flow/nodes/Invocation/InvocationNode.tsx | 20 +- .../Invocation/InvocationNodeInfoIcon.tsx | 2 +- .../InvocationNodeStatusIndicator.tsx | 9 +- .../nodes/Invocation/OutputFields.test.tsx | 62 + .../flow/nodes/Invocation/OutputFields.tsx | 33 + .../Invocation/fields/InputFieldTitle.tsx | 1 + .../src/features/nodes/hooks/useAutoLayout.ts | 6 +- .../features/nodes/hooks/useNodeCopyPaste.ts | 37 +- .../nodes/hooks/useOutputFieldNames.ts | 11 +- .../features/nodes/store/nodesSlice.test.ts | 396 +++- .../src/features/nodes/store/nodesSlice.ts | 127 +- .../store/util/connectorTopology.test.ts | 165 +- .../nodes/store/util/connectorTopology.ts | 405 +++- .../util/getFirstValidConnection.test.ts | 82 +- .../store/util/getFirstValidConnection.ts | 30 +- .../features/nodes/store/util/getHasCycles.ts | 4 +- .../nodes/store/util/reactFlowUtil.test.ts | 18 + .../nodes/store/util/reactFlowUtil.ts | 13 +- .../features/nodes/store/util/testUtils.ts | 254 +++ .../store/util/validateConnection.test.ts | 618 ++++- .../nodes/store/util/validateConnection.ts | 386 ++++ .../util/validateConnectionTypes.test.ts | 18 + .../web/src/features/nodes/types/constants.ts | 3 + .../web/src/features/nodes/types/field.ts | 2 + .../src/features/nodes/types/invocation.ts | 14 +- .../web/src/features/nodes/types/openapi.ts | 13 +- .../web/src/features/nodes/types/workflow.ts | 7 +- .../nodes/util/graph/buildNodesGraph.test.ts | 201 +- .../nodes/util/graph/buildNodesGraph.ts | 127 +- .../nodes/util/graph/generation/Graph.ts | 2 +- .../nodes/util/graph/loopBodyBoundary.test.ts | 212 ++ .../nodes/util/graph/loopBodyBoundary.ts | 219 ++ .../util/graph/validateForLoopGraph.test.ts | 604 +++++ .../nodes/util/graph/validateForLoopGraph.ts | 532 +++++ .../node/getOutputFieldNamesByScope.test.ts | 49 + .../util/node/getOutputFieldNamesByScope.ts | 21 + .../util/node/getOutputFieldRows.test.ts | 37 + .../nodes/util/node/getOutputFieldRows.ts | 21 + .../features/nodes/util/node/nodeUpdate.ts | 2 +- .../util/schema/buildFieldInputTemplate.ts | 10 +- .../schema/buildFieldOutputTemplate.test.ts | 26 + .../util/schema/buildFieldOutputTemplate.ts | 7 +- .../nodes/util/schema/parseSchema.test.ts | 82 +- .../features/nodes/util/schema/parseSchema.ts | 10 +- .../nodes/util/workflow/buildWorkflow.test.ts | 34 +- .../nodes/util/workflow/buildWorkflow.ts | 12 +- .../util/workflow/graphToWorkflow.test.ts | 138 +- .../nodes/util/workflow/graphToWorkflow.ts | 14 +- .../util/workflow/validateWorkflow.test.ts | 234 ++ .../nodes/util/workflow/validateWorkflow.ts | 86 +- .../features/queue/store/readiness.test.ts | 63 +- .../web/src/features/queue/store/readiness.ts | 16 +- .../frontend/web/src/services/api/schema.ts | 574 ++++- tests/app/invocations/test_collections.py | 128 ++ tests/app/invocations/test_loop_nodes.py | 186 ++ .../invocations/test_output_field_scope.py | 17 + .../test_for_loop_processor.py | 357 +++ .../test_for_loop_processor_sqlite.py | 376 +++ .../test_for_loop_session_queue.py | 267 +++ .../services/test_for_loop_session_runner.py | 492 ++++ .../services/test_workflow_graph_builder.py | 247 ++ tests/test_graph_execution_state.py | 2008 ++++++++++++++++- tests/test_node_graph.py | 756 +++++++ tests/test_nodes.py | 8 + 83 files changed, 15509 insertions(+), 331 deletions(-) create mode 100644 docs/src/content/docs/contributing/loop-nodes.md create mode 100644 docs/src/content/docs/features/Workflows/loop-nodes.mdx create mode 100644 invokeai/app/invocations/loops.py create mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk.mounted.test.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk.test.ts create mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/LoopBodyBoundaryOverlay.test.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/LoopBodyBoundaryOverlay.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/edges/InvocationLoopLinkageEdge.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/OutputFields.test.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/OutputFields.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/util/graph/loopBodyBoundary.test.ts create mode 100644 invokeai/frontend/web/src/features/nodes/util/graph/loopBodyBoundary.ts create mode 100644 invokeai/frontend/web/src/features/nodes/util/graph/validateForLoopGraph.test.ts create mode 100644 invokeai/frontend/web/src/features/nodes/util/graph/validateForLoopGraph.ts create mode 100644 invokeai/frontend/web/src/features/nodes/util/node/getOutputFieldNamesByScope.test.ts create mode 100644 invokeai/frontend/web/src/features/nodes/util/node/getOutputFieldNamesByScope.ts create mode 100644 invokeai/frontend/web/src/features/nodes/util/node/getOutputFieldRows.test.ts create mode 100644 invokeai/frontend/web/src/features/nodes/util/node/getOutputFieldRows.ts create mode 100644 invokeai/frontend/web/src/features/nodes/util/schema/buildFieldOutputTemplate.test.ts create mode 100644 tests/app/invocations/test_collections.py create mode 100644 tests/app/invocations/test_loop_nodes.py create mode 100644 tests/app/invocations/test_output_field_scope.py create mode 100644 tests/app/services/session_processor/test_for_loop_processor.py create mode 100644 tests/app/services/session_processor/test_for_loop_processor_sqlite.py create mode 100644 tests/app/services/session_queue/test_for_loop_session_queue.py create mode 100644 tests/app/services/test_for_loop_session_runner.py diff --git a/docs/src/content/docs/contributing/loop-nodes.md b/docs/src/content/docs/contributing/loop-nodes.md new file mode 100644 index 00000000000..7ffecdcb734 --- /dev/null +++ b/docs/src/content/docs/contributing/loop-nodes.md @@ -0,0 +1,91 @@ +--- +title: Loop Nodes Architecture +--- + +This page records the implementation contract for the collection-based `For` and `ForReturn` nodes. The durable +contract is shared by the backend graph executor, saved workflow format, and workflow editor. + +## Core contract + +`For` is a bounded collection loop, not a general `While` node. Its source is one `collection: list[Any]` input. Each +iteration exposes `item`, `index`, `total`, and `state`; the final execution surface exposes `output_collection` and +`final_state`. `ForReturn` closes one iteration and may provide an output item, updated state, and a +`continue_condition`. + +Loop state is explicit `LoopState` graph data. It is copied and serialized with normal invocation inputs and results; +it is not stored in transient process-local context. If a return omits state, the previous state carries forward. A +missing or `None` continue condition continues; `False` finalizes the current loop after recording its output and state. + +The loop is sequential. A body failure or cancellation stops later iterations and does not release partial final-scoped +outputs. An empty collection is successful: no body node runs, `output_collection` is empty, and `final_state` is the +provided initial state or an empty state. + +## Durable loop linkage + +Every `For` and `ForReturn` pair is associated by a serialized, direct `loop_linkage` edge: + +```text +For.loop_linkage - - - - - - - - - - - - - - - - - > ForReturn.loop_linkage +For.item -> body path -> ForReturn.output +``` + +This edge is an association, not executable data flow. It is excluded from ordinary input propagation, cycle detection, +and scheduling. The backend requires exactly one outgoing linkage for every `For`, exactly one incoming linkage for every +`ForReturn`, and the exact `For.loop_linkage` to `ForReturn.loop_linkage` endpoints. Default edges using the reserved +`loop_linkage` field are invalid. + +The editor may represent the association temporarily as a one-to-one connector alias: + +```text +For.loop_linkage -> connector.in -> connector.out -> ForReturn.loop_linkage +``` + +Every connector on that path must have exactly one input and one output. The path cannot branch, be reused as ordinary +data flow, or terminate at a different node. Graph construction canonicalizes a complete alias to one direct runtime +`loop_linkage` edge. No loop identity or body metadata is inferred or migrated. + +## Body and output scopes + +Iteration-scoped outputs (`item`, `index`, `total`, and `state`) define the loop body. Final-scoped outputs +(`output_collection` and `final_state`) are available only after the matching loop context completes. Body nodes must +terminate at the linked `ForReturn`; they cannot escape directly to after-loop nodes. Final outputs cannot feed back into +the loop body. + +`ForReturn.output` and `ForReturn.state` are scheduler-facing result fields and are hidden as downstream editor outputs. +They are still retained in execution results for aggregation, persistence, and resume. Ordinary state helper nodes +(`state_empty`, `state_get`, `state_set`, and `state_merge`) carry explicit `LoopState` values through the body. + +## Supported nested shapes + +Nested `For` boundaries are supported recursively when each inner boundary has its own direct linkage and matching +`ForReturn`. The inner final collection may feed the parent return directly or through an ordinary parent-scoped +continuation. Independent inner loops must all feed one explicit fan-in continuation; collection concatenation, zipping, +or Cartesian semantics come from the connected collection operation, not from loop scheduling. + +A bounded internal `Iterate` is supported only when one `Collect` collapses its item dimension before the parent +`ForReturn`: + +```text +For.item -> Iterate.collection +Iterate.item -> body -> Collect.item +Collect.collection -> ForReturn.output +``` + +Unsupported shapes, including independent iterator-derived body inputs, mixed nested `For`/`Iterate` bodies, escaping +body paths, ambiguous returns, and arbitrary cyclic graphs, are rejected before execution. + +## Persistence and validation + +Prepared execution nodes, source/prepared mappings, iteration paths, results, indegrees, and finalized loop contexts +are persisted through `GraphExecutionState`. Runtime-only queues and metadata are rebuilt when state is rehydrated. +Finalization is keyed by the loop source and its parent iteration path so nested or repeated contexts cannot mix output +collections or state. + +The frontend and backend validate the same boundary rules. Saved workflows preserve node types, field handles, and the +direct linkage edge. The current invocation templates provide output-scope metadata when a workflow is loaded. The +editor's boundary overlay and contextual `ForReturn` picker are presentation aids; they do not replace whole-graph +validation. + +Collection helpers are ordinary explicit nodes: `CollectionConcat` preserves left-to-right order and accepts unequal +lengths, `CollectionZip` requires equal lengths, and `CollectionCartesian` produces deterministic left-major/right-minor +pairs with a 100,000-pair limit. They do not add implicit loop dimensions. diff --git a/docs/src/content/docs/features/Workflows/editor-interface.mdx b/docs/src/content/docs/features/Workflows/editor-interface.mdx index bce33485ad6..fb865111bd7 100644 --- a/docs/src/content/docs/features/Workflows/editor-interface.mdx +++ b/docs/src/content/docs/features/Workflows/editor-interface.mdx @@ -119,6 +119,9 @@ The screenshots below aren't examples of complete functioning node graphs, but r + For a guide to the newer collection-based **For** and **ForReturn** nodes, including loop state and nested loops, + see the [Loop Nodes guide](./loop-nodes). + ### Defined & Random Seeds It is common to want to use both the same seed (for continuity) and random seeds (for variety). To define a seed, simply enter it into the **'Seed'** field on a noise node. Conversely, the **RandomInt** node generates a random integer between 'Low' and 'High', and can be used as input to the 'Seed' edge point on a noise node to randomize your seed. diff --git a/docs/src/content/docs/features/Workflows/index.mdx b/docs/src/content/docs/features/Workflows/index.mdx index 0ce8b49de82..662627fa118 100644 --- a/docs/src/content/docs/features/Workflows/index.mdx +++ b/docs/src/content/docs/features/Workflows/index.mdx @@ -20,6 +20,12 @@ You can read more about nodes and how to use the node editor by checking out the href="../workflows/editor-interface" /> + + ## Downloading New Nodes To download a new node and enhance your workflows with new features, visit our list of Community Nodes. These are nodes that have been created by the community, for the community. diff --git a/docs/src/content/docs/features/Workflows/loop-nodes.mdx b/docs/src/content/docs/features/Workflows/loop-nodes.mdx new file mode 100644 index 00000000000..ffb856cf43b --- /dev/null +++ b/docs/src/content/docs/features/Workflows/loop-nodes.mdx @@ -0,0 +1,224 @@ +--- +title: Loop Nodes +description: Repeat workflow steps over a collection and optionally carry values from one iteration to the next. +sidebar: + order: 4 +lastUpdated: 2026-08-30 +--- + +Loop nodes let a workflow repeat the same group of steps for every item in a collection. For example, a workflow can +process every image found by a detector, try several seeds, or build a collection of results one item at a time. + +The main loop uses two nodes: + +- **For** starts the loop and provides one collection item at a time. +- **ForReturn** marks the end of the loop body and sends the result back to the loop. + +## The Basic Pattern + +Connect the nodes in this order: + +```text +Collection producer -> For.collection +For.loop_linkage - - - - - - - - - - - - - - - - - > ForReturn.loop_linkage +For.item -> work nodes -> ForReturn.output +For.output_collection -> nodes after the loop +``` + +The `For` node has one iteration for each item in its `collection`. During an iteration, its `item`, `index`, `total`, +and `state` outputs contain values for that iteration. The work nodes run, then `ForReturn` closes that iteration. + +The `For` node does not send its per-item `item` output directly to nodes after the loop. To run a node once after all +iterations finish, connect that node to `For.output_collection` or `For.final_state`. + +The editor draws a dashed green boundary around the loop body. This is a visual guide showing which nodes repeat; the +boundary does not carry data. The dashed green line between `For.loop_linkage` and `ForReturn.loop_linkage` is a required +association, not a data value. The remaining values move through the ports and edges you connect. + +The loop is not executable until its two boundary nodes are paired. Connect `For.loop_linkage` directly to the matching +`ForReturn.loop_linkage`. When you add `ForReturn` from an iteration-output connection in the node picker, the editor can +create this association for you. Replacing either boundary node requires pairing the replacement again. + +### A Simple Example + +To process every image in a collection: + +```text +Image collection -> For.collection +For.item -> Image Processor.image +Image Processor.image -> ForReturn.output +For.output_collection -> Save or display node +``` + +The processor runs once per image. The final `output_collection` contains the processed images in the same order as the +input collection. + +`ForReturn.output` may be disconnected when the result is carried through loop state instead. A body still needs a +dependency into `ForReturn`, such as a state update or a scalar `continue_condition`; there is no separate completion +port for effects-only bodies. + +## What The For Outputs Mean + +| Output | Meaning | Use it for | +| --- | --- | --- | +| `loop_linkage` | The association port for the matching `ForReturn` | Pairing the loop boundaries; it is not data | +| `item` | The current collection item | The work done during this iteration | +| `index` | The zero-based position of the current item | Labels, counters, or position-based logic | +| `total` | The collection length | Progress or position-based logic | +| `state` | The current loop state | Values carried from earlier iterations | +| `output_collection` | All values returned through `ForReturn.output` | Work that should happen after the loop | +| `final_state` | The state from the last completed iteration | Reading accumulated values after the loop | + +The `item`, `index`, `total`, and `state` outputs belong inside the loop body. The `output_collection` and `final_state` +outputs become available after the loop finishes. `loop_linkage` is only for pairing the two boundary nodes. + +## Returning Results + +`ForReturn` closes the loop body. It has four useful inputs: + +- **Output** adds one value to `For.output_collection` for the current iteration. +- **State** supplies the state for the next iteration. +- **Continue Condition** controls whether another item should be processed. It continues by default; connecting `False` + stops after the current iteration. +- **Loop Linkage** pairs this node with its owning `For`. It must be connected directly to `For.loop_linkage`; it does + not provide a value to the loop body. + +The `Loop Linkage` connection is separate from the body data connections. It tells the engine which `ForReturn` closes +which `For`, which matters when loops are nested or several loop boundaries are present in one workflow. + +## Carrying Values With State + +State is an optional set of named values that travels from one iteration to the next. It is useful for a running sum, a +changing parameter, or several values that must be updated together. + +The state helper nodes make this easier: + +- **Empty Loop State** creates a blank state. +- **Get Loop State Value** reads a value by name. Its `default` input is used when that name has not been set yet. +- **Set Loop State Value** returns a copy of the state with one named value changed. +- **Merge Loop State Values** returns a copy with several named values changed. + +A running value usually looks like this: + +```text +For.state -> Get Loop State Value.state +For.item -> Add or other calculation +Get.value + For.item -> calculation +calculation -> Set Loop State Value.value +Set Loop State Value.state -> ForReturn.state +For.final_state -> Get Loop State Value.state (after the loop) +``` + +Set the `default` on `Get Loop State Value` when the value does not exist during the first iteration. For example, use +`0` for the initial value of a running sum. + +State is separate from `output_collection`: + +- Use `output` when each iteration produces a result that should be collected automatically. +- Use state when the loop needs to remember or update a value while it runs. +- Use both when the loop needs per-item results and an accumulator. + +## Making Collections For A Loop + +`For` accepts one collection input. Use collection-producing nodes before it when the values need to be calculated. + +### Range Nodes + +- **Integer Range** creates values from `start` up to, but not including, `stop`. +- **Integer Range of Size** creates a requested number of values. +- **Random Range** creates a collection of random integers using its seed and bounds. + +For example: + +```text +Integer Range of Size -> For.collection +For.item -> work nodes +``` + +This is useful when the loop should run a known number of times. The integer itself can be used as a seed, index, or +ordinary numeric input. + +### Combining Collections + +These nodes make the collection relationship explicit before the loop: + +- **Concatenate Collections** puts every item from the first collection before every item from the second. The two + collections may have different lengths. +- **Zip Collections** pairs items by position. Both collections must have the same length; a mismatch is an error. +- **Cartesian Product of Collections** creates every possible pair, one item from each collection. The collections may + have different lengths. The result is limited to 100,000 pairs. + +Choose based on the relationship between the items: + +```text +Concatenate: first phase, then second phase +Zip: first[0] with second[0], first[1] with second[1], ... +Cartesian: every item in first with every item in second +``` + +An empty input to `Concatenate` is harmless. An empty input to `Zip` is valid when both inputs are empty. An empty input +to `Cartesian Product` produces no pairs. + +## For And Iterate + +Both `For` and `Iterate` repeat work over a collection, but they serve different purposes: + +- Use **Iterate** when a normal workflow needs to expand a collection into separate executions and **Collect** when those + executions should be gathered again. +- Use **For** when the repeated work has a clear beginning and end, needs loop state, may stop early, or should expose a + final collection and final state. + +An `Iterate` can also be used inside a `For` for a bounded inner pass. The inner values must be gathered by one +`Collect` before the outer `ForReturn`: + +```text +For.item -> Iterate.collection +Iterate.item -> inner work -> Collect.item +Collect.collection -> ForReturn.output +``` + +The outer loop waits for the inner `Iterate` and `Collect` to finish, then returns one collection for that outer item. +Other arrangements, such as multiple internal `Iterate` nodes or an independent external `Iterate` feeding a `For` body, +are not supported. + +## Nested For Loops + +A `For` can contain another `For`. The inner loop must finish before the outer loop returns its current item: + +```text +OuterFor.item -> InnerFor.collection +InnerFor.item -> inner work -> InnerForReturn.output +InnerFor.output_collection -> OuterForReturn.output +``` + +Each `For` must have its own matching `ForReturn`. The editor and saved workflow keep those pairings separate, so state +from an inner loop does not automatically become state for the outer loop. Connect values explicitly when they need to +cross a loop boundary. + +Multiple inner loops can be used as siblings when their final collections feed an ordinary collection-combining node. +That node defines whether those collections are concatenated, zipped, or combined as a Cartesian product. The workflow +does not choose one of those meanings automatically. + +## Empty Collections And Early Stop + +An empty collection is a successful loop with zero body iterations: + +- `output_collection` is empty. +- `final_state` is the provided initial state, or an empty state. +- No body node runs. + +Set `ForReturn.continue_condition` to `False` when the loop should stop after the current item. The results and state +from completed iterations remain available through the normal final outputs. + +## Common Wiring Mistakes + +- Connect the collection to `For.collection`, not to `For.item`. +- Connect `For.loop_linkage` directly to the matching `ForReturn.loop_linkage`. +- Connect body work to `ForReturn.output` when its results should be collected. +- Connect after-loop work to `For.output_collection` or `For.final_state`, not to `For.item` or `For.state`. +- Give each nested `For` its own `ForReturn`. +- Use `Zip Collections` only when the input lengths must match. +- Use `Cartesian Product of Collections` carefully: large inputs can produce many pairs. + +For the execution and validation rules behind these connections, see the contributor reference: +[`Loop Nodes Architecture`](../../contributing/loop-nodes). diff --git a/invokeai/app/invocations/collections.py b/invokeai/app/invocations/collections.py index 39e77f5b637..0764ad4fd91 100644 --- a/invokeai/app/invocations/collections.py +++ b/invokeai/app/invocations/collections.py @@ -1,15 +1,19 @@ # Copyright (c) 2023 Kyle Schouviller (https://github.com/kyle0654) and the InvokeAI Team +from typing import Any + import numpy as np from pydantic import ValidationInfo, field_validator -from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation -from invokeai.app.invocations.fields import InputField +from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput, invocation, invocation_output +from invokeai.app.invocations.fields import InputField, OutputField, UIType from invokeai.app.invocations.primitives import IntegerCollectionOutput from invokeai.app.services.shared.invocation_context import InvocationContext from invokeai.app.util.misc import SEED_MAX +MAX_CARTESIAN_PRODUCT_SIZE = 100_000 + @invocation("range", title="Integer Range", tags=["collection", "integer", "range"], category="batch", version="1.0.0") class RangeInvocation(BaseInvocation): @@ -73,3 +77,77 @@ class RandomRangeInvocation(BaseInvocation): def invoke(self, context: InvocationContext) -> IntegerCollectionOutput: rng = np.random.default_rng(self.seed) return IntegerCollectionOutput(collection=list(rng.integers(low=self.low, high=self.high, size=self.size))) + + +@invocation_output("collection_concat_output") +class CollectionConcatInvocationOutput(BaseInvocationOutput): + collection: list[Any] = OutputField(description="The concatenated collection", ui_type=UIType._Collection) + + +@invocation( + "collection_concat", + title="Concatenate Collections", + tags=["collection", "concat", "sequential"], + category="batch", + version="1.0.0", +) +class CollectionConcatInvocation(BaseInvocation): + """Concatenates two collections in left-to-right order.""" + + first: list[Any] = InputField(default=[], description="The first collection", ui_type=UIType._Collection) + second: list[Any] = InputField(default=[], description="The second collection", ui_type=UIType._Collection) + + def invoke(self, context: InvocationContext) -> CollectionConcatInvocationOutput: + return CollectionConcatInvocationOutput(collection=[*self.first, *self.second]) + + +@invocation_output("collection_zip_output") +class CollectionZipInvocationOutput(BaseInvocationOutput): + collection: list[Any] = OutputField(description="The positional pairs", ui_type=UIType._Collection) + + +@invocation( + "collection_zip", + title="Zip Collections", + tags=["collection", "zip", "pair"], + category="batch", + version="1.0.0", +) +class CollectionZipInvocation(BaseInvocation): + """Pairs items at matching positions from two equally sized collections.""" + + first: list[Any] = InputField(default=[], description="The first collection", ui_type=UIType._Collection) + second: list[Any] = InputField(default=[], description="The second collection", ui_type=UIType._Collection) + + def invoke(self, context: InvocationContext) -> CollectionZipInvocationOutput: + if len(self.first) != len(self.second): + raise ValueError("Zip inputs must have the same length") + return CollectionZipInvocationOutput( + collection=[[first, second] for first, second in zip(self.first, self.second, strict=True)] + ) + + +@invocation_output("collection_cartesian_output") +class CollectionCartesianInvocationOutput(BaseInvocationOutput): + collection: list[Any] = OutputField(description="The Cartesian product pairs", ui_type=UIType._Collection) + + +@invocation( + "collection_cartesian", + title="Cartesian Product of Collections", + tags=["collection", "cartesian", "product"], + category="batch", + version="1.0.0", +) +class CollectionCartesianInvocation(BaseInvocation): + """Emits every pair formed by one item from each collection, up to 100,000 pairs.""" + + first: list[Any] = InputField(default=[], description="The first collection", ui_type=UIType._Collection) + second: list[Any] = InputField(default=[], description="The second collection", ui_type=UIType._Collection) + + def invoke(self, context: InvocationContext) -> CollectionCartesianInvocationOutput: + if self.first and self.second and len(self.first) > MAX_CARTESIAN_PRODUCT_SIZE // len(self.second): + raise ValueError(f"Cartesian product exceeds the maximum size of {MAX_CARTESIAN_PRODUCT_SIZE} pairs") + return CollectionCartesianInvocationOutput( + collection=[[first, second] for first in self.first for second in self.second] + ) diff --git a/invokeai/app/invocations/fields.py b/invokeai/app/invocations/fields.py index 97b4da0a3db..ecfba7a63b2 100644 --- a/invokeai/app/invocations/fields.py +++ b/invokeai/app/invocations/fields.py @@ -31,7 +31,7 @@ class UIType(str, Enum, metaclass=MetaEnum): - Any Field We cannot infer the usage of `typing.Any` via schema parsing, so you *must* use `ui_type=UIType.Any` to - indicate that the field accepts any type. Use with caution. This cannot be used on outputs. + indicate that the field accepts any type. Use with caution. On inputs, this renders as a connection-only field. - Scheduler Field Special handling in the UI is needed for this field, which otherwise would be parsed as a plain enum field. @@ -549,6 +549,17 @@ class FieldKind(str, Enum, metaclass=MetaEnum): NodeAttribute = "node_attribute" +class OutputScope(str, Enum, metaclass=MetaEnum): + """ + The execution scope for an output field. + - `Iteration`: The field emits values for a loop body's current iteration. + - `Final`: The field emits values after a loop boundary completes. + """ + + Iteration = "iteration" + Final = "final" + + class InputFieldJSONSchemaExtra(BaseModel): """ Extra attributes to be added to input fields and their OpenAPI schema. Used during graph execution, @@ -630,6 +641,7 @@ class OutputFieldJSONSchemaExtra(BaseModel): ui_hidden: bool = False ui_order: Optional[int] = None ui_type: Optional[UIType] = None + output_scope: Optional[OutputScope] = None model_config = ConfigDict( validate_assignment=True, @@ -949,6 +961,7 @@ def OutputField( ui_type: Optional[UIType] = None, ui_hidden: bool = False, ui_order: Optional[int] = None, + output_scope: Optional[OutputScope] = None, ) -> Any: """ Creates an output field for an invocation output. @@ -965,6 +978,9 @@ def OutputField( ui_order: Specifies the order in which this field should be rendered in the UI. If omitted, the field will be rendered after all fields with an explicit order, in the order they are defined in the Invocation class. + + output_scope: Optionally specifies whether this output is scoped to a loop iteration or to the final loop + result. Unscoped outputs have the normal invocation output behavior. """ return Field( @@ -987,6 +1003,7 @@ def OutputField( ui_hidden=ui_hidden, ui_order=ui_order, ui_type=ui_type, + output_scope=output_scope, field_kind=FieldKind.Output, ).model_dump(exclude_none=True), ) diff --git a/invokeai/app/invocations/loops.py b/invokeai/app/invocations/loops.py new file mode 100644 index 00000000000..4c472b352bc --- /dev/null +++ b/invokeai/app/invocations/loops.py @@ -0,0 +1,214 @@ +import copy +from typing import Any, Optional, TypeVar + +from pydantic import BaseModel, Field + +from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput, invocation, invocation_output +from invokeai.app.invocations.fields import Input, InputField, OutputField, OutputScope, UIType +from invokeai.app.services.shared.invocation_context import InvocationContext + +T = TypeVar("T") + + +def _copy_value(value: T) -> T: + if isinstance(value, BaseModel): + return value.model_copy(deep=True) + return copy.deepcopy(value) + + +class LoopState(BaseModel): + values: dict[str, Any] = Field(default_factory=dict) + + +@invocation_output("loop_state_output") +class LoopStateOutput(BaseInvocationOutput): + state: LoopState = OutputField(description="The loop state") + + +@invocation_output("loop_state_value_output") +class LoopStateValueOutput(BaseInvocationOutput): + value: Optional[Any] = OutputField( + default=None, + description="The value read from the loop state, or None when the key is missing", + ui_type=UIType.Any, + ) + + +@invocation("state_empty", title="Empty Loop State", tags=["loop", "state"], category="workflow", version="1.0.0") +class StateEmptyInvocation(BaseInvocation): + """Creates an empty loop state.""" + + def invoke(self, context: InvocationContext) -> LoopStateOutput: + return LoopStateOutput(state=LoopState()) + + +@invocation("state_get", title="Get Loop State Value", tags=["loop", "state"], category="workflow", version="1.0.2") +class StateGetInvocation(BaseInvocation): + """Reads a value from loop state.""" + + state: LoopState = InputField(description="The loop state to read") + key: str = InputField(default="", description="The state key to read") + default: Any = InputField( + default=None, + description="The value to return when the key is missing", + ui_type=UIType.Any, + ) + + def invoke(self, context: InvocationContext) -> LoopStateValueOutput: + return LoopStateValueOutput(value=_copy_value(self.state.values.get(self.key, self.default))) + + +@invocation("state_set", title="Set Loop State Value", tags=["loop", "state"], category="workflow", version="1.0.1") +class StateSetInvocation(BaseInvocation): + """Returns loop state with one value set.""" + + state: Optional[LoopState] = InputField(default=None, description="The loop state to update") + key: str = InputField(default="", description="The state key to set") + value: Any = InputField( + default=None, + description="The value to set. Connect an output to this input.", + ui_type=UIType.Any, + ) + + def invoke(self, context: InvocationContext) -> LoopStateOutput: + values = _copy_value((self.state or LoopState()).values) + values[self.key] = _copy_value(self.value) + return LoopStateOutput(state=LoopState(values=values)) + + +@invocation( + "state_merge", title="Merge Loop State Values", tags=["loop", "state"], category="workflow", version="1.0.1" +) +class StateMergeInvocation(BaseInvocation): + """Returns loop state with multiple values merged.""" + + state: Optional[LoopState] = InputField(default=None, description="The loop state to update") + values: dict[str, Any] = InputField( + default_factory=dict, + description="The values to merge into the loop state. Connect an output to this input.", + ui_type=UIType.Any, + ) + + def invoke(self, context: InvocationContext) -> LoopStateOutput: + values = _copy_value((self.state or LoopState()).values) + values.update(_copy_value(self.values)) + return LoopStateOutput(state=LoopState(values=values)) + + +@invocation_output("for_output") +class ForInvocationOutput(BaseInvocationOutput): + loop_linkage: Any = OutputField( + description="The loop linkage to the matching ForReturn", + title="Loop Linkage", + ui_type=UIType.Any, + ) + item: Optional[Any] = OutputField( + default=None, + description="The item for the current loop iteration, or None when the collection is empty", + title="Collection Item", + ui_type=UIType._CollectionItem, + output_scope=OutputScope.Iteration, + ) + index: int = OutputField( + description="The index for the current loop iteration", + title="Index", + output_scope=OutputScope.Iteration, + ) + total: int = OutputField( + description="The total number of items in the loop collection", + title="Total", + output_scope=OutputScope.Iteration, + ) + state: LoopState = OutputField( + description="The state for the current loop iteration", + title="State", + output_scope=OutputScope.Iteration, + ) + output_collection: list[Any] = OutputField( + description="The collected loop body outputs", + title="Output Collection", + ui_type=UIType._Collection, + output_scope=OutputScope.Final, + ) + final_state: LoopState = OutputField( + description="The final loop state", + title="Final State", + output_scope=OutputScope.Final, + ) + + +@invocation("for", version="1.3.0") +class ForInvocation(BaseInvocation): + collection: list[Any] = InputField( + description="The list of items to iterate over", + default=[], + ui_type=UIType._Collection, + ) + state: Optional[LoopState] = InputField( + default=None, + description="Optional initial loop state", + ) + index: int = InputField( + description="The internal iteration index for a prepared For execution node", + default=-1, + input=Input.Direct, + ui_hidden=True, + ) + + def invoke(self, context: InvocationContext) -> ForInvocationOutput: + if self.index < 0: + raise NotImplementedError("ForInvocation is scheduler-special and cannot be invoked directly") + + state = self.state or LoopState() + return ForInvocationOutput( + loop_linkage="loop_linkage", + item=self.collection[self.index], + index=self.index, + total=len(self.collection), + state=state, + output_collection=[], + final_state=state, + ) + + +@invocation_output("for_return_output") +class ForReturnInvocationOutput(BaseInvocationOutput): + output: Optional[Any] = OutputField( + default=None, + description="The output item to append to the loop output collection", + title="Output", + ui_type=UIType._CollectionItem, + ui_hidden=True, + ) + state: Optional[LoopState] = OutputField( + default=None, + description="The state to pass to the next loop iteration", + title="State", + ui_hidden=True, + ) + + +@invocation("for_return", version="1.3.2") +class ForReturnInvocation(BaseInvocation): + loop_linkage: Optional[Any] = InputField( + default=None, + description="The loop linkage from the matching For", + input=Input.Connection, + ui_type=UIType.Any, + ) + output: Optional[Any] = InputField( + default=None, + description="The output item to append to the loop output collection", + ui_type=UIType._CollectionItem, + ) + state: Optional[LoopState] = InputField( + default=None, + description="The state to pass to the next loop iteration", + ) + continue_condition: Optional[bool] = InputField( + default=True, + description="Whether to schedule the next loop iteration; false finalizes the loop", + ) + + def invoke(self, context: InvocationContext) -> ForReturnInvocationOutput: + return ForReturnInvocationOutput(output=self.output, state=self.state) diff --git a/invokeai/app/services/shared/graph.py b/invokeai/app/services/shared/graph.py index 1d233e028a9..62da8178a83 100644 --- a/invokeai/app/services/shared/graph.py +++ b/invokeai/app/services/shared/graph.py @@ -49,8 +49,15 @@ CallSavedWorkflowInvocation, is_call_saved_workflow_dynamic_input, ) -from invokeai.app.invocations.fields import Input, InputField, OutputField, UIType +from invokeai.app.invocations.fields import Input, InputField, OutputField, OutputScope, UIType from invokeai.app.invocations.logic import IfInvocation +from invokeai.app.invocations.loops import ( + ForInvocation, + ForInvocationOutput, + ForReturnInvocation, + ForReturnInvocationOutput, + LoopState, +) from invokeai.app.services.shared.invocation_context import InvocationContext from invokeai.app.util.misc import uuid_string @@ -81,6 +88,15 @@ def __getattr__(self, name: str) -> Any: # Port name constants ITEM_FIELD = "item" COLLECTION_FIELD = "collection" +LOOP_LINKAGE_FIELD = "loop_linkage" + + +@dataclass(frozen=True) +class _SupportedNestedForBody: + body_path_nodes: frozenset[str] + outer_return_id: str + inner_for_ids: tuple[str, ...] + continuation_nodes: frozenset[str] class EdgeConnection(BaseModel): @@ -103,6 +119,10 @@ def __hash__(self): class Edge(BaseModel): model_config = ConfigDict(frozen=True) + type: Literal["default", "loop_linkage"] = Field( + default="default", + description="The kind of relationship represented by this edge", + ) source: EdgeConnection = Field(description="The connection for the edge's from node and field") destination: EdgeConnection = Field(description="The connection for the edge's to node and field") @@ -453,16 +473,41 @@ def _get_iterator_iteration_count(self, node_id: str, iteration_node_map: list[t input_collection = getattr(input_collection_output, input_collection_edge.source.field) return len(input_collection) + def _get_for_iteration_count(self, node_id: str, iteration_node_map: list[tuple[str, str]]) -> int: + input_collection_edges = self._state.graph._get_input_edges(node_id, COLLECTION_FIELD) + if len(input_collection_edges) == 0: + node = self._state.graph.get_node(node_id) + assert isinstance(node, ForInvocation) + return len(node.collection) + + input_collection_edge = input_collection_edges[0] + input_collection_prepared_node_id = next( + prepared_id + for source_id, prepared_id in iteration_node_map + if source_id == input_collection_edge.source.node_id + ) + input_collection_output = self._state.results[input_collection_prepared_node_id] + input_collection = getattr(input_collection_output, input_collection_edge.source.field) + if not isinstance(input_collection, list): + raise ValueError("For collection input must be a list") + return len(input_collection) + def _get_new_node_iterations( self, node: BaseInvocation, node_id: str, iteration_node_map: list[tuple[str, str]] ) -> list[int]: - if not isinstance(node, IterateInvocation): - return [-1] + if isinstance(node, IterateInvocation): + iteration_count = self._get_iterator_iteration_count(node_id, iteration_node_map) + if iteration_count == 0: + return [] + return list(range(iteration_count)) - iteration_count = self._get_iterator_iteration_count(node_id, iteration_node_map) - if iteration_count == 0: - return [] - return list(range(iteration_count)) + if isinstance(node, ForInvocation): + iteration_count = self._get_for_iteration_count(node_id, iteration_node_map) + if iteration_count == 0: + return [] + return [0] + + return [-1] def _build_execution_edges(self, node_id: str, iteration_node_map: list[tuple[str, str]]) -> list[Edge]: input_edges = self._state.graph._get_input_edges(node_id) @@ -480,12 +525,27 @@ def _build_execution_edges(self, node_id: str, iteration_node_map: list[tuple[st ) return new_edges - def _create_execution_node_copy(self, node: BaseInvocation, node_id: str, iteration_index: int) -> BaseInvocation: - new_node = node.model_copy(deep=True) + def _has_unmaterializable_for_final_input(self, node_id: str) -> bool: + final_for_source_ids = set() + for edge in self._state.graph._get_input_edges(node_id): + source_node = self._state.graph.get_node(edge.source.node_id) + if not isinstance(source_node, ForInvocation): + continue + if get_output_field_scope(source_node, edge.source.field) == OutputScope.Final: + final_for_source_ids.add(edge.source.node_id) + + return any(not self._state._all_for_contexts_finalized(source_for_id) for source_for_id in final_for_source_ids) + + def _create_execution_node_copy( + self, node: BaseInvocation, node_id: str, iteration_index: int, *, deep_copy: bool = True + ) -> BaseInvocation: + new_node = node.model_copy(deep=deep_copy) new_node.id = uuid_string() if isinstance(new_node, IterateInvocation): new_node.index = iteration_index + if isinstance(new_node, ForInvocation): + new_node.index = iteration_index # Scheduler-managed iteration boundaries and collectors are cheaper to execute than to hash, especially when # their inputs contain large collections. Loop body nodes retain their normal cache behavior. @@ -496,6 +556,576 @@ def _create_execution_node_copy(self, node: BaseInvocation, node_id: str, iterat self._state._register_prepared_exec_node(new_node.id, node_id) return new_node + def _create_empty_for_final_output( + self, + source_for_id: str, + node: "ForInvocation", + iteration_node_map: list[tuple[str, str]], + ) -> str: + new_node = self._create_execution_node_copy(node, source_for_id, -1, deep_copy=False) + assert isinstance(new_node, ForInvocation) + new_edges = self._build_execution_edges(source_for_id, iteration_node_map) + iteration_path = self._get_known_iteration_path(-1, iteration_node_map) + if iteration_path is not None: + self._state._prepared_registry().set_iteration_path(new_node.id, iteration_path) + self._attach_execution_edges(new_node.id, new_edges) + self._state._runtime().prepare_inputs(new_node) + + initial_state = copydeep(new_node.state or LoopState()) + new_node.collection = [] + new_node.state = initial_state + + self._state.results[new_node.id] = ForInvocationOutput( + loop_linkage="loop_linkage", + item=None, + index=-1, + total=0, + state=initial_state, + output_collection=[], + final_state=initial_state, + ) + self._state.executed.add(new_node.id) + self._state._set_prepared_exec_state(new_node.id, "executed") + + return new_node.id + + def _mark_empty_for_complete(self, source_for_id: str) -> None: + prepared_for_ids = [ + prepared_id + for prepared_id in self._state._prepared_registry().get_prepared_ids(source_for_id) + if isinstance(self._state.execution_graph.get_node(prepared_id), ForInvocation) + and self._state.execution_graph.get_node(prepared_id).index == -1 + ] + if not prepared_for_ids: + self._state.finalized_loop_contexts.add((source_for_id, ())) + self._state.finalized_loop_nodes.add(source_for_id) + for prepared_for_id in prepared_for_ids: + self._state._mark_loop_context_finalized(source_for_id, prepared_for_id) + + self._state._mark_for_source_complete(source_for_id) + + def create_for_iteration( + self, + source_for_id: str, + iteration_index: int, + collection: list[Any], + state: "LoopState", + iteration_path: tuple[int, ...], + ) -> str: + node = self._state.graph.get_node(source_for_id) + if not isinstance(node, ForInvocation): + raise TypeError(f"Expected source ForInvocation, got {type(node).__name__}") + + new_node = self._create_execution_node_copy(node, source_for_id, iteration_index, deep_copy=False) + assert isinstance(new_node, ForInvocation) + new_node.collection = copydeep(collection) + new_node.state = copydeep(state) + self._state._prepared_registry().set_iteration_path(new_node.id, iteration_path) + self._initialize_execution_node(new_node.id) + return new_node.id + + def create_for_body_iteration(self, source_for_id: str, prepared_for_id: str) -> Optional[str]: + graph = self._state.graph.nx_graph_flat() + execution_graph = self._state.execution_graph.nx_graph_flat() + nested_body = self._state.graph._get_supported_for_nested_iterate_body(source_for_id, graph) + if nested_body is not None: + return self._create_nested_iterate_body_iteration( + source_for_id, prepared_for_id, graph, execution_graph, nested_body + ) + nested_for_body = self._state.graph._get_supported_for_nested_for_body(source_for_id, graph) + if nested_for_body is not None: + return self._create_nested_for_body_iteration( + source_for_id, prepared_for_id, graph, execution_graph, nested_for_body + ) + + body_path_to_return = self._state.graph._get_for_body_path_to_return(source_for_id, graph) + if body_path_to_return is None: + return None + + body_path_nodes, source_return_id = body_path_to_return + source_to_prepared = {source_for_id: prepared_for_id} + prepared_return_id: Optional[str] = None + + for source_node_id in nx.topological_sort(graph): + if source_node_id not in body_path_nodes: + continue + + node = self._state.graph.get_node(source_node_id) + new_edges: list[Edge] = [] + for edge in self._state.graph._get_input_edges(source_node_id): + prepared_source_id = source_to_prepared.get(edge.source.node_id) + if prepared_source_id is None: + prepared_source_id = self.get_iteration_node( + edge.source.node_id, + graph, + execution_graph, + [prepared_for_id], + ) + if prepared_source_id is None: + raise RuntimeError( + f"Unable to rematerialize For body input {edge}: no prepared source node is available" + ) + new_edges.append( + Edge( + source=EdgeConnection(node_id=prepared_source_id, field=edge.source.field), + destination=EdgeConnection(node_id="", field=edge.destination.field), + ) + ) + + new_node = self._create_execution_node_copy(node, source_node_id, -1) + source_to_prepared[source_node_id] = new_node.id + self._state._prepared_registry().set_iteration_path( + new_node.id, self._state._get_iteration_path(prepared_for_id) + ) + self._state.executed.discard(source_node_id) + attached_edges = self._attach_execution_edges(new_node.id, new_edges) + self._initialize_execution_node(new_node.id, attached_edges) + + if source_node_id == source_return_id: + prepared_return_id = new_node.id + + return prepared_return_id + + def _is_deferred_nested_for_return(self, node_id: str, graph: nx.DiGraph) -> bool: + return any( + (nested_body := self._state.graph._get_supported_for_nested_for_body(source_for_id, graph)) is not None + and nested_body.outer_return_id == node_id + for source_for_id, source_node in self._state.graph.nodes.items() + if isinstance(source_node, ForInvocation) + ) + + def _get_final_prepared_for_id(self, source_for_id: str, parent_iteration_path: tuple[int, ...]) -> str: + candidates = [ + prepared_id + for prepared_id in self._state._prepared_registry().get_prepared_ids(source_for_id) + if isinstance(self._state.execution_graph.get_node(prepared_id), ForInvocation) + and self._state._get_for_parent_iteration_path(prepared_id) == parent_iteration_path + ] + if not candidates: + raise RuntimeError(f"Unable to find finalized nested For '{source_for_id}' for {parent_iteration_path}") + return max( + candidates, + key=lambda prepared_id: self._state.execution_graph.get_node(prepared_id).index, + ) + + def create_nested_for_return(self, inner_for_id: str, prepared_inner_for_id: str) -> Optional[str]: + graph = self._state.graph.nx_graph_flat() + inner_iteration_path = self._state._get_iteration_path(prepared_inner_for_id) + outer_for_id: Optional[str] = None + nested_body: Optional[_SupportedNestedForBody] = None + for source_for_id, source_node in self._state.graph.nodes.items(): + if not isinstance(source_node, ForInvocation): + continue + candidate = self._state.graph._get_supported_for_nested_for_body(source_for_id, graph) + if candidate is not None and inner_for_id in candidate.inner_for_ids: + outer_for_id = source_for_id + nested_body = candidate + break + if outer_for_id is None or nested_body is None: + return None + + prepared_inner_for = self._state.execution_graph.get_node(prepared_inner_for_id) + outer_iteration_path = ( + inner_iteration_path + if isinstance(prepared_inner_for, ForInvocation) and prepared_inner_for.index == -1 + else inner_iteration_path[:-1] + ) + prepared_outer_for_id = next( + ( + prepared_id + for prepared_id in self._state._prepared_registry().get_prepared_ids(outer_for_id) + if self._state._get_iteration_path(prepared_id) == outer_iteration_path + ), + None, + ) + if prepared_outer_for_id is None: + raise RuntimeError("Unable to rematerialize nested ForReturn: owning outer For is unavailable") + + source_return_id = nested_body.outer_return_id + existing_return_ids = [ + prepared_id + for prepared_id in self._state._prepared_registry().get_prepared_ids(source_return_id) + if self._state._get_iteration_path(prepared_id) == outer_iteration_path + ] + if len(existing_return_ids) > 1: + raise RuntimeError( + f"Multiple nested ForReturn executions exist for {source_return_id} at {outer_iteration_path}" + ) + if existing_return_ids: + return existing_return_ids[0] + + continuation_nodes = self._state.graph._get_for_nested_for_continuation_nodes(nested_body) + prepared_inner_ids: dict[str, str] = {inner_for_id: prepared_inner_for_id} + if any( + not self._state._is_loop_context_finalized(inner_id, outer_iteration_path) + for inner_id in nested_body.inner_for_ids + ): + return None + for inner_id in nested_body.inner_for_ids: + if inner_id in prepared_inner_ids: + continue + prepared_inner_ids[inner_id] = self._get_final_prepared_for_id(inner_id, outer_iteration_path) + source_to_prepared: dict[str, str] = { + outer_for_id: prepared_outer_for_id, + **prepared_inner_ids, + } + for source_node_id in nx.topological_sort(graph): + if source_node_id not in continuation_nodes: + continue + + new_edges: list[Edge] = [] + for edge in self._state.graph._get_input_edges(source_node_id): + prepared_source_id = source_to_prepared.get(edge.source.node_id) + if prepared_source_id is None: + prepared_source_id = self.get_iteration_node( + edge.source.node_id, + graph, + self._state.execution_graph.nx_graph_flat(), + [prepared_outer_for_id], + ) + if prepared_source_id is None: + raise RuntimeError( + f"Unable to rematerialize nested For continuation input {edge}: no prepared source node is available" + ) + new_edges.append( + Edge( + source=EdgeConnection(node_id=prepared_source_id, field=edge.source.field), + destination=EdgeConnection(node_id="", field=edge.destination.field), + ) + ) + + new_node = self._create_execution_node_copy(self._state.graph.get_node(source_node_id), source_node_id, -1) + source_to_prepared[source_node_id] = new_node.id + self._state._prepared_registry().set_iteration_path(new_node.id, outer_iteration_path) + self._state.executed.discard(source_node_id) + attached_edges = self._attach_execution_edges(new_node.id, new_edges) + self._initialize_execution_node(new_node.id, attached_edges) + + self._state.executed.discard(source_return_id) + return_edges: list[Edge] = [] + for edge in self._state.graph._get_input_edges(source_return_id): + if edge.destination.field == "output": + prepared_source_id = source_to_prepared.get(edge.source.node_id) + source_field = edge.source.field + elif edge.destination.field == "state": + prepared_source_id = prepared_outer_for_id + source_field = edge.source.field + elif edge.destination.field == "continue_condition": + prepared_source_id = source_to_prepared.get(edge.source.node_id) + source_field = edge.source.field + else: + raise RuntimeError(f"Unable to rematerialize nested ForReturn input {edge}") + if prepared_source_id is None: + raise RuntimeError(f"Unable to rematerialize nested ForReturn input {edge}") + return_edges.append( + Edge( + source=EdgeConnection(node_id=prepared_source_id, field=source_field), + destination=EdgeConnection(node_id="", field=edge.destination.field), + ) + ) + + prepared_return_node = self._create_execution_node_copy( + self._state.graph.get_node(source_return_id), source_return_id, -1 + ) + self._state._prepared_registry().set_iteration_path(prepared_return_node.id, outer_iteration_path) + attached_return_edges = self._attach_execution_edges(prepared_return_node.id, return_edges) + self._initialize_execution_node(prepared_return_node.id, attached_return_edges) + return prepared_return_node.id + + def _create_nested_for_body_iteration( + self, + source_for_id: str, + prepared_for_id: str, + graph: nx.DiGraph, + execution_graph: nx.DiGraph, + nested_body: _SupportedNestedForBody, + ) -> Optional[str]: + body_path_nodes = nested_body.body_path_nodes + source_return_id = nested_body.outer_return_id + prepared_for_node = self._state.execution_graph.get_node(prepared_for_id) + outer_iteration_path = self._state._get_iteration_path(prepared_for_id) + if isinstance(prepared_for_node, ForInvocation) and prepared_for_node.index >= 0: + outer_iteration_path = ( + *self._state._get_for_parent_iteration_path(prepared_for_id), + prepared_for_node.index, + ) + + source_to_prepared: dict[str, str] = {source_for_id: prepared_for_id} + for source_node_id in nx.topological_sort(graph): + if source_node_id not in body_path_nodes or source_node_id in { + *nested_body.inner_for_ids, + source_return_id, + }: + continue + if not any(nx.has_path(graph, source_node_id, inner_for_id) for inner_for_id in nested_body.inner_for_ids): + continue + + existing_prepared_ids = [ + prepared_id + for prepared_id in self._state._prepared_registry().get_prepared_ids(source_node_id) + if self._state._get_iteration_path(prepared_id) == outer_iteration_path + ] + if len(existing_prepared_ids) == 1: + source_to_prepared[source_node_id] = existing_prepared_ids[0] + continue + + new_edges: list[Edge] = [] + for edge in self._state.graph._get_input_edges(source_node_id): + prepared_source_id = source_to_prepared.get(edge.source.node_id) + if prepared_source_id is None: + prepared_source_id = self.get_iteration_node( + edge.source.node_id, graph, execution_graph, [prepared_for_id] + ) + if prepared_source_id is None: + raise RuntimeError( + f"Unable to rematerialize nested For input {edge}: no prepared source node is available" + ) + new_edges.append( + Edge( + source=EdgeConnection(node_id=prepared_source_id, field=edge.source.field), + destination=EdgeConnection(node_id="", field=edge.destination.field), + ) + ) + + new_node = self._create_execution_node_copy(self._state.graph.get_node(source_node_id), source_node_id, -1) + source_to_prepared[source_node_id] = new_node.id + self._state._prepared_registry().set_iteration_path(new_node.id, outer_iteration_path) + self._state.executed.discard(source_node_id) + attached_edges = self._attach_execution_edges(new_node.id, new_edges) + self._initialize_execution_node(new_node.id, attached_edges) + + for body_node_id in body_path_nodes: + if body_node_id in nested_body.inner_for_ids or not any( + nx.has_path(graph, body_node_id, inner_for_id) for inner_for_id in nested_body.inner_for_ids + ): + self._state.executed.discard(body_node_id) + self._state.executed.discard(source_return_id) + + for source_inner_for_id in nested_body.inner_for_ids: + existing_prepared_ids = [ + prepared_id + for prepared_id in self._state._prepared_registry().get_prepared_ids(source_inner_for_id) + if self._state._get_for_parent_iteration_path(prepared_id) == outer_iteration_path + ] + if existing_prepared_ids: + continue + + inner_input_map: list[tuple[str, str]] = [] + for edge in self._state.graph._get_input_edges(source_inner_for_id): + prepared_source_id = source_to_prepared.get(edge.source.node_id) + if prepared_source_id is None: + prepared_source_id = self.get_iteration_node( + edge.source.node_id, graph, execution_graph, [prepared_for_id] + ) + if prepared_source_id is None: + raise RuntimeError( + f"Unable to rematerialize nested For input {edge}: no prepared source node is available" + ) + inner_input_map.append((edge.source.node_id, prepared_source_id)) + + if any(prepared_source_id not in self._state.results for _, prepared_source_id in inner_input_map): + return None + + self._state.executed.discard(source_inner_for_id) + inner_prepared_ids = self.create_execution_node( + source_inner_for_id, inner_input_map, iteration_path=outer_iteration_path + ) + if not inner_prepared_ids: + self._mark_source_node_empty(source_inner_for_id) + elif all( + isinstance(self._state.execution_graph.get_node(inner_id), ForInvocation) + and self._state.execution_graph.get_node(inner_id).index == -1 + for inner_id in inner_prepared_ids + ): + self._mark_empty_for_complete(source_inner_for_id) + for inner_prepared_id in inner_prepared_ids: + self.create_nested_for_return(source_inner_for_id, inner_prepared_id) + else: + for inner_prepared_id in inner_prepared_ids: + self.create_for_body_iteration( + source_for_id=source_inner_for_id, + prepared_for_id=inner_prepared_id, + ) + + return None + + def _create_nested_iterate_body_iteration( + self, + source_for_id: str, + prepared_for_id: str, + graph: nx.DiGraph, + execution_graph: nx.DiGraph, + nested_body: tuple[set[str], str, str, str], + ) -> Optional[str]: + body_path_nodes, source_return_id, source_iterate_id, source_collect_id = nested_body + prepared_for_node = self._state.execution_graph.get_node(prepared_for_id) + outer_iteration_path = self._state._get_iteration_path(prepared_for_id) + if isinstance(prepared_for_node, ForInvocation) and prepared_for_node.index >= 0: + outer_iteration_path = ( + *self._state._get_for_parent_iteration_path(prepared_for_id), + prepared_for_node.index, + ) + source_to_prepared: dict[str, str] = {source_for_id: prepared_for_id} + inner_prepared_by_source: dict[tuple[str, str], str] = {} + + def resolve_outer_input(source_node_id: str) -> Optional[str]: + prepared_source_id = source_to_prepared.get(source_node_id) + if prepared_source_id is not None: + return prepared_source_id + return self.get_iteration_node(source_node_id, graph, execution_graph, [prepared_for_id]) + + def create_body_copy(source_node_id: str, input_resolver, iteration_path: tuple[int, ...]) -> str: + new_edges: list[Edge] = [] + for edge in self._state.graph._get_input_edges(source_node_id): + prepared_source_id = input_resolver(edge.source.node_id) + if prepared_source_id is None: + raise RuntimeError( + f"Unable to rematerialize For body input {edge}: no prepared source node is available" + ) + new_edges.append( + Edge( + source=EdgeConnection(node_id=prepared_source_id, field=edge.source.field), + destination=EdgeConnection(node_id="", field=edge.destination.field), + ) + ) + + new_node = self._create_execution_node_copy(self._state.graph.get_node(source_node_id), source_node_id, -1) + self._state._prepared_registry().set_iteration_path(new_node.id, iteration_path) + attached_edges = self._attach_execution_edges(new_node.id, new_edges) + self._initialize_execution_node(new_node.id, attached_edges) + return new_node.id + + def get_existing_body_node(source_node_id: str) -> Optional[str]: + matching_ids = [ + prepared_id + for prepared_id in self._state._prepared_registry().get_prepared_ids(source_node_id) + if self._state._get_iteration_path(prepared_id) == outer_iteration_path + ] + if len(matching_ids) == 1: + return matching_ids[0] + return None + + for source_node_id in nx.topological_sort(graph): + if source_node_id not in body_path_nodes or source_node_id in { + source_iterate_id, + source_collect_id, + source_return_id, + }: + continue + if not nx.has_path(graph, source_node_id, source_iterate_id): + continue + source_to_prepared[source_node_id] = get_existing_body_node(source_node_id) or create_body_copy( + source_node_id, resolve_outer_input, outer_iteration_path + ) + + iterate_input_map: list[tuple[str, str]] = [] + for edge in self._state.graph._get_input_edges(source_iterate_id): + prepared_source_id = resolve_outer_input(edge.source.node_id) + if prepared_source_id is None: + raise RuntimeError( + f"Unable to rematerialize For body input {edge}: no prepared source node is available" + ) + iterate_input_map.append((edge.source.node_id, prepared_source_id)) + + if any(prepared_source_id not in self._state.results for _, prepared_source_id in iterate_input_map): + return None + + self._state.executed.discard(source_iterate_id) + inner_prepared_ids = self.create_execution_node( + source_iterate_id, iterate_input_map, iteration_path=outer_iteration_path + ) + if not inner_prepared_ids: + self._mark_source_node_empty(source_iterate_id) + + for inner_prepared_id in inner_prepared_ids: + inner_iteration_path = self._state._get_iteration_path(inner_prepared_id) + for source_node_id in nx.topological_sort(graph): + if source_node_id not in body_path_nodes: + continue + if source_node_id in {source_iterate_id, source_collect_id, source_return_id}: + continue + if nx.has_path(graph, source_node_id, source_iterate_id): + continue + if not nx.has_path(graph, source_iterate_id, source_node_id): + continue + + def resolve_inner_input( + input_source_node_id: str, current_inner_prepared_id: str = inner_prepared_id + ) -> Optional[str]: + if input_source_node_id == source_iterate_id: + return current_inner_prepared_id + prepared_source_id = source_to_prepared.get(input_source_node_id) + if prepared_source_id is not None: + return prepared_source_id + prepared_source_id = inner_prepared_by_source.get((input_source_node_id, current_inner_prepared_id)) + if prepared_source_id is not None: + return prepared_source_id + return self.get_iteration_node( + input_source_node_id, graph, execution_graph, [current_inner_prepared_id] + ) + + self._state.executed.discard(source_node_id) + prepared_id = create_body_copy(source_node_id, resolve_inner_input, inner_iteration_path) + inner_prepared_by_source[(source_node_id, inner_prepared_id)] = prepared_id + + if not inner_prepared_ids: + for source_node_id in body_path_nodes: + if source_node_id in {source_iterate_id, source_collect_id, source_return_id}: + continue + if nx.has_path(graph, source_iterate_id, source_node_id): + self._mark_source_node_empty(source_node_id) + + collect_item_edge = self._state.graph._get_input_edges(source_collect_id, ITEM_FIELD)[0] + collect_edges: list[Edge] = [] + for inner_prepared_id in inner_prepared_ids: + prepared_source_id = inner_prepared_by_source.get((collect_item_edge.source.node_id, inner_prepared_id)) + if prepared_source_id is None and collect_item_edge.source.node_id == source_iterate_id: + prepared_source_id = inner_prepared_id + if prepared_source_id is None: + raise RuntimeError( + f"Unable to rematerialize For body input {collect_item_edge}: no prepared source node is available" + ) + collect_edges.append( + Edge( + source=EdgeConnection(node_id=prepared_source_id, field=collect_item_edge.source.field), + destination=EdgeConnection(node_id="", field=ITEM_FIELD), + ) + ) + + self._state.executed.discard(source_collect_id) + collect_node = self._state.graph.get_node(source_collect_id) + prepared_collect_node = self._create_execution_node_copy(collect_node, source_collect_id, -1) + self._state._prepared_registry().set_iteration_path(prepared_collect_node.id, outer_iteration_path) + attached_collect_edges = self._attach_execution_edges(prepared_collect_node.id, collect_edges) + self._initialize_execution_node(prepared_collect_node.id, attached_collect_edges) + + return_edges: list[Edge] = [] + for edge in self._state.graph._get_input_edges(source_return_id): + if edge.destination.field == "output": + prepared_source_id = prepared_collect_node.id + source_field = COLLECTION_FIELD + else: + prepared_source_id = resolve_outer_input(edge.source.node_id) + source_field = edge.source.field + if prepared_source_id is None: + raise RuntimeError( + f"Unable to rematerialize For body input {edge}: no prepared source node is available" + ) + return_edges.append( + Edge( + source=EdgeConnection(node_id=prepared_source_id, field=source_field), + destination=EdgeConnection(node_id="", field=edge.destination.field), + ) + ) + self._state.executed.discard(source_return_id) + prepared_return_node = self._create_execution_node_copy( + self._state.graph.get_node(source_return_id), source_return_id, -1 + ) + self._state._prepared_registry().set_iteration_path(prepared_return_node.id, outer_iteration_path) + attached_return_edges = self._attach_execution_edges(prepared_return_node.id, return_edges) + self._initialize_execution_node(prepared_return_node.id, attached_return_edges) + return prepared_return_node.id + def _attach_execution_edges(self, exec_node_id: str, new_edges: list[Edge]) -> list[Edge]: attached_edges = [ Edge( @@ -516,7 +1146,15 @@ def _initialize_execution_node(self, exec_node_id: str, input_edges: Optional[li def _get_collect_iteration_group_key(self, edge: Edge, sibling_depth: Optional[int] = None) -> tuple[int, ...]: path = self._state._get_iteration_path(edge.source.node_id) + source_node = self._state.execution_graph.get_node(edge.source.node_id) + if ( + isinstance(source_node, ForInvocation) + and get_output_field_scope(source_node, edge.source.field) == OutputScope.Final + ): + return self._state._get_for_parent_iteration_path(edge.source.node_id) if edge.destination.field == ITEM_FIELD: + if isinstance(source_node, ForInvocation) and source_node.index == -1: + return path # Ragged siblings need the deepest path to identify their shared outer group. depth = len(path) if sibling_depth is None else sibling_depth return path[: max(depth - 1, 0)] @@ -534,12 +1172,48 @@ def _get_ordered_prepared_nodes_for_source(self, source_node_id: str) -> list[st key=lambda exec_node_id: (self._state._get_iteration_path(exec_node_id), exec_node_id), ) + def _get_ordered_prepared_nodes_for_edge(self, edge: Edge) -> list[str]: + prepared_nodes = self._get_ordered_prepared_nodes_for_source(edge.source.node_id) + source_node = self._state.graph.get_node(edge.source.node_id) + if not ( + isinstance(source_node, ForInvocation) + and get_output_field_scope(source_node, edge.source.field) == OutputScope.Final + ): + return prepared_nodes + + final_nodes_by_parent_path: dict[tuple[int, ...], str] = {} + for prepared_id in prepared_nodes: + parent_path = self._state._get_for_parent_iteration_path(prepared_id) + previous_id = final_nodes_by_parent_path.get(parent_path) + if previous_id is None: + final_nodes_by_parent_path[parent_path] = prepared_id + continue + previous_node = self._state.execution_graph.get_node(previous_id) + prepared_node = self._state.execution_graph.get_node(prepared_id) + assert isinstance(previous_node, ForInvocation) + assert isinstance(prepared_node, ForInvocation) + if prepared_node.index > previous_node.index: + final_nodes_by_parent_path[parent_path] = prepared_id + + return [final_nodes_by_parent_path[parent_path] for parent_path in sorted(final_nodes_by_parent_path)] + + def _get_prepared_edge_iteration_path(self, edge: Edge, prepared_id: str) -> tuple[int, ...]: + source_node = self._state.graph.get_node(edge.source.node_id) + if ( + isinstance(source_node, ForInvocation) + and get_output_field_scope(source_node, edge.source.field) == OutputScope.Final + ): + return self._state._get_for_parent_iteration_path(prepared_id) + return self._state._get_iteration_path(prepared_id) + def _get_iterator_input_iteration_paths(self, iterator_node_id: str) -> set[tuple[int, ...]]: iteration_paths: set[tuple[int, ...]] = set() for edge in self._state.graph._get_input_edges(iterator_node_id, COLLECTION_FIELD): source_node_id = edge.source.node_id prepared_nodes = self._get_ordered_prepared_nodes_for_source(source_node_id) - iteration_paths.update(self._state._get_iteration_path(prepared_id) for prepared_id in prepared_nodes) + iteration_paths.update( + self._get_prepared_edge_iteration_path(edge, prepared_id) for prepared_id in prepared_nodes + ) return iteration_paths def _get_collect_candidate_group_keys(self, edge: Edge) -> set[tuple[int, ...]]: @@ -575,9 +1249,10 @@ def _get_collect_iteration_mapping_groups( group_keys: set[tuple[int, ...]] = set() for edge in input_edges: group_keys.update(self._get_collect_candidate_group_keys(edge)) - prepared_nodes = self._get_ordered_prepared_nodes_for_source(edge.source.node_id) + prepared_nodes = self._get_ordered_prepared_nodes_for_edge(edge) sibling_depth = max( - (len(self._state._get_iteration_path(prepared_id)) for prepared_id in prepared_nodes), default=0 + (len(self._get_prepared_edge_iteration_path(edge, prepared_id)) for prepared_id in prepared_nodes), + default=0, ) for prepared_id in prepared_nodes: prepared_edge = Edge( @@ -587,7 +1262,12 @@ def _get_collect_iteration_mapping_groups( group_key = self._get_collect_iteration_group_key(prepared_edge, sibling_depth) group_keys.add(group_key) prepared_inputs.append( - (prepared_edge, edge.source.node_id, prepared_id, self._state._get_iteration_path(prepared_id)) + ( + prepared_edge, + edge.source.node_id, + prepared_id, + self._get_prepared_edge_iteration_path(edge, prepared_id), + ) ) if not group_keys: @@ -624,17 +1304,25 @@ def _get_collect_iteration_mapping_groups( for group_key in final_group_keys ] - def _get_parent_iteration_mappings_without_iterators( - self, parent_node_ids: list[str] - ) -> list[list[tuple[str, str]]]: + def _get_parent_iteration_mappings_without_iterators(self, next_node_id: str) -> list[list[tuple[str, str]]]: + input_edges = self._state.graph._get_input_edges(next_node_id) + parent_node_ids = list(dict.fromkeys(edge.source.node_id for edge in input_edges)) parent_prepared_nodes = { - node_id: self._get_ordered_prepared_nodes_for_source(node_id) for node_id in parent_node_ids + node_id: list( + dict.fromkeys( + (prepared_id, self._get_prepared_edge_iteration_path(edge, prepared_id)) + for edge in input_edges + if edge.source.node_id == node_id + for prepared_id in self._get_ordered_prepared_nodes_for_edge(edge) + ) + ) + for node_id in parent_node_ids } all_iteration_paths = { - self._state._get_iteration_path(prepared_id) + iteration_path for prepared_nodes in parent_prepared_nodes.values() - for prepared_id in prepared_nodes - if self._state._get_iteration_path(prepared_id) != () + for _prepared_id, iteration_path in prepared_nodes + if iteration_path != () } iteration_paths = sorted( iteration_path @@ -651,26 +1339,22 @@ def _get_parent_iteration_mappings_without_iterators( for iteration_path in iteration_paths: mapping: list[tuple[str, str]] = [] for node_id, prepared_nodes in parent_prepared_nodes.items(): - matching_prepared_node = next( + matching_prepared = next( iter( sorted( ( - prepared_id - for prepared_id in prepared_nodes - if iteration_path[: len(self._state._get_iteration_path(prepared_id))] - == self._state._get_iteration_path(prepared_id) - ), - key=lambda prepared_id: ( - -len(self._state._get_iteration_path(prepared_id)), - prepared_id, + (prepared_id, prepared_path) + for prepared_id, prepared_path in prepared_nodes + if iteration_path[: len(prepared_path)] == prepared_path ), + key=lambda prepared: (-len(prepared[1]), prepared[0]), ) ), None, ) - if matching_prepared_node is None: + if matching_prepared is None: break - mapping.append((node_id, matching_prepared_node)) + mapping.append((node_id, matching_prepared[0])) if len(mapping) == len(parent_node_ids): mappings.append(mapping) return mappings @@ -680,10 +1364,12 @@ def _mark_source_node_empty(self, source_node_id: str) -> None: self._state.executed.add(source_node_id) self._state.executed_history.append(source_node_id) - def _index_prepared_nodes_by_iteration_path(self, prepared_nodes: set[str]) -> dict[tuple[int, ...], list[str]]: + def _index_prepared_nodes_by_iteration_path( + self, prepared_nodes: set[str], input_edges: list[Edge] + ) -> dict[tuple[int, ...], list[str]]: prepared_nodes_by_iteration_path: dict[tuple[int, ...], list[str]] = {} for prepared_id in prepared_nodes: - iteration_path = self._state._get_iteration_path(prepared_id) + iteration_path = self._get_prepared_edge_iteration_path(input_edges[0], prepared_id) prepared_nodes_by_iteration_path.setdefault(iteration_path, []).append(prepared_id) return prepared_nodes_by_iteration_path @@ -724,7 +1410,7 @@ def _get_parent_iteration_mappings(self, next_node_id: str, graph: "nx.DiGraph") iterator_graph = self.iterator_graph(graph) iterator_nodes = self.get_node_iterators(next_node_id, iterator_graph) if not iterator_nodes: - return iter(self._get_parent_iteration_mappings_without_iterators(parent_node_ids)) + return iter(self._get_parent_iteration_mappings_without_iterators(next_node_id)) iterator_nodes_prepared = [ sorted(self._state.source_prepared_mapping[node_id], key=self._state._get_iteration_path) @@ -734,7 +1420,10 @@ def _get_parent_iteration_mappings(self, next_node_id: str, graph: "nx.DiGraph") node_id: self._get_prepared_nodes_for_source(node_id) for node_id in parent_node_ids } prepared_nodes_by_source_and_path = { - node_id: self._index_prepared_nodes_by_iteration_path(prepared_nodes) + node_id: self._index_prepared_nodes_by_iteration_path( + prepared_nodes, + [edge for edge in self._state.graph._get_input_edges(next_node_id) if edge.source.node_id == node_id], + ) for node_id, prepared_nodes in prepared_nodes_by_source.items() } @@ -778,6 +1467,8 @@ def create_execution_node( node = self._state.graph.get_node(node_id) iteration_indexes = self._get_new_node_iterations(node, node_id, iteration_node_map) if not iteration_indexes: + if isinstance(node, ForInvocation): + return [self._create_empty_for_final_output(node_id, node, iteration_node_map)] return [] new_edges = self._build_execution_edges(node_id, iteration_node_map) @@ -787,7 +1478,7 @@ def create_execution_node( new_node_iteration_path = iteration_path if new_node_iteration_path is None: new_node_iteration_path = self._get_known_iteration_path(iteration_index, iteration_node_map) - elif isinstance(node, IterateInvocation): + elif isinstance(node, (ForInvocation, IterateInvocation)): new_node_iteration_path += (iteration_index,) if new_node_iteration_path is not None: self._state._prepared_registry().set_iteration_path(new_node.id, new_node_iteration_path) @@ -805,11 +1496,23 @@ def iterator_graph(self, base: Optional["nx.DiGraph"] = None) -> "nx.DiGraph": ) for c in collectors: g.remove_edges_from(list(g.in_edges(c))) + for edge in self._state.graph.edges: + source_node = self._state.graph.get_node(edge.source.node_id) + if ( + isinstance(source_node, ForInvocation) + and get_output_field_scope(source_node, edge.source.field) == OutputScope.Final + ): + if g.has_edge(edge.source.node_id, edge.destination.node_id): + g.remove_edge(edge.source.node_id, edge.destination.node_id) return g def get_node_iterators(self, node_id: str, it_graph: Optional["nx.DiGraph"] = None) -> list[str]: g = it_graph or self.iterator_graph() - return [n for n in nx.ancestors(g, node_id) if isinstance(self._state.graph.get_node(n), IterateInvocation)] + return [ + n + for n in nx.ancestors(g, node_id) + if isinstance(self._state.graph.get_node(n), (ForInvocation, IterateInvocation)) + ] def _get_prepared_nodes_for_source(self, source_node_id: str) -> set[str]: return { @@ -865,6 +1568,15 @@ def _find_prepared_node_matching_iterators( None, ) + def _get_final_for_exec_node(self, prepared_nodes: set[str]) -> Optional[str]: + prepared_for_nodes = [(node_id, self._state.execution_graph.nodes.get(node_id)) for node_id in prepared_nodes] + prepared_for_nodes = [ + (node_id, node) for node_id, node in prepared_for_nodes if isinstance(node, ForInvocation) + ] + if not prepared_for_nodes: + return None + return max(prepared_for_nodes, key=lambda item: item[1].index)[0] + def get_iteration_node( self, source_node_id: str, @@ -879,6 +1591,8 @@ def get_iteration_node( return next(iter(prepared_nodes)) parent_iterators = self._get_parent_iterator_exec_nodes(source_node_id, graph, prepared_iterator_nodes) + if not parent_iterators and isinstance(self._state.graph.get_node(source_node_id), ForInvocation): + return self._get_final_for_exec_node(prepared_nodes) if len(prepared_nodes) == 1: prepared_node_id = next(iter(prepared_nodes)) if self._matches_parent_iterators(prepared_node_id, parent_iterators, execution_graph): @@ -900,12 +1614,22 @@ def prepare(self, base_g: Optional["nx.DiGraph"] = None) -> Optional[str]: node_id for node_id in nx.topological_sort(g) if node_id not in self._state.source_prepared_mapping + and node_id not in self._state.executed + and not ( + isinstance(self._state.graph.get_node(node_id), ForReturnInvocation) + and self._is_deferred_nested_for_return(node_id, g) + ) + and not self._has_unmaterializable_for_final_input(node_id) + and all( + source_id in self._state.source_prepared_mapping or source_id in self._state.executed + for source_id, _ in g.in_edges(node_id) + ) and ( - not isinstance(self._state.graph.get_node(node_id), IterateInvocation) + not isinstance(self._state.graph.get_node(node_id), (ForInvocation, IterateInvocation)) or all(source_id in self._state.executed for source_id, _ in g.in_edges(node_id)) ) and not any( - isinstance(self._state.graph.get_node(ancestor_id), IterateInvocation) + isinstance(self._state.graph.get_node(ancestor_id), (ForInvocation, IterateInvocation)) and ancestor_id not in self._state.executed for ancestor_id in nx.ancestors(g, node_id) ) @@ -931,8 +1655,14 @@ def prepare(self, base_g: Optional["nx.DiGraph"] = None) -> Optional[str]: for iteration_mappings in self._get_parent_iteration_mappings(next_node_id, g): iteration_path = None if not parent_iterator_nodes: + input_edges = self._state.graph._get_input_edges(next_node_id) iteration_path = max( - (self._state._get_iteration_path(prepared_id) for _, prepared_id in iteration_mappings), + ( + self._get_prepared_edge_iteration_path(edge, prepared_id) + for source_id, prepared_id in iteration_mappings + for edge in input_edges + if edge.source.node_id == source_id + ), key=lambda path: (len(path), path), default=(), ) @@ -941,8 +1671,15 @@ def prepare(self, base_g: Optional["nx.DiGraph"] = None) -> Optional[str]: if not new_node_ids: self._mark_source_node_empty(next_node_id) + if isinstance(next_node, ForInvocation): + self._mark_empty_for_complete(next_node_id) return next_node_id + if isinstance(next_node, ForInvocation) and all( + self._state.execution_graph.get_node(exec_node_id).index == -1 for exec_node_id in new_node_ids + ): + self._mark_empty_for_complete(next_node_id) + return new_node_ids[0] @@ -992,9 +1729,210 @@ def _mark_source_node_complete(self, exec_node_id: str) -> None: registry = self._state._prepared_registry() source_node_id = registry.get_source_node_id(exec_node_id) prepared_nodes = registry.get_prepared_ids(source_node_id) - if all(node_id in self._state.executed for node_id in prepared_nodes): + if ( + all(node_id in self._state.executed for node_id in prepared_nodes) + and source_node_id not in self._state.executed + ): self._state.executed.add(source_node_id) - self._state.executed_history.append(source_node_id) + if source_node_id not in self._state.executed_history: + self._state.executed_history.append(source_node_id) + + def _get_for_parent(self, exec_node_id: str) -> Optional[str]: + execution_graph = self._state.execution_graph.nx_graph_flat() + + source_return_id = self._state._prepared_registry().get_source_node_id(exec_node_id) + iteration_path = self._state._get_iteration_path(exec_node_id) + source_graph = self._state.graph.nx_graph_flat() + for source_for_id, source_for_node in self._state.graph.nodes.items(): + if not isinstance(source_for_node, ForInvocation): + continue + body_path_to_return = self._state.graph._get_for_body_path_to_return(source_for_id, source_graph) + if body_path_to_return is None or body_path_to_return[1] != source_return_id: + continue + matching_for_ids = [ + prepared_for_id + for prepared_for_id in self._state._prepared_registry().get_prepared_ids(source_for_id) + if isinstance(self._state.execution_graph.get_node(prepared_for_id), ForInvocation) + and iteration_path[: len(self._state._get_iteration_path(prepared_for_id))] + == self._state._get_iteration_path(prepared_for_id) + ] + if matching_for_ids: + return max( + matching_for_ids, + key=lambda prepared_for_id: ( + len(self._state._get_iteration_path(prepared_for_id)), + self._state.execution_graph.get_node(prepared_for_id).index, + ), + ) + + for ancestor_id in nx.ancestors(execution_graph, exec_node_id): + source_node = self._state.execution_graph.get_node(ancestor_id) + if isinstance(source_node, ForInvocation): + return ancestor_id + + # An empty nested Iterate has no item execution node, so its synthetic Collect and ForReturn have no + # execution-graph edge back to their owning For. Recover ownership from the durable source boundary and + # matching execution path so the outer loop can advance. + for source_for_id, source_for_node in self._state.graph.nodes.items(): + if not isinstance(source_for_node, ForInvocation): + continue + body_path_to_return = self._state.graph._get_for_body_path_to_return(source_for_id, source_graph) + if body_path_to_return is None or body_path_to_return[1] != source_return_id: + continue + for prepared_for_id in self._state._prepared_registry().get_prepared_ids(source_for_id): + prepared_for_node = self._state.execution_graph.get_node(prepared_for_id) + if not isinstance(prepared_for_node, ForInvocation) or prepared_for_node.index < 0: + continue + if self._state._get_iteration_path(prepared_for_id) == iteration_path: + return prepared_for_id + return None + + def _get_loop_state_for_next_iteration( + self, for_exec_node_id: str, return_output: "ForReturnInvocationOutput" + ) -> "LoopState": + if return_output.state is not None: + return return_output.state + + for_output = self._state.results.get(for_exec_node_id) + if isinstance(for_output, ForInvocationOutput): + return for_output.state + + return LoopState() + + def _get_ordered_for_return_outputs( + self, for_exec_node_id: str, source_return_id: str + ) -> list["ForReturnInvocationOutput"]: + parent_iteration_path = self._state._get_for_parent_iteration_path(for_exec_node_id) + prepared_return_ids = self._state._prepared_registry().get_prepared_ids(source_return_id) + prepared_return_ids = [ + prepared_return_id + for prepared_return_id in prepared_return_ids + if self._state._get_iteration_path(prepared_return_id)[:-1] == parent_iteration_path + ] + prepared_return_ids = sorted(prepared_return_ids, key=self._state._get_iteration_path) + return [ + output + for prepared_return_id in prepared_return_ids + if isinstance((output := self._state.results.get(prepared_return_id)), ForReturnInvocationOutput) + ] + + def _finalize_for_outputs( + self, + for_exec_node_id: str, + source_for_id: str, + source_return_id: str, + return_output: "ForReturnInvocationOutput", + ) -> None: + for_output = self._state.results.get(for_exec_node_id) + if not isinstance(for_output, ForInvocationOutput): + return + + return_outputs = self._get_ordered_for_return_outputs(for_exec_node_id, source_return_id) + for_output.output_collection = [output.output for output in return_outputs if output.output is not None] + for_output.final_state = self._get_loop_state_for_next_iteration(for_exec_node_id, return_output) + self._state._mark_loop_context_finalized(source_for_id, for_exec_node_id) + + def _try_schedule_next_for_iteration(self, exec_node_id: str, output: BaseInvocationOutput) -> None: + if not isinstance(output, ForReturnInvocationOutput): + return + if not isinstance(self._state.execution_graph.get_node(exec_node_id), ForReturnInvocation): + return + + for_exec_node_id = self._get_for_parent(exec_node_id) + if for_exec_node_id is None: + return + + for_node = self._state.execution_graph.get_node(for_exec_node_id) + if not isinstance(for_node, ForInvocation): + return + + registry = self._state._prepared_registry() + source_for_id = registry.get_source_node_id(for_exec_node_id) + source_return_id = registry.get_source_node_id(exec_node_id) + + next_index = for_node.index + 1 + for_return_node = self._state.execution_graph.get_node(exec_node_id) + assert isinstance(for_return_node, ForReturnInvocation) + if next_index >= len(for_node.collection) or for_return_node.continue_condition is False: + self._finalize_for_outputs(for_exec_node_id, source_for_id, source_return_id, output) + self._state._materializer().create_nested_for_return( + inner_for_id=source_for_id, + prepared_inner_for_id=for_exec_node_id, + ) + for_node.collection = [] + return + + next_state = self._get_loop_state_for_next_iteration(for_exec_node_id, output) + parent_iteration_path = self._state._get_for_parent_iteration_path(for_exec_node_id) + + next_for_id = self._state._materializer().create_for_iteration( + source_for_id=source_for_id, + iteration_index=next_index, + collection=for_node.collection, + state=next_state, + iteration_path=(*parent_iteration_path, next_index), + ) + self._state.executed.discard(source_for_id) + self._state._materializer().create_for_body_iteration(source_for_id=source_for_id, prepared_for_id=next_for_id) + for_node.collection = [] + + def _try_materialize_deferred_nested_for_body(self, exec_node_id: str) -> None: + completed_source_id = self._state._prepared_registry().get_source_node_id(exec_node_id) + graph = self._state.graph.nx_graph_flat() + for source_for_id, source_node in self._state.graph.nodes.items(): + if not isinstance(source_node, ForInvocation): + continue + nested_body = self._state.graph._get_supported_for_nested_iterate_body(source_for_id, graph) + nested_for_body = self._state.graph._get_supported_for_nested_for_body(source_for_id, graph) + if nested_body is not None: + body_path_nodes, _return_id, iterate_id, _collect_id = nested_body + deferred_node_ids = (iterate_id,) + elif nested_for_body is None: + continue + else: + body_path_nodes = nested_for_body.body_path_nodes + deferred_node_ids = nested_for_body.inner_for_ids + if completed_source_id != source_for_id and ( + completed_source_id not in body_path_nodes + or not any( + nx.has_path(graph, completed_source_id, deferred_node_id) for deferred_node_id in deferred_node_ids + ) + ): + continue + for prepared_for_id in self._state._prepared_registry().get_prepared_ids(source_for_id): + prepared_for_node = self._state.execution_graph.get_node(prepared_for_id) + prepared_for_path = self._state._get_iteration_path(prepared_for_id) + if isinstance(prepared_for_node, ForInvocation) and prepared_for_node.index >= 0: + prepared_for_path = ( + *self._state._get_for_parent_iteration_path(prepared_for_id), + prepared_for_node.index, + ) + if prepared_for_path != self._state._get_iteration_path(exec_node_id): + continue + if nested_for_body is not None: + if not all( + any( + self._state._get_for_parent_iteration_path(prepared_child_id) == prepared_for_path + for prepared_child_id in self._state._prepared_registry().get_prepared_ids(child_id) + ) + for child_id in deferred_node_ids + ): + self._state._materializer().create_for_body_iteration( + source_for_id=source_for_id, prepared_for_id=prepared_for_id + ) + return + continue + if any( + (iterate_path := self._state._get_iteration_path(prepared_iterate_id))[: len(prepared_for_path)] + == prepared_for_path + and len(iterate_path) > len(prepared_for_path) + for prepared_iterate_id in self._state._prepared_registry().get_prepared_ids(deferred_node_ids[0]) + ): + continue + self._state._materializer().create_for_body_iteration( + source_for_id=source_for_id, prepared_for_id=prepared_for_id + ) + return def _decrement_child_indegree(self, child_exec_node_id: str, parent_exec_node_id: str) -> None: if child_exec_node_id not in self._state.indegree: @@ -1075,8 +2013,35 @@ def complete(self, exec_node_id: str, output: BaseInvocationOutput) -> None: return self._record_completed_node(exec_node_id, output) + self._try_schedule_next_for_iteration(exec_node_id, output) self._mark_source_node_complete(exec_node_id) self._release_downstream_nodes(exec_node_id) + completed_node = self._state.execution_graph.get_node(exec_node_id) + if isinstance(completed_node, ForInvocation) and completed_node.index >= 0: + source_for_id = self._state._prepared_registry().get_source_node_id(exec_node_id) + nested_body = self._state.graph._get_supported_for_nested_iterate_body( + source_for_id, self._state.graph.nx_graph_flat() + ) + prepared_for_node = self._state.execution_graph.get_node(exec_node_id) + prepared_for_path = self._state._get_iteration_path(exec_node_id) + if isinstance(prepared_for_node, ForInvocation) and prepared_for_node.index >= 0: + prepared_for_path = ( + *self._state._get_for_parent_iteration_path(exec_node_id), + prepared_for_node.index, + ) + if nested_body is not None and not any( + (iterate_path := self._state._get_iteration_path(prepared_iterate_id))[: len(prepared_for_path)] + == prepared_for_path + and len(iterate_path) > len(prepared_for_path) + for prepared_iterate_id in self._state._prepared_registry().get_prepared_ids(nested_body[2]) + ): + self._state._materializer().create_for_body_iteration( + source_for_id=source_for_id, prepared_for_id=exec_node_id + ) + elif nested_body is None: + self._try_materialize_deferred_nested_for_body(exec_node_id) + else: + self._try_materialize_deferred_nested_for_body(exec_node_id) if len(self._state.executed_history) == len(self._state.graph.nodes): self._state.execution_graph._invalidate_edge_indexes() self._state._ready_queues = {} @@ -1103,7 +2068,7 @@ def _get_ordered_iterator_sources(self, source_node_id: str) -> list[str]: iterator_sources = [ node_id for node_id in nx.ancestors(iterator_graph, source_node_id) - if isinstance(self._state.graph.get_node(node_id), IterateInvocation) + if isinstance(self._state.graph.get_node(node_id), (ForInvocation, IterateInvocation)) ] topo = list(nx.topological_sort(iterator_graph)) @@ -1128,11 +2093,11 @@ def _build_iteration_path(self, exec_node_id: str, source_node_id: str) -> tuple if iterator_exec_id is None: continue iterator_node = self._state.execution_graph.nodes.get(iterator_exec_id) - if isinstance(iterator_node, IterateInvocation): + if isinstance(iterator_node, (ForInvocation, IterateInvocation)): path.append(iterator_node.index) node_obj = self._state.execution_graph.nodes.get(exec_node_id) - if isinstance(node_obj, IterateInvocation): + if isinstance(node_obj, (ForInvocation, IterateInvocation)): path.append(node_obj.index) return tuple(path) @@ -1258,6 +2223,23 @@ def get_output_field_type(node: BaseInvocation, field: str) -> Any: return None +def get_output_field_scope(node: BaseInvocation, field: str) -> OutputScope | None: + try: + invocation_class = type(node) + invocation_output_class = invocation_class.get_output_annotation() + field_info = invocation_output_class.model_fields.get(field) + assert field_info is not None, f"Output field '{field}' not found in {invocation_output_class.get_type()}" + json_schema_extra = field_info.json_schema_extra + if not isinstance(json_schema_extra, dict): + return None + output_scope = json_schema_extra.get("output_scope") + if output_scope is None: + return None + return OutputScope(output_scope) + except Exception: + return None + + def get_input_field_type(node: BaseInvocation, field: str) -> Any: # TODO(psyche): This is awkward - if field_info is None, it means the field is not defined in the output, which # really should raise. The consumers of this utility expect it to never raise, and return None instead. Fixing this @@ -1737,8 +2719,8 @@ def delete_node(self, node_id: str) -> None: try: # Delete edges for this node - input_edges = self._get_input_edges(node_id) - output_edges = self._get_output_edges(node_id) + input_edges = self._get_input_edges(node_id, include_loop_linkage=True) + output_edges = self._get_output_edges(node_id, include_loop_linkage=True) for edge in input_edges: self.delete_edge(edge) @@ -1805,6 +2787,7 @@ def _validate_node_id_mapping(self) -> None: def _validate_edge_nodes_and_fields(self) -> None: for edge in self.edges: + self._validate_reserved_edge_fields(edge) source_node = self.nodes.get(edge.source.node_id, None) if source_node is None: raise NodeNotFoundError(f"Edge source node {edge.source.node_id} does not exist in the graph") @@ -1839,6 +2822,7 @@ def _validate_edge_type_compatibility(self) -> None: edge.destination.field ): continue + self._validate_edge_not_to_direct_input(edge, destination_node) if not are_connections_compatible( self.get_node(edge.source.node_id), edge.source.field, @@ -1849,6 +2833,7 @@ def _validate_edge_type_compatibility(self) -> None: def _validate_special_nodes(self) -> None: # TODO: may need to validate all iterators & collectors in subgraphs so edge connections in parent graphs will be available + self._validate_for_loop_linkages() for node in self.nodes.values(): if isinstance(node, IterateInvocation): err = self._is_iterator_connection_valid(node.id) @@ -1858,6 +2843,40 @@ def _validate_special_nodes(self) -> None: err = self._is_collector_connection_valid(node.id) if err is not None: raise InvalidEdgeError(f"Invalid collector node ({node.id}): {err}") + if isinstance(node, ForInvocation): + err = self._is_for_connection_valid(node.id) + if err is not None: + raise InvalidEdgeError(f"Invalid For node ({node.id}): {err}") + if isinstance(node, ForReturnInvocation): + err = self._is_for_return_connection_valid(node.id) + if err is not None: + raise InvalidEdgeError(f"Invalid ForReturn node ({node.id}): {err}") + + def _validate_for_loop_linkages(self) -> None: + """Validates the required non-data association between each For and its ForReturn.""" + for_nodes = [node for node in self.nodes.values() if isinstance(node, ForInvocation)] + return_nodes = [node for node in self.nodes.values() if isinstance(node, ForReturnInvocation)] + linkage_edges = self._get_loop_linkage_edges() + + for edge in linkage_edges: + source_node = self.nodes.get(edge.source.node_id) + destination_node = self.nodes.get(edge.destination.node_id) + if ( + not isinstance(source_node, ForInvocation) + or not isinstance(destination_node, ForReturnInvocation) + or edge.source.field != LOOP_LINKAGE_FIELD + or edge.destination.field != LOOP_LINKAGE_FIELD + ): + raise InvalidEdgeError(f"Invalid loop linkage ({edge})") + + for node in for_nodes: + matching_edges = [edge for edge in linkage_edges if edge.source.node_id == node.id] + if len(matching_edges) != 1: + raise InvalidEdgeError(f"For '{node.id}' must have exactly one loop linkage") + for node in return_nodes: + matching_edges = [edge for edge in linkage_edges if edge.destination.node_id == node.id] + if len(matching_edges) != 1: + raise InvalidEdgeError(f"ForReturn '{node.id}' must have exactly one loop linkage") def validate_self(self) -> None: """ @@ -1936,9 +2955,41 @@ def _validate_edge_field_compatibility( edge.destination.field ): return + self._validate_edge_not_to_direct_input(edge, destination_node) if not are_connections_compatible(source_node, edge.source.field, destination_node, edge.destination.field): raise InvalidEdgeError(f"Field types are incompatible ({edge})") + def _validate_edge_not_to_direct_input(self, edge: Edge, destination_node: BaseInvocation) -> None: + destination_field = type(destination_node).model_fields.get(edge.destination.field) + if destination_field is not None: + json_schema_extra = destination_field.json_schema_extra + if isinstance(json_schema_extra, dict) and json_schema_extra.get("input") == Input.Direct: + raise InvalidEdgeError(f"Cannot connect to direct input ({edge})") + + def _validate_reserved_edge_fields(self, edge: Edge) -> None: + if edge.type == "default" and ( + edge.source.field == LOOP_LINKAGE_FIELD or edge.destination.field == LOOP_LINKAGE_FIELD + ): + raise InvalidEdgeError(f"The loop_linkage field must use a loop_linkage edge ({edge})") + + def _validate_loop_linkage_edge( + self, edge: Edge, source_node: BaseInvocation, destination_node: BaseInvocation + ) -> None: + if ( + not isinstance(source_node, ForInvocation) + or not isinstance(destination_node, ForReturnInvocation) + or edge.source.field != LOOP_LINKAGE_FIELD + or edge.destination.field != LOOP_LINKAGE_FIELD + ): + raise InvalidEdgeError(f"Invalid loop linkage ({edge})") + + if any(existing_edge.source.node_id == source_node.id for existing_edge in self._get_loop_linkage_edges()): + raise InvalidEdgeError(f"For node already has a loop linkage ({edge})") + if any( + existing_edge.destination.node_id == destination_node.id for existing_edge in self._get_loop_linkage_edges() + ): + raise InvalidEdgeError(f"ForReturn node already has a loop linkage ({edge})") + def _validate_iterator_edge_rules( self, edge: Edge, source_node: BaseInvocation, destination_node: BaseInvocation ) -> None: @@ -1982,7 +3033,11 @@ def _validate_collector_edge_rules( def _validate_edge(self, edge: Edge, allow_inputless_source_collector: bool = False): """Validates that a new edge doesn't create a cycle in the graph""" + self._validate_reserved_edge_fields(edge) source_node, destination_node = self._get_edge_nodes(edge) + if edge.type == "loop_linkage": + self._validate_loop_linkage_edge(edge, source_node, destination_node) + return self._validate_edge_destination_uniqueness(edge, destination_node) self._validate_edge_would_not_create_cycle(edge) self._validate_edge_field_compatibility(edge, source_node, destination_node) @@ -2019,8 +3074,8 @@ def update_node(self, node_id: str, new_node: BaseInvocation) -> None: # Set the new node in the graph self.nodes[new_node.id] = new_node if new_node.id != node.id: - input_edges = self._get_input_edges(node_id) - output_edges = self._get_output_edges(node_id) + input_edges = self._get_input_edges(node_id, include_loop_linkage=True) + output_edges = self._get_output_edges(node_id, include_loop_linkage=True) # Delete node and all edges self.delete_node(node_id) @@ -2029,6 +3084,7 @@ def update_node(self, node_id: str, new_node: BaseInvocation) -> None: for edge in input_edges: self.add_edge( Edge( + type=edge.type, source=edge.source, destination=EdgeConnection(node_id=new_node.id, field=edge.destination.field), ) @@ -2037,17 +3093,22 @@ def update_node(self, node_id: str, new_node: BaseInvocation) -> None: for edge in output_edges: self.add_edge( Edge( + type=edge.type, source=EdgeConnection(node_id=new_node.id, field=edge.source.field), destination=edge.destination, ) ) - def _get_input_edges(self, node_id: str, field: Optional[str] = None) -> list[Edge]: + def _get_input_edges( + self, node_id: str, field: Optional[str] = None, *, include_loop_linkage: bool = False + ) -> list[Edge]: """Gets all input edges for a node. If field is provided, only edges to that field are returned.""" self._ensure_edge_indexes() assert self._input_edges_by_node is not None edges = self._input_edges_by_node.get(node_id, []) + if not include_loop_linkage: + edges = [edge for edge in edges if edge.type == "default"] if field is None: return list(edges) @@ -2056,11 +3117,15 @@ def _get_input_edges(self, node_id: str, field: Optional[str] = None) -> list[Ed return filtered_edges - def _get_output_edges(self, node_id: str, field: Optional[str] = None) -> list[Edge]: + def _get_output_edges( + self, node_id: str, field: Optional[str] = None, *, include_loop_linkage: bool = False + ) -> list[Edge]: """Gets all output edges for a node. If field is provided, only edges from that field are returned.""" self._ensure_edge_indexes() assert self._output_edges_by_node is not None edges = self._output_edges_by_node.get(node_id, []) + if not include_loop_linkage: + edges = [edge for edge in edges if edge.type == "default"] if field is None: return list(edges) @@ -2069,6 +3134,424 @@ def _get_output_edges(self, node_id: str, field: Optional[str] = None) -> list[E return filtered_edges + def _get_loop_linkage_edges(self, node_id: str | None = None) -> list[Edge]: + edges = [edge for edge in self.edges if edge.type == "loop_linkage"] + if node_id is None: + return edges + return [edge for edge in edges if edge.source.node_id == node_id or edge.destination.node_id == node_id] + + def _get_linked_for_return_id(self, for_node_id: str) -> str | None: + linkage_edges = [ + edge for edge in self._get_loop_linkage_edges(for_node_id) if edge.source.node_id == for_node_id + ] + if len(linkage_edges) != 1: + return None + return linkage_edges[0].destination.node_id + + def _get_linked_for_id(self, return_node_id: str) -> str | None: + linkage_edges = [ + edge for edge in self._get_loop_linkage_edges(return_node_id) if edge.destination.node_id == return_node_id + ] + if len(linkage_edges) != 1: + return None + return linkage_edges[0].source.node_id + + def _get_for_iteration_output_edges(self, node_id: str) -> list[Edge]: + node = self.get_node(node_id) + return [ + edge + for edge in self._get_output_edges(node_id) + if get_output_field_scope(node, edge.source.field) == OutputScope.Iteration + ] + + def _get_for_final_output_edges(self, node_id: str) -> list[Edge]: + node = self.get_node(node_id) + return [ + edge + for edge in self._get_output_edges(node_id) + if get_output_field_scope(node, edge.source.field) == OutputScope.Final + ] + + def _get_for_reachable_body_nodes(self, iteration_edges: list[Edge], graph: nx.DiGraph) -> set[str]: + body_nodes: set[str] = set() + for edge in iteration_edges: + body_nodes.add(edge.destination.node_id) + body_nodes.update(nx.descendants(graph, edge.destination.node_id)) + return body_nodes + + def _get_for_body_path_nodes( + self, reachable_body_nodes: set[str], return_node_id: str, graph: nx.DiGraph + ) -> set[str]: + return (reachable_body_nodes & nx.ancestors(graph, return_node_id)) | {return_node_id} + + def _get_for_body_path_to_return(self, node_id: str, graph: nx.DiGraph) -> tuple[set[str], str] | None: + """Resolve the runtime body path to its owning ForReturn. + + The loop linkage identifies the return endpoint. The ordinary body graph still determines whether that return + is reachable from an iteration output and which nodes belong to the body. + """ + iteration_edges = self._get_for_iteration_output_edges(node_id) + if len(iteration_edges) == 0: + return None + + reachable_body_nodes = self._get_for_reachable_body_nodes(iteration_edges, graph) + return_node_id = self._get_linked_for_return_id(node_id) + if return_node_id is None or return_node_id not in reachable_body_nodes: + return None + + return self._get_for_body_path_nodes(reachable_body_nodes, return_node_id, graph), return_node_id + + def _get_supported_for_nested_iterate_body( + self, node_id: str, graph: nx.DiGraph + ) -> tuple[set[str], str, str, str] | None: + """Returns the bounded internal Iterate body contract, if this For uses it.""" + body_path_to_return = self._get_for_body_path_to_return(node_id, graph) + if body_path_to_return is None: + return None + + body_path_nodes, return_node_id = body_path_to_return + iterate_node_ids = [ + body_node_id + for body_node_id in body_path_nodes + if isinstance(self.get_node(body_node_id), IterateInvocation) + ] + collect_node_ids = [ + body_node_id + for body_node_id in body_path_nodes + if isinstance(self.get_node(body_node_id), CollectInvocation) + ] + if len(iterate_node_ids) != 1 or len(collect_node_ids) != 1: + return None + + iterate_node_id = iterate_node_ids[0] + collect_node_id = collect_node_ids[0] + if not nx.has_path(graph, iterate_node_id, collect_node_id): + return None + iterate_input_edges = self._get_input_edges(iterate_node_id, COLLECTION_FIELD) + if len(iterate_input_edges) != 1: + return None + iterate_input_source_id = iterate_input_edges[0].source.node_id + if iterate_input_source_id != node_id and iterate_input_source_id not in body_path_nodes: + return None + + return_output_edges = self._get_input_edges(return_node_id, "output") + if len(return_output_edges) != 1 or ( + return_output_edges[0].source.node_id != collect_node_id + or return_output_edges[0].source.field != COLLECTION_FIELD + ): + return None + if any( + edge.destination.field != "output" + and edge.destination.field != "continue_condition" + and (edge.destination.field != "state" or edge.source.node_id != node_id or edge.source.field != "state") + for edge in self._get_input_edges(return_node_id) + ): + return None + + if self._get_input_edges(collect_node_id, COLLECTION_FIELD): + return None + collect_item_edges = self._get_input_edges(collect_node_id, ITEM_FIELD) + if len(collect_item_edges) != 1: + return None + collect_item_source_id = collect_item_edges[0].source.node_id + if not nx.has_path(graph, iterate_node_id, collect_item_source_id): + return None + + for body_node_id in body_path_nodes: + if body_node_id in {iterate_node_id, collect_node_id, return_node_id}: + continue + if not nx.has_path(graph, body_node_id, collect_node_id): + return None + if not ( + nx.has_path(graph, body_node_id, iterate_node_id) or nx.has_path(graph, iterate_node_id, body_node_id) + ): + return None + + return body_path_nodes, return_node_id, iterate_node_id, collect_node_id + + def _get_supported_for_nested_for_body(self, node_id: str, graph: nx.DiGraph) -> _SupportedNestedForBody | None: + """Returns the supported recursive nested For contract, if this For uses it. + + Each direct child loop has its own ForReturn. A single child may close the parent directly or through a + continuation. Multiple independent child loops must all feed an ordinary parent-scoped continuation, which acts + as an explicit fan-in barrier after every child has finalized for the current parent iteration. + """ + outer_node = self.get_node(node_id) + if not isinstance(outer_node, ForInvocation): + return None + + iteration_edges = self._get_for_iteration_output_edges(node_id) + if len(iteration_edges) == 0: + return None + reachable_body_nodes = self._get_for_reachable_body_nodes(iteration_edges, graph) + reachable_return_ids = [ + body_node_id + for body_node_id in reachable_body_nodes + if isinstance(self.get_node(body_node_id), ForReturnInvocation) + ] + outer_return_id = self._get_linked_for_return_id(node_id) + if outer_return_id is None or outer_return_id not in reachable_return_ids: + return None + + nested_for_ids = [ + body_node_id + for body_node_id in reachable_body_nodes + if isinstance(self.get_node(body_node_id), ForInvocation) and body_node_id != node_id + ] + direct_nested_for_ids = [ + nested_for_id + for nested_for_id in nested_for_ids + if not any( + other_nested_for_id != nested_for_id and nx.has_path(graph, other_nested_for_id, nested_for_id) + for other_nested_for_id in nested_for_ids + ) + ] + if not direct_nested_for_ids: + return None + direct_nested_for_ids = tuple( + nested_for_id for nested_for_id in nx.topological_sort(graph) if nested_for_id in direct_nested_for_ids + ) + + inner_body_path_nodes: set[str] = set() + for inner_for_id in direct_nested_for_ids: + inner_for = self.get_node(inner_for_id) + assert isinstance(inner_for, ForInvocation) + inner_return_id = self._get_linked_for_return_id(inner_for_id) + if inner_return_id is None: + return None + + inner_body_path_to_return = self._get_for_body_path_to_return(inner_for_id, graph) + if inner_body_path_to_return is None: + return None + child_body_path_nodes, resolved_inner_return_id = inner_body_path_to_return + if resolved_inner_return_id != inner_return_id: + return None + if inner_return_id not in reachable_return_ids: + return None + + inner_nested_for_ids = [ + body_node_id + for body_node_id in child_body_path_nodes + if isinstance(self.get_node(body_node_id), ForInvocation) + ] + if any( + isinstance(self.get_node(body_node_id), IterateInvocation) for body_node_id in child_body_path_nodes + ): + return None + inner_nested_body = ( + self._get_supported_for_nested_for_body(inner_for_id, graph) if inner_nested_for_ids else None + ) + if inner_nested_for_ids and inner_nested_body is None: + return None + if inner_nested_body is not None: + child_body_path_nodes = child_body_path_nodes | inner_nested_body.body_path_nodes + if any( + edge.destination.field == "state" + and edge.source.node_id != inner_for_id + and edge.source.node_id not in child_body_path_nodes + for edge in self._get_input_edges(inner_return_id) + ): + return None + + inner_collection_edges = self._get_input_edges(inner_for_id, COLLECTION_FIELD) + if len(inner_collection_edges) != 1: + return None + inner_collection_source_id = inner_collection_edges[0].source.node_id + if inner_collection_source_id != node_id and inner_collection_source_id not in reachable_body_nodes: + return None + + inner_body_path_nodes.update(child_body_path_nodes) + + if set(reachable_return_ids) - inner_body_path_nodes != {outer_return_id}: + return None + + outer_output_edges = self._get_input_edges(outer_return_id, "output") + if len(outer_output_edges) != 1: + return None + if any( + edge.destination.field != "output" + and edge.destination.field != "continue_condition" + and (edge.destination.field != "state" or edge.source.node_id != node_id or edge.source.field != "state") + for edge in self._get_input_edges(outer_return_id) + ): + return None + + outer_preparation_nodes = { + body_node_id + for inner_for_id in direct_nested_for_ids + for body_node_id in reachable_body_nodes & nx.ancestors(graph, inner_for_id) + } | set(direct_nested_for_ids) + inner_final_descendants: set[str] = set() + for inner_for_id in direct_nested_for_ids: + for edge in self._get_for_final_output_edges(inner_for_id): + inner_final_descendants.add(edge.destination.node_id) + inner_final_descendants.update(nx.descendants(graph, edge.destination.node_id)) + continuation_nodes = reachable_body_nodes - outer_preparation_nodes - inner_body_path_nodes - {outer_return_id} + if any( + edge.destination.field == "continue_condition" + and edge.source.node_id != node_id + and edge.source.node_id not in continuation_nodes + and not ( + edge.source.node_id in direct_nested_for_ids + and edge.source.field in {"output_collection", "final_state"} + ) + for edge in self._get_input_edges(outer_return_id) + ): + return None + if not continuation_nodes <= inner_final_descendants: + return None + if any(not nx.has_path(graph, body_node_id, outer_return_id) for body_node_id in continuation_nodes): + return None + if any( + isinstance(self.get_node(body_node_id), (ForInvocation, IterateInvocation, ForReturnInvocation)) + for body_node_id in continuation_nodes + ): + return None + if any( + edge.source.node_id in inner_body_path_nodes + or (edge.source.node_id in direct_nested_for_ids and edge.source.field != "output_collection") + for body_node_id in continuation_nodes + for edge in self._get_input_edges(body_node_id) + ): + return None + output_source_id = outer_output_edges[0].source.node_id + if output_source_id in direct_nested_for_ids: + if ( + len(direct_nested_for_ids) != 1 + or outer_output_edges[0].source.field != "output_collection" + or continuation_nodes + ): + return None + elif output_source_id not in continuation_nodes: + return None + + if any( + not any( + edge.destination.node_id in continuation_nodes or edge.destination.node_id == outer_return_id + for edge in self._get_for_final_output_edges(inner_for_id) + ) + for inner_for_id in direct_nested_for_ids + ): + return None + + allowed_body_nodes = outer_preparation_nodes | inner_body_path_nodes | continuation_nodes | {outer_return_id} + if reachable_body_nodes != allowed_body_nodes: + return None + + if any( + isinstance(self.get_node(body_node_id), (ForInvocation, IterateInvocation)) + for body_node_id in outer_preparation_nodes + if body_node_id not in direct_nested_for_ids + ): + return None + + for body_node_id in allowed_body_nodes - {outer_return_id, *direct_nested_for_ids}: + if body_node_id in inner_body_path_nodes or body_node_id in continuation_nodes: + continue + if not any(nx.has_path(graph, body_node_id, inner_for_id) for inner_for_id in direct_nested_for_ids): + return None + return _SupportedNestedForBody( + body_path_nodes=frozenset(allowed_body_nodes), + outer_return_id=outer_return_id, + inner_for_ids=direct_nested_for_ids, + continuation_nodes=frozenset(continuation_nodes), + ) + + def _get_for_nested_for_continuation_nodes(self, nested_body: _SupportedNestedForBody) -> set[str]: + return set(nested_body.continuation_nodes) + + def _is_for_connection_valid(self, node_id: str) -> str | None: + if len(self._get_input_edges(node_id, COLLECTION_FIELD)) > 1: + return "For loop may have only one collection input edge" + if len(self._get_input_edges(node_id, "state")) > 1: + return "For loop may have only one state input edge" + + iteration_edges = self._get_for_iteration_output_edges(node_id) + if len(iteration_edges) == 0: + return "For loop must have at least one iteration output edge" + + graph = self.nx_graph_flat() + reachable_body_nodes = self._get_for_reachable_body_nodes(iteration_edges, graph) + + nested_for_node_ids = [ + body_node_id + for body_node_id in reachable_body_nodes + if body_node_id != node_id + and isinstance(self.get_node(body_node_id), ForInvocation) + and not any( + other_body_node_id != body_node_id + and isinstance(self.get_node(other_body_node_id), ForInvocation) + and nx.has_path(graph, other_body_node_id, body_node_id) + for other_body_node_id in reachable_body_nodes + ) + ] + nested_body = self._get_supported_for_nested_for_body(node_id, graph) if nested_for_node_ids else None + if nested_for_node_ids and nested_body is None: + return "Nested For loops require one linked inner For with a matching ForReturn" + + if nested_body is not None: + body_path_nodes = nested_body.body_path_nodes + return_node_id = nested_body.outer_return_id + else: + return_node_id = self._get_linked_for_return_id(node_id) + if return_node_id is None or return_node_id not in reachable_body_nodes: + return "For loop body must expose exactly one matching ForReturn" + body_path_nodes = self._get_for_body_path_nodes(reachable_body_nodes, return_node_id, graph) + + unterminated_body_nodes = reachable_body_nodes - body_path_nodes + if len(unterminated_body_nodes) > 0: + return "For loop body paths must terminate at the matching ForReturn and not escape the loop body" + + if any(isinstance(self.get_node(body_node_id), IterateInvocation) for body_node_id in body_path_nodes): + if self._get_supported_for_nested_iterate_body(node_id, graph) is None: + return "Iterate nodes inside For loop bodies are unsupported" + + for body_node_id in body_path_nodes: + for edge in self._get_input_edges(body_node_id): + source_node_id = edge.source.node_id + if source_node_id == node_id or source_node_id in body_path_nodes: + continue + active_source_scope = nx.ancestors(graph, source_node_id) | {source_node_id} + if any(isinstance(self.get_node(source_id), IterateInvocation) for source_id in active_source_scope): + return "For loop body does not support iterator-derived external inputs" + + for edge in self._get_for_final_output_edges(node_id): + if edge.destination.node_id in body_path_nodes: + return "final-scoped For outputs cannot feed the loop body" + + for body_node_id in body_path_nodes: + if body_node_id == return_node_id: + continue + for edge in self._get_output_edges(body_node_id): + if edge.destination.node_id not in body_path_nodes: + return "For loop body paths must not escape before the matching ForReturn" + + return None + + def _is_for_return_connection_valid(self, node_id: str) -> str | None: + graph = self.nx_graph_flat() + matching_for_node_ids = [] + for loop_node_id, loop_node in self.nodes.items(): + if not isinstance(loop_node, ForInvocation): + continue + body_path_to_return = self._get_for_body_path_to_return(loop_node_id, graph) + if body_path_to_return is None: + continue + body_path_nodes, return_node_id = body_path_to_return + if node_id == return_node_id and node_id in body_path_nodes: + matching_for_node_ids.append(loop_node_id) + + if len(matching_for_node_ids) != 1: + return "ForReturn must belong to exactly one matching For" + + if ( + len(self._get_input_edges(node_id, "output")) > 1 + or len(self._get_input_edges(node_id, "state")) > 1 + or len(self._get_input_edges(node_id, "continue_condition")) > 1 + ): + return "ForReturn may have only one input edge per field" + return None + def _is_iterator_connection_valid( self, node_id: str, @@ -2338,7 +3821,7 @@ def nx_graph(self) -> "nx.DiGraph": # TODO: Cache this? g = nx.DiGraph() g.add_nodes_from(list(self.nodes.keys())) - g.add_edges_from({(e.source.node_id, e.destination.node_id) for e in self.edges}) + g.add_edges_from({(e.source.node_id, e.destination.node_id) for e in self.edges if e.type == "default"}) return g def nx_graph_flat(self, nx_graph: Optional["nx.DiGraph"] = None) -> "nx.DiGraph": @@ -2348,7 +3831,7 @@ def nx_graph_flat(self, nx_graph: Optional["nx.DiGraph"] = None) -> "nx.DiGraph" # Add all nodes from this graph except graph/iteration nodes g.add_nodes_from([n.id for n in self.nodes.values()]) - unique_edges = {(e.source.node_id, e.destination.node_id) for e in self.edges} + unique_edges = {(e.source.node_id, e.destination.node_id) for e in self.edges if e.type == "default"} g.add_edges_from(unique_edges) return g @@ -2420,6 +3903,14 @@ class GraphExecutionState(BaseModel): description="The map of original graph nodes to prepared nodes", default_factory=dict, ) + finalized_loop_nodes: set[str] = Field( + description="Legacy set of top-level loop source nodes whose final outputs have been materialized", + default_factory=set, + ) + finalized_loop_contexts: set[tuple[str, tuple[int, ...]]] = Field( + description="The finalized loop source and parent iteration contexts", + default_factory=set, + ) prepared_iteration_paths: dict[str, tuple[int, ...]] = Field( description="The iteration coordinates of each prepared execution node", default_factory=dict, @@ -2486,6 +3977,55 @@ def _set_prepared_exec_state(self, exec_node_id: str, state: PreparedExecState) def _get_iteration_path(self, exec_node_id: str) -> tuple[int, ...]: return self._runtime().get_iteration_path(exec_node_id) + def _get_for_parent_iteration_path(self, exec_node_id: str) -> tuple[int, ...]: + iteration_path = self._get_iteration_path(exec_node_id) + node = self.execution_graph.get_node(exec_node_id) + if isinstance(node, ForInvocation) and node.index == -1: + return iteration_path + return iteration_path[:-1] + + def _mark_loop_context_finalized(self, source_for_id: str, prepared_for_id: str) -> None: + parent_iteration_path = self._get_for_parent_iteration_path(prepared_for_id) + self.finalized_loop_contexts.add((source_for_id, parent_iteration_path)) + if parent_iteration_path == (): + self.finalized_loop_nodes.add(source_for_id) + + def _mark_for_source_complete(self, source_for_id: str) -> None: + if not self._all_for_contexts_finalized(source_for_id): + return + + source_node_ids = {source_for_id} + body_path_to_return = self.graph._get_for_body_path_to_return(source_for_id, self.graph.nx_graph_flat()) + if body_path_to_return is not None: + body_path_nodes, _return_node_id = body_path_to_return + source_node_ids.update(body_path_nodes) + + for source_node_id in source_node_ids: + if source_node_id not in self.executed: + self.executed.add(source_node_id) + self.executed_history.append(source_node_id) + + def _get_for_parent_iteration_paths(self, source_for_id: str) -> set[tuple[int, ...]]: + return { + self._get_for_parent_iteration_path(prepared_for_id) + for prepared_for_id in self._prepared_registry().get_prepared_ids(source_for_id) + if isinstance(self.execution_graph.get_node(prepared_for_id), ForInvocation) + } + + def _is_loop_context_finalized(self, source_for_id: str, parent_iteration_path: tuple[int, ...]) -> bool: + return (source_for_id, parent_iteration_path) in self.finalized_loop_contexts or ( + parent_iteration_path == () and source_for_id in self.finalized_loop_nodes + ) + + def _all_for_contexts_finalized(self, source_for_id: str) -> bool: + parent_iteration_paths = self._get_for_parent_iteration_paths(source_for_id) + if not parent_iteration_paths: + return source_for_id in self.finalized_loop_nodes + return all( + self._is_loop_context_finalized(source_for_id, parent_iteration_path) + for parent_iteration_path in parent_iteration_paths + ) + def _queue_for(self, cls_name: str) -> Deque[str]: return self._scheduler().queue_for(cls_name) @@ -2572,6 +4112,9 @@ def _rehydrate_resolved_if_exec_branches(self) -> None: self._resolved_if_exec_branches[exec_node_id] = "true_input" if node.condition else "false_input" def _rehydrate_ready_queues(self) -> None: + if self.has_error(): + return + execution_graph = self.execution_graph.nx_graph_flat() for exec_node_id in nx.topological_sort(execution_graph): if exec_node_id in self.executed: @@ -2603,6 +4146,7 @@ def model_post_init(self, __context: Any) -> None: "workflow_call_history", "prepared_source_mapping", "source_prepared_mapping", + "finalized_loop_nodes", ] } ) @@ -2621,6 +4165,8 @@ def next(self) -> Optional[BaseInvocation]: if self.is_waiting_on_workflow_call(): return None + if self.has_error(): + return None # If there are no prepared nodes, prepare some nodes next_node = self._get_next_node() @@ -2649,8 +4195,22 @@ def is_complete(self) -> bool: """Returns true if the graph is complete""" if self.is_waiting_on_workflow_call(): return False + completed_source_ids = set(self.executed) + for source_node_id, source_node in self.graph.nodes.items(): + prepared_node_ids = self._prepared_registry().get_prepared_ids(source_node_id) + if not prepared_node_ids or not all(node_id in self.executed for node_id in prepared_node_ids): + continue + if isinstance(source_node, ForInvocation) and not self._all_for_contexts_finalized(source_node_id): + continue + completed_source_ids.add(source_node_id) node_ids = set(self.graph.nx_graph_flat().nodes) - return self.has_error() or all((k in self.executed for k in node_ids)) + complete = self.has_error() or all((k in completed_source_ids for k in node_ids)) + if complete and not self.has_error(): + for source_node_id in nx.topological_sort(self.graph.nx_graph_flat()): + if source_node_id in completed_source_ids and source_node_id not in self.executed: + self.executed.add(source_node_id) + self.executed_history.append(source_node_id) + return complete def has_error(self) -> bool: """Returns true if the graph has any errors""" diff --git a/invokeai/app/services/shared/workflow_graph_builder.py b/invokeai/app/services/shared/workflow_graph_builder.py index c5c37cd3261..712de8d0641 100644 --- a/invokeai/app/services/shared/workflow_graph_builder.py +++ b/invokeai/app/services/shared/workflow_graph_builder.py @@ -221,6 +221,10 @@ def _get_default_edges(workflow_edges: Sequence[Any]) -> list[Mapping[str, Any]] return [edge for edge in workflow_edges if _is_mapping(edge) and edge.get("type") == "default"] +def _get_loop_linkage_edges(workflow_edges: Sequence[Any]) -> list[Mapping[str, Any]]: + return [edge for edge in workflow_edges if _is_mapping(edge) and edge.get("type") == "loop_linkage"] + + def _get_connector_input_edge( connector_id: str, workflow_edges: Sequence[Mapping[str, Any]] ) -> Mapping[str, Any] | None: @@ -234,6 +238,30 @@ def _get_connector_input_edge( ) +def _get_connector_input_edges( + connector_id: str, workflow_edges: Sequence[Mapping[str, Any]] +) -> list[Mapping[str, Any]]: + return [ + edge + for edge in workflow_edges + if edge.get("target") == connector_id + and edge.get("targetHandle") == CONNECTOR_INPUT_HANDLE + and isinstance(edge.get("sourceHandle"), str) + ] + + +def _get_connector_output_edges( + connector_id: str, workflow_edges: Sequence[Mapping[str, Any]] +) -> list[Mapping[str, Any]]: + return [ + edge + for edge in workflow_edges + if edge.get("source") == connector_id + and edge.get("sourceHandle") == CONNECTOR_OUTPUT_HANDLE + and isinstance(edge.get("targetHandle"), str) + ] + + def _resolve_connector_source( connector_id: str, workflow_nodes: dict[str, Mapping[str, Any]], workflow_edges: Sequence[Mapping[str, Any]] ) -> tuple[str, str] | None: @@ -268,6 +296,60 @@ def resolve(node_id: str) -> tuple[str, str] | None: return resolve(connector_id) +def _resolve_for_connector_loop_linkage_path( + edge: Mapping[str, Any], workflow_nodes: dict[str, Mapping[str, Any]], workflow_edges: Sequence[Mapping[str, Any]] +) -> tuple[str, str, list[Mapping[str, Any]]] | None: + """Resolve one connector alias path from a For to its direct ForReturn association.""" + if edge.get("sourceHandle") != "loop_linkage" or edge.get("targetHandle") != CONNECTOR_INPUT_HANDLE: + return None + + source_id = edge.get("source") + connector_id = edge.get("target") + source_node = workflow_nodes.get(source_id) + if ( + not isinstance(source_id, str) + or not _is_invocation_node(source_node) + or source_node.get("data", {}).get("type") != "for" + ): + return None + if not isinstance(connector_id, str) or not _is_connector_node(workflow_nodes.get(connector_id)): + return None + + path_edges: list[Mapping[str, Any]] = [edge] + visited_connectors: set[str] = set() + current_connector_id = connector_id + + while True: + if current_connector_id in visited_connectors: + return None + visited_connectors.add(current_connector_id) + + input_edges = _get_connector_input_edges(current_connector_id, workflow_edges) + if len(input_edges) != 1 or input_edges[0] is not path_edges[-1]: + return None + + output_edges = _get_connector_output_edges(current_connector_id, workflow_edges) + if len(output_edges) != 1: + return None + output_edge = output_edges[0] + path_edges.append(output_edge) + + target_id = output_edge.get("target") + target_handle = output_edge.get("targetHandle") + target_node = workflow_nodes.get(target_id) + if _is_invocation_node(target_node): + if target_node.get("data", {}).get("type") != "for_return" or target_handle != "loop_linkage": + return None + return source_id, target_id, path_edges + if ( + not isinstance(target_id, str) + or not _is_connector_node(target_node) + or target_handle != CONNECTOR_INPUT_HANDLE + ): + return None + current_connector_id = target_id + + def build_graph_from_workflow(workflow: Mapping[str, Any]) -> Graph: workflow_nodes_raw = workflow.get("nodes", []) workflow_edges_raw = workflow.get("edges", []) @@ -276,7 +358,49 @@ def build_graph_from_workflow(workflow: Mapping[str, Any]) -> Graph: workflow_nodes = { node["id"]: node for node in workflow_nodes_raw if _is_mapping(node) and isinstance(node.get("id"), str) } - default_edges = _get_default_edges(workflow_edges_raw if isinstance(workflow_edges_raw, Sequence) else []) + workflow_edges = workflow_edges_raw if isinstance(workflow_edges_raw, Sequence) else [] + default_edges = _get_default_edges(workflow_edges) + loop_linkage_edges = _get_loop_linkage_edges(workflow_edges) + + connector_loop_linkage_paths: list[tuple[str, str, list[Mapping[str, Any]]]] = [] + connector_loop_linkage_edge_ids: set[int] = set() + linked_for_ids = { + edge.get("source") + for edge in loop_linkage_edges + if isinstance(edge.get("source"), str) and edge.get("sourceHandle") == "loop_linkage" + } + linked_return_ids = { + edge.get("target") + for edge in loop_linkage_edges + if isinstance(edge.get("target"), str) and edge.get("targetHandle") == "loop_linkage" + } + for edge in default_edges: + source_id = edge.get("source") + source_handle = edge.get("sourceHandle") + target_id = edge.get("target") + target_handle = edge.get("targetHandle") + if not isinstance(source_id, str) or source_handle != "loop_linkage": + continue + source_node = workflow_nodes.get(source_id) + if not _is_invocation_node(source_node) or source_node.get("data", {}).get("type") != "for": + continue + target_node = workflow_nodes.get(target_id) + if not _is_connector_node(target_node) or target_handle != CONNECTOR_INPUT_HANDLE: + continue + + path = _resolve_for_connector_loop_linkage_path(edge, workflow_nodes, default_edges) + if path is None: + raise InvalidWorkflowInputError( + "loop_linkage connector path must resolve to exactly one For and one ForReturn without branching" + ) + if path[0] in linked_for_ids or path[1] in linked_return_ids: + raise InvalidWorkflowInputError( + "loop_linkage connector path must resolve to exactly one For and one ForReturn without branching" + ) + connector_loop_linkage_paths.append(path) + connector_loop_linkage_edge_ids.update(id(path_edge) for path_edge in path[2]) + linked_for_ids.add(path[0]) + linked_return_ids.add(path[1]) parsed_nodes: dict[str, dict[str, Any]] = {} for node in workflow_nodes.values(): @@ -312,10 +436,12 @@ def build_graph_from_workflow(workflow: Mapping[str, Any]) -> Graph: parsed_nodes[node_id] = graph_node - parsed_edges: list[dict[str, dict[str, str]]] = [] + parsed_edges: list[dict[str, Any]] = [] seen_edges: set[tuple[str, str, str, str]] = set() for edge in default_edges: + if id(edge) in connector_loop_linkage_edge_ids: + continue source_id = edge.get("source") target_id = edge.get("target") source_handle = edge.get("sourceHandle") @@ -345,6 +471,7 @@ def build_graph_from_workflow(workflow: Mapping[str, Any]) -> Graph: parsed_edges.append( { + "type": "default", "source": { "node_id": resolved_source_id, "field": resolved_source_handle, @@ -356,16 +483,46 @@ def build_graph_from_workflow(workflow: Mapping[str, Any]) -> Graph: } ) + for edge in loop_linkage_edges: + source_id = edge.get("source") + target_id = edge.get("target") + source_handle = edge.get("sourceHandle") + target_handle = edge.get("targetHandle") + if not all(isinstance(v, str) for v in (source_id, target_id, source_handle, target_handle)): + continue + if not _is_invocation_node(workflow_nodes.get(source_id)) or not _is_invocation_node( + workflow_nodes.get(target_id) + ): + continue + parsed_edges.append( + { + "type": "loop_linkage", + "source": {"node_id": source_id, "field": source_handle}, + "destination": {"node_id": target_id, "field": target_handle}, + } + ) + + for source_id, target_id, _ in connector_loop_linkage_paths: + parsed_edges.append( + { + "type": "loop_linkage", + "source": {"node_id": source_id, "field": "loop_linkage"}, + "destination": {"node_id": target_id, "field": "loop_linkage"}, + } + ) + for edge in parsed_edges: - destination_node_id = edge["destination"]["node_id"] - destination_field = edge["destination"]["field"] - parsed_nodes[destination_node_id].pop(destination_field, None) + if edge["type"] == "default": + destination_node_id = edge["destination"]["node_id"] + destination_field = edge["destination"]["field"] + parsed_nodes[destination_node_id].pop(destination_field, None) return Graph.model_validate( { "nodes": parsed_nodes, "edges": [ Edge( + type=edge["type"], source=EdgeConnection(**edge["source"]), destination=EdgeConnection(**edge["destination"]), ) diff --git a/invokeai/frontend/web/openapi.json b/invokeai/frontend/web/openapi.json index 14a9684363e..87ea8d5a029 100644 --- a/invokeai/frontend/web/openapi.json +++ b/invokeai/frontend/web/openapi.json @@ -21296,6 +21296,294 @@ "title": "CollectInvocationOutput", "type": "object" }, + "CollectionCartesianInvocation": { + "category": "batch", + "class": "invocation", + "classification": "stable", + "description": "Emits every pair formed by one item from each collection, up to 100,000 pairs.", + "node_pack": "invokeai", + "properties": { + "id": { + "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", + "field_kind": "node_attribute", + "title": "Id", + "type": "string" + }, + "is_intermediate": { + "default": false, + "description": "Whether or not this is an intermediate invocation.", + "field_kind": "node_attribute", + "input": "direct", + "orig_required": true, + "title": "Is Intermediate", + "type": "boolean", + "ui_hidden": false, + "ui_type": "IsIntermediate" + }, + "use_cache": { + "default": true, + "description": "Whether or not to use the cache", + "field_kind": "node_attribute", + "title": "Use Cache", + "type": "boolean" + }, + "first": { + "default": [], + "description": "The first collection", + "field_kind": "input", + "input": "any", + "items": {}, + "orig_default": [], + "orig_required": false, + "title": "First", + "type": "array", + "ui_type": "CollectionField" + }, + "second": { + "default": [], + "description": "The second collection", + "field_kind": "input", + "input": "any", + "items": {}, + "orig_default": [], + "orig_required": false, + "title": "Second", + "type": "array", + "ui_type": "CollectionField" + }, + "type": { + "const": "collection_cartesian", + "default": "collection_cartesian", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["type", "id"], + "tags": ["collection", "cartesian", "product"], + "title": "Cartesian Product of Collections", + "type": "object", + "version": "1.0.0", + "output": { + "$ref": "#/components/schemas/CollectionCartesianInvocationOutput" + } + }, + "CollectionCartesianInvocationOutput": { + "class": "output", + "properties": { + "collection": { + "description": "The Cartesian product pairs", + "field_kind": "output", + "items": {}, + "title": "Collection", + "type": "array", + "ui_hidden": false, + "ui_type": "CollectionField" + }, + "type": { + "const": "collection_cartesian_output", + "default": "collection_cartesian_output", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["output_meta", "collection", "type", "type"], + "title": "CollectionCartesianInvocationOutput", + "type": "object" + }, + "CollectionConcatInvocation": { + "category": "batch", + "class": "invocation", + "classification": "stable", + "description": "Concatenates two collections in left-to-right order.", + "node_pack": "invokeai", + "properties": { + "id": { + "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", + "field_kind": "node_attribute", + "title": "Id", + "type": "string" + }, + "is_intermediate": { + "default": false, + "description": "Whether or not this is an intermediate invocation.", + "field_kind": "node_attribute", + "input": "direct", + "orig_required": true, + "title": "Is Intermediate", + "type": "boolean", + "ui_hidden": false, + "ui_type": "IsIntermediate" + }, + "use_cache": { + "default": true, + "description": "Whether or not to use the cache", + "field_kind": "node_attribute", + "title": "Use Cache", + "type": "boolean" + }, + "first": { + "default": [], + "description": "The first collection", + "field_kind": "input", + "input": "any", + "items": {}, + "orig_default": [], + "orig_required": false, + "title": "First", + "type": "array", + "ui_type": "CollectionField" + }, + "second": { + "default": [], + "description": "The second collection", + "field_kind": "input", + "input": "any", + "items": {}, + "orig_default": [], + "orig_required": false, + "title": "Second", + "type": "array", + "ui_type": "CollectionField" + }, + "type": { + "const": "collection_concat", + "default": "collection_concat", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["type", "id"], + "tags": ["collection", "concat", "sequential"], + "title": "Concatenate Collections", + "type": "object", + "version": "1.0.0", + "output": { + "$ref": "#/components/schemas/CollectionConcatInvocationOutput" + } + }, + "CollectionConcatInvocationOutput": { + "class": "output", + "properties": { + "collection": { + "description": "The concatenated collection", + "field_kind": "output", + "items": {}, + "title": "Collection", + "type": "array", + "ui_hidden": false, + "ui_type": "CollectionField" + }, + "type": { + "const": "collection_concat_output", + "default": "collection_concat_output", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["output_meta", "collection", "type", "type"], + "title": "CollectionConcatInvocationOutput", + "type": "object" + }, + "CollectionZipInvocation": { + "category": "batch", + "class": "invocation", + "classification": "stable", + "description": "Pairs items at matching positions from two equally sized collections.", + "node_pack": "invokeai", + "properties": { + "id": { + "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", + "field_kind": "node_attribute", + "title": "Id", + "type": "string" + }, + "is_intermediate": { + "default": false, + "description": "Whether or not this is an intermediate invocation.", + "field_kind": "node_attribute", + "input": "direct", + "orig_required": true, + "title": "Is Intermediate", + "type": "boolean", + "ui_hidden": false, + "ui_type": "IsIntermediate" + }, + "use_cache": { + "default": true, + "description": "Whether or not to use the cache", + "field_kind": "node_attribute", + "title": "Use Cache", + "type": "boolean" + }, + "first": { + "default": [], + "description": "The first collection", + "field_kind": "input", + "input": "any", + "items": {}, + "orig_default": [], + "orig_required": false, + "title": "First", + "type": "array", + "ui_type": "CollectionField" + }, + "second": { + "default": [], + "description": "The second collection", + "field_kind": "input", + "input": "any", + "items": {}, + "orig_default": [], + "orig_required": false, + "title": "Second", + "type": "array", + "ui_type": "CollectionField" + }, + "type": { + "const": "collection_zip", + "default": "collection_zip", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["type", "id"], + "tags": ["collection", "zip", "pair"], + "title": "Zip Collections", + "type": "object", + "version": "1.0.0", + "output": { + "$ref": "#/components/schemas/CollectionZipInvocationOutput" + } + }, + "CollectionZipInvocationOutput": { + "class": "output", + "properties": { + "collection": { + "description": "The positional pairs", + "field_kind": "output", + "items": {}, + "title": "Collection", + "type": "array", + "ui_hidden": false, + "ui_type": "CollectionField" + }, + "type": { + "const": "collection_zip_output", + "default": "collection_zip_output", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["output_meta", "collection", "type", "type"], + "title": "CollectionZipInvocationOutput", + "type": "object" + }, "ColorCollectionOutput": { "class": "output", "description": "Base class for nodes that output a collection of colors", @@ -27706,6 +27994,13 @@ }, "Edge": { "properties": { + "type": { + "type": "string", + "enum": ["default", "loop_linkage"], + "title": "Type", + "description": "The kind of relationship represented by this edge", + "default": "default" + }, "source": { "$ref": "#/components/schemas/EdgeConnection", "description": "The connection for the edge's from node and field" @@ -35599,64 +35894,392 @@ "title": "FluxVariantType", "description": "FLUX.1 model variants." }, - "FoundModel": { - "properties": { - "path": { - "type": "string", - "title": "Path", - "description": "Path to the model" - }, - "is_installed": { - "type": "boolean", - "title": "Is Installed", - "description": "Whether or not the model is already installed" - } - }, - "type": "object", - "required": ["path", "is_installed"], - "title": "FoundModel" - }, - "FreeUConfig": { - "description": "Configuration for the FreeU hyperparameters.\n- https://huggingface.co/docs/diffusers/main/en/using-diffusers/freeu\n- https://github.com/ChenyangSi/FreeU", - "properties": { - "s1": { - "description": "Scaling factor for stage 1 to attenuate the contributions of the skip features. This is done to mitigate the \"oversmoothing effect\" in the enhanced denoising process.", - "maximum": 3, - "minimum": -1, - "title": "S1", - "type": "number" - }, - "s2": { - "description": "Scaling factor for stage 2 to attenuate the contributions of the skip features. This is done to mitigate the \"oversmoothing effect\" in the enhanced denoising process.", - "maximum": 3, - "minimum": -1, - "title": "S2", - "type": "number" - }, - "b1": { - "description": "Scaling factor for stage 1 to amplify the contributions of backbone features.", - "maximum": 3, - "minimum": -1, - "title": "B1", - "type": "number" - }, - "b2": { - "description": "Scaling factor for stage 2 to amplify the contributions of backbone features.", - "maximum": 3, - "minimum": -1, - "title": "B2", - "type": "number" - } - }, - "required": ["s1", "s2", "b1", "b2"], - "title": "FreeUConfig", - "type": "object" - }, - "FreeUInvocation": { - "category": "model", + "ForInvocation": { "class": "invocation", "classification": "stable", - "description": "Applies FreeU to the UNet. Suggested values (b1/b2/s1/s2):\n\nSD1.5: 1.2/1.4/0.9/0.2,\nSD2: 1.1/1.2/0.9/0.2,\nSDXL: 1.1/1.2/0.6/0.4,", + "node_pack": "invokeai", + "properties": { + "id": { + "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", + "field_kind": "node_attribute", + "title": "Id", + "type": "string" + }, + "is_intermediate": { + "default": false, + "description": "Whether or not this is an intermediate invocation.", + "field_kind": "node_attribute", + "input": "direct", + "orig_required": true, + "title": "Is Intermediate", + "type": "boolean", + "ui_hidden": false, + "ui_type": "IsIntermediate" + }, + "use_cache": { + "default": true, + "description": "Whether or not to use the cache", + "field_kind": "node_attribute", + "title": "Use Cache", + "type": "boolean" + }, + "collection": { + "default": [], + "description": "The list of items to iterate over", + "field_kind": "input", + "input": "any", + "items": {}, + "orig_default": [], + "orig_required": false, + "title": "Collection", + "type": "array", + "ui_type": "CollectionField" + }, + "state": { + "anyOf": [ + { + "$ref": "#/components/schemas/LoopState" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional initial loop state", + "field_kind": "input", + "input": "any", + "orig_default": null, + "orig_required": false + }, + "index": { + "default": -1, + "description": "The internal iteration index for a prepared For execution node", + "field_kind": "input", + "input": "direct", + "orig_default": -1, + "orig_required": false, + "title": "Index", + "type": "integer", + "ui_hidden": true + }, + "type": { + "const": "for", + "default": "for", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["type", "id"], + "title": "ForInvocation", + "type": "object", + "version": "1.3.0", + "output": { + "$ref": "#/components/schemas/ForInvocationOutput" + } + }, + "ForInvocationOutput": { + "class": "output", + "properties": { + "loop_linkage": { + "description": "The loop linkage to the matching ForReturn", + "field_kind": "output", + "title": "Loop Linkage", + "ui_hidden": false, + "ui_type": "AnyField" + }, + "item": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "description": "The item for the current loop iteration, or None when the collection is empty", + "field_kind": "output", + "output_scope": "iteration", + "title": "Collection Item", + "ui_hidden": false, + "ui_type": "CollectionItemField" + }, + "index": { + "description": "The index for the current loop iteration", + "field_kind": "output", + "output_scope": "iteration", + "title": "Index", + "type": "integer", + "ui_hidden": false + }, + "total": { + "description": "The total number of items in the loop collection", + "field_kind": "output", + "output_scope": "iteration", + "title": "Total", + "type": "integer", + "ui_hidden": false + }, + "state": { + "$ref": "#/components/schemas/LoopState", + "description": "The state for the current loop iteration", + "field_kind": "output", + "output_scope": "iteration", + "title": "State", + "ui_hidden": false + }, + "output_collection": { + "description": "The collected loop body outputs", + "field_kind": "output", + "items": {}, + "output_scope": "final", + "title": "Output Collection", + "type": "array", + "ui_hidden": false, + "ui_type": "CollectionField" + }, + "final_state": { + "$ref": "#/components/schemas/LoopState", + "description": "The final loop state", + "field_kind": "output", + "output_scope": "final", + "title": "Final State", + "ui_hidden": false + }, + "type": { + "const": "for_output", + "default": "for_output", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": [ + "output_meta", + "loop_linkage", + "item", + "index", + "total", + "state", + "output_collection", + "final_state", + "type", + "type" + ], + "title": "ForInvocationOutput", + "type": "object" + }, + "ForReturnInvocation": { + "class": "invocation", + "classification": "stable", + "node_pack": "invokeai", + "properties": { + "id": { + "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", + "field_kind": "node_attribute", + "title": "Id", + "type": "string" + }, + "is_intermediate": { + "default": false, + "description": "Whether or not this is an intermediate invocation.", + "field_kind": "node_attribute", + "input": "direct", + "orig_required": true, + "title": "Is Intermediate", + "type": "boolean", + "ui_hidden": false, + "ui_type": "IsIntermediate" + }, + "use_cache": { + "default": true, + "description": "Whether or not to use the cache", + "field_kind": "node_attribute", + "title": "Use Cache", + "type": "boolean" + }, + "loop_linkage": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "description": "The loop linkage from the matching For", + "field_kind": "input", + "input": "connection", + "orig_default": null, + "orig_required": false, + "title": "Loop Linkage", + "ui_type": "AnyField" + }, + "output": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "description": "The output item to append to the loop output collection", + "field_kind": "input", + "input": "any", + "orig_default": null, + "orig_required": false, + "title": "Output", + "ui_type": "CollectionItemField" + }, + "state": { + "anyOf": [ + { + "$ref": "#/components/schemas/LoopState" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The state to pass to the next loop iteration", + "field_kind": "input", + "input": "any", + "orig_default": null, + "orig_required": false + }, + "continue_condition": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": true, + "description": "Whether to schedule the next loop iteration; false finalizes the loop", + "field_kind": "input", + "input": "any", + "orig_default": true, + "orig_required": false, + "title": "Continue Condition" + }, + "type": { + "const": "for_return", + "default": "for_return", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["type", "id"], + "title": "ForReturnInvocation", + "type": "object", + "version": "1.3.2", + "output": { + "$ref": "#/components/schemas/ForReturnInvocationOutput" + } + }, + "ForReturnInvocationOutput": { + "class": "output", + "properties": { + "output": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "description": "The output item to append to the loop output collection", + "field_kind": "output", + "title": "Output", + "ui_hidden": true, + "ui_type": "CollectionItemField" + }, + "state": { + "anyOf": [ + { + "$ref": "#/components/schemas/LoopState" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The state to pass to the next loop iteration", + "field_kind": "output", + "title": "State", + "ui_hidden": true + }, + "type": { + "const": "for_return_output", + "default": "for_return_output", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["output_meta", "output", "state", "type", "type"], + "title": "ForReturnInvocationOutput", + "type": "object" + }, + "FoundModel": { + "properties": { + "path": { + "type": "string", + "title": "Path", + "description": "Path to the model" + }, + "is_installed": { + "type": "boolean", + "title": "Is Installed", + "description": "Whether or not the model is already installed" + } + }, + "type": "object", + "required": ["path", "is_installed"], + "title": "FoundModel" + }, + "FreeUConfig": { + "description": "Configuration for the FreeU hyperparameters.\n- https://huggingface.co/docs/diffusers/main/en/using-diffusers/freeu\n- https://github.com/ChenyangSi/FreeU", + "properties": { + "s1": { + "description": "Scaling factor for stage 1 to attenuate the contributions of the skip features. This is done to mitigate the \"oversmoothing effect\" in the enhanced denoising process.", + "maximum": 3, + "minimum": -1, + "title": "S1", + "type": "number" + }, + "s2": { + "description": "Scaling factor for stage 2 to attenuate the contributions of the skip features. This is done to mitigate the \"oversmoothing effect\" in the enhanced denoising process.", + "maximum": 3, + "minimum": -1, + "title": "S2", + "type": "number" + }, + "b1": { + "description": "Scaling factor for stage 1 to amplify the contributions of backbone features.", + "maximum": 3, + "minimum": -1, + "title": "B1", + "type": "number" + }, + "b2": { + "description": "Scaling factor for stage 2 to amplify the contributions of backbone features.", + "maximum": 3, + "minimum": -1, + "title": "B2", + "type": "number" + } + }, + "required": ["s1", "s2", "b1", "b2"], + "title": "FreeUConfig", + "type": "object" + }, + "FreeUInvocation": { + "category": "model", + "class": "invocation", + "classification": "stable", + "description": "Applies FreeU to the UNet. Suggested values (b1/b2/s1/s2):\n\nSD1.5: 1.2/1.4/0.9/0.2,\nSD2: 1.1/1.2/0.9/0.2,\nSDXL: 1.1/1.2/0.6/0.4,", "node_pack": "invokeai", "properties": { "id": { @@ -36887,6 +37510,15 @@ { "$ref": "#/components/schemas/CollectInvocation" }, + { + "$ref": "#/components/schemas/CollectionCartesianInvocation" + }, + { + "$ref": "#/components/schemas/CollectionConcatInvocation" + }, + { + "$ref": "#/components/schemas/CollectionZipInvocation" + }, { "$ref": "#/components/schemas/ColorCorrectInvocation" }, @@ -37088,6 +37720,12 @@ { "$ref": "#/components/schemas/FluxVaeEncodeInvocation" }, + { + "$ref": "#/components/schemas/ForInvocation" + }, + { + "$ref": "#/components/schemas/ForReturnInvocation" + }, { "$ref": "#/components/schemas/FreeUInvocation" }, @@ -37592,6 +38230,18 @@ { "$ref": "#/components/schemas/SpandrelImageToImageInvocation" }, + { + "$ref": "#/components/schemas/StateEmptyInvocation" + }, + { + "$ref": "#/components/schemas/StateGetInvocation" + }, + { + "$ref": "#/components/schemas/StateMergeInvocation" + }, + { + "$ref": "#/components/schemas/StateSetInvocation" + }, { "$ref": "#/components/schemas/StringBatchInvocation" }, @@ -37826,6 +38476,15 @@ { "$ref": "#/components/schemas/CollectInvocationOutput" }, + { + "$ref": "#/components/schemas/CollectionCartesianInvocationOutput" + }, + { + "$ref": "#/components/schemas/CollectionConcatInvocationOutput" + }, + { + "$ref": "#/components/schemas/CollectionZipInvocationOutput" + }, { "$ref": "#/components/schemas/ColorCollectionOutput" }, @@ -37907,6 +38566,12 @@ { "$ref": "#/components/schemas/FluxReduxOutput" }, + { + "$ref": "#/components/schemas/ForInvocationOutput" + }, + { + "$ref": "#/components/schemas/ForReturnInvocationOutput" + }, { "$ref": "#/components/schemas/Gemma2EncoderOutput" }, @@ -37976,6 +38641,12 @@ { "$ref": "#/components/schemas/LoRASelectorOutput" }, + { + "$ref": "#/components/schemas/LoopStateOutput" + }, + { + "$ref": "#/components/schemas/LoopStateValueOutput" + }, { "$ref": "#/components/schemas/MDControlListOutput" }, @@ -38236,6 +38907,37 @@ "title": "Source Prepared Mapping", "description": "The map of original graph nodes to prepared nodes" }, + "finalized_loop_nodes": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": true, + "title": "Finalized Loop Nodes", + "description": "Legacy set of top-level loop source nodes whose final outputs have been materialized" + }, + "finalized_loop_contexts": { + "items": { + "prefixItems": [ + { + "type": "string" + }, + { + "items": { + "type": "integer" + }, + "type": "array" + } + ], + "type": "array", + "maxItems": 2, + "minItems": 2 + }, + "type": "array", + "uniqueItems": true, + "title": "Finalized Loop Contexts", + "description": "The finalized loop source and parent iteration contexts" + }, "prepared_iteration_paths": { "additionalProperties": { "items": { @@ -38275,7 +38977,8 @@ "workflow_call_stack", "workflow_call_history", "prepared_source_mapping", - "source_prepared_mapping" + "source_prepared_mapping", + "finalized_loop_nodes" ], "title": "GraphExecutionState", "description": "Tracks source-graph expansion, execution progress, and runtime results." @@ -45953,6 +46656,15 @@ { "$ref": "#/components/schemas/CollectInvocation" }, + { + "$ref": "#/components/schemas/CollectionCartesianInvocation" + }, + { + "$ref": "#/components/schemas/CollectionConcatInvocation" + }, + { + "$ref": "#/components/schemas/CollectionZipInvocation" + }, { "$ref": "#/components/schemas/ColorCorrectInvocation" }, @@ -46154,6 +46866,12 @@ { "$ref": "#/components/schemas/FluxVaeEncodeInvocation" }, + { + "$ref": "#/components/schemas/ForInvocation" + }, + { + "$ref": "#/components/schemas/ForReturnInvocation" + }, { "$ref": "#/components/schemas/FreeUInvocation" }, @@ -46658,6 +47376,18 @@ { "$ref": "#/components/schemas/SpandrelImageToImageInvocation" }, + { + "$ref": "#/components/schemas/StateEmptyInvocation" + }, + { + "$ref": "#/components/schemas/StateGetInvocation" + }, + { + "$ref": "#/components/schemas/StateMergeInvocation" + }, + { + "$ref": "#/components/schemas/StateSetInvocation" + }, { "$ref": "#/components/schemas/StringBatchInvocation" }, @@ -46849,6 +47579,15 @@ { "$ref": "#/components/schemas/CollectInvocationOutput" }, + { + "$ref": "#/components/schemas/CollectionCartesianInvocationOutput" + }, + { + "$ref": "#/components/schemas/CollectionConcatInvocationOutput" + }, + { + "$ref": "#/components/schemas/CollectionZipInvocationOutput" + }, { "$ref": "#/components/schemas/ColorCollectionOutput" }, @@ -46930,6 +47669,12 @@ { "$ref": "#/components/schemas/FluxReduxOutput" }, + { + "$ref": "#/components/schemas/ForInvocationOutput" + }, + { + "$ref": "#/components/schemas/ForReturnInvocationOutput" + }, { "$ref": "#/components/schemas/Gemma2EncoderOutput" }, @@ -46999,6 +47744,12 @@ { "$ref": "#/components/schemas/LoRASelectorOutput" }, + { + "$ref": "#/components/schemas/LoopStateOutput" + }, + { + "$ref": "#/components/schemas/LoopStateValueOutput" + }, { "$ref": "#/components/schemas/MDControlListOutput" }, @@ -47349,6 +48100,15 @@ { "$ref": "#/components/schemas/CollectInvocation" }, + { + "$ref": "#/components/schemas/CollectionCartesianInvocation" + }, + { + "$ref": "#/components/schemas/CollectionConcatInvocation" + }, + { + "$ref": "#/components/schemas/CollectionZipInvocation" + }, { "$ref": "#/components/schemas/ColorCorrectInvocation" }, @@ -47550,6 +48310,12 @@ { "$ref": "#/components/schemas/FluxVaeEncodeInvocation" }, + { + "$ref": "#/components/schemas/ForInvocation" + }, + { + "$ref": "#/components/schemas/ForReturnInvocation" + }, { "$ref": "#/components/schemas/FreeUInvocation" }, @@ -48054,6 +48820,18 @@ { "$ref": "#/components/schemas/SpandrelImageToImageInvocation" }, + { + "$ref": "#/components/schemas/StateEmptyInvocation" + }, + { + "$ref": "#/components/schemas/StateGetInvocation" + }, + { + "$ref": "#/components/schemas/StateMergeInvocation" + }, + { + "$ref": "#/components/schemas/StateSetInvocation" + }, { "$ref": "#/components/schemas/StringBatchInvocation" }, @@ -48336,6 +49114,15 @@ "collect": { "$ref": "#/components/schemas/CollectInvocationOutput" }, + "collection_cartesian": { + "$ref": "#/components/schemas/CollectionCartesianInvocationOutput" + }, + "collection_concat": { + "$ref": "#/components/schemas/CollectionConcatInvocationOutput" + }, + "collection_zip": { + "$ref": "#/components/schemas/CollectionZipInvocationOutput" + }, "color": { "$ref": "#/components/schemas/ColorOutput" }, @@ -48537,6 +49324,12 @@ "flux_vae_encode": { "$ref": "#/components/schemas/LatentsOutput" }, + "for": { + "$ref": "#/components/schemas/ForInvocationOutput" + }, + "for_return": { + "$ref": "#/components/schemas/ForReturnInvocationOutput" + }, "freeu": { "$ref": "#/components/schemas/UNetOutput" }, @@ -49041,6 +49834,18 @@ "spandrel_image_to_image_autoscale": { "$ref": "#/components/schemas/ImageOutput" }, + "state_empty": { + "$ref": "#/components/schemas/LoopStateOutput" + }, + "state_get": { + "$ref": "#/components/schemas/LoopStateValueOutput" + }, + "state_merge": { + "$ref": "#/components/schemas/LoopStateOutput" + }, + "state_set": { + "$ref": "#/components/schemas/LoopStateOutput" + }, "string": { "$ref": "#/components/schemas/StringOutput" }, @@ -49223,6 +50028,9 @@ "cogview4_model_loader", "cogview4_text_encoder", "collect", + "collection_cartesian", + "collection_concat", + "collection_zip", "color", "color_correct", "color_map", @@ -49290,6 +50098,8 @@ "flux_text_encoder", "flux_vae_decode", "flux_vae_encode", + "for", + "for_return", "freeu", "gemini_image_generation", "gemma2_encoder_loader", @@ -49458,6 +50268,10 @@ "show_image", "spandrel_image_to_image", "spandrel_image_to_image_autoscale", + "state_empty", + "state_get", + "state_merge", + "state_set", "string", "string_batch", "string_collection", @@ -49680,6 +50494,15 @@ { "$ref": "#/components/schemas/CollectInvocation" }, + { + "$ref": "#/components/schemas/CollectionCartesianInvocation" + }, + { + "$ref": "#/components/schemas/CollectionConcatInvocation" + }, + { + "$ref": "#/components/schemas/CollectionZipInvocation" + }, { "$ref": "#/components/schemas/ColorCorrectInvocation" }, @@ -49881,6 +50704,12 @@ { "$ref": "#/components/schemas/FluxVaeEncodeInvocation" }, + { + "$ref": "#/components/schemas/ForInvocation" + }, + { + "$ref": "#/components/schemas/ForReturnInvocation" + }, { "$ref": "#/components/schemas/FreeUInvocation" }, @@ -50385,6 +51214,18 @@ { "$ref": "#/components/schemas/SpandrelImageToImageInvocation" }, + { + "$ref": "#/components/schemas/StateEmptyInvocation" + }, + { + "$ref": "#/components/schemas/StateGetInvocation" + }, + { + "$ref": "#/components/schemas/StateMergeInvocation" + }, + { + "$ref": "#/components/schemas/StateSetInvocation" + }, { "$ref": "#/components/schemas/StringBatchInvocation" }, @@ -50767,6 +51608,15 @@ { "$ref": "#/components/schemas/CollectInvocation" }, + { + "$ref": "#/components/schemas/CollectionCartesianInvocation" + }, + { + "$ref": "#/components/schemas/CollectionConcatInvocation" + }, + { + "$ref": "#/components/schemas/CollectionZipInvocation" + }, { "$ref": "#/components/schemas/ColorCorrectInvocation" }, @@ -50968,6 +51818,12 @@ { "$ref": "#/components/schemas/FluxVaeEncodeInvocation" }, + { + "$ref": "#/components/schemas/ForInvocation" + }, + { + "$ref": "#/components/schemas/ForReturnInvocation" + }, { "$ref": "#/components/schemas/FreeUInvocation" }, @@ -51472,6 +52328,18 @@ { "$ref": "#/components/schemas/SpandrelImageToImageInvocation" }, + { + "$ref": "#/components/schemas/StateEmptyInvocation" + }, + { + "$ref": "#/components/schemas/StateGetInvocation" + }, + { + "$ref": "#/components/schemas/StateMergeInvocation" + }, + { + "$ref": "#/components/schemas/StateSetInvocation" + }, { "$ref": "#/components/schemas/StringBatchInvocation" }, @@ -59151,6 +60019,67 @@ "title": "LogoutResponse", "description": "Response from logout." }, + "LoopState": { + "properties": { + "values": { + "additionalProperties": true, + "title": "Values", + "type": "object" + } + }, + "title": "LoopState", + "type": "object" + }, + "LoopStateOutput": { + "class": "output", + "properties": { + "state": { + "$ref": "#/components/schemas/LoopState", + "description": "The loop state", + "field_kind": "output", + "ui_hidden": false + }, + "type": { + "const": "loop_state_output", + "default": "loop_state_output", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["output_meta", "state", "type", "type"], + "title": "LoopStateOutput", + "type": "object" + }, + "LoopStateValueOutput": { + "class": "output", + "properties": { + "value": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "description": "The value read from the loop state, or None when the key is missing", + "field_kind": "output", + "title": "Value", + "ui_hidden": false, + "ui_type": "AnyField" + }, + "type": { + "const": "loop_state_value_output", + "default": "loop_state_value_output", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["output_meta", "value", "type", "type"], + "title": "LoopStateValueOutput", + "type": "object" + }, "LoraModelDefaultSettings": { "properties": { "weight": { @@ -76185,12 +77114,29 @@ } ], "default": null + }, + "output_scope": { + "anyOf": [ + { + "$ref": "#/components/schemas/OutputScope" + }, + { + "type": "null" + } + ], + "default": null } }, - "required": ["field_kind", "ui_hidden", "ui_order", "ui_type"], + "required": ["field_kind", "ui_hidden", "ui_order", "ui_type", "output_scope"], "title": "OutputFieldJSONSchemaExtra", "type": "object" }, + "OutputScope": { + "description": "The execution scope for an output field.\n- `Iteration`: The field emits values for a loop body's current iteration.\n- `Final`: The field emits values after a loop boundary completes.", + "enum": ["iteration", "final"], + "title": "OutputScope", + "type": "string" + }, "PBRMapsInvocation": { "category": "controlnet_preprocessors", "class": "invocation", @@ -87375,6 +88321,309 @@ "required": ["description", "source", "name", "base", "type"], "title": "StarterModelWithoutDependencies" }, + "StateEmptyInvocation": { + "category": "workflow", + "class": "invocation", + "classification": "stable", + "description": "Creates an empty loop state.", + "node_pack": "invokeai", + "properties": { + "id": { + "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", + "field_kind": "node_attribute", + "title": "Id", + "type": "string" + }, + "is_intermediate": { + "default": false, + "description": "Whether or not this is an intermediate invocation.", + "field_kind": "node_attribute", + "input": "direct", + "orig_required": true, + "title": "Is Intermediate", + "type": "boolean", + "ui_hidden": false, + "ui_type": "IsIntermediate" + }, + "use_cache": { + "default": true, + "description": "Whether or not to use the cache", + "field_kind": "node_attribute", + "title": "Use Cache", + "type": "boolean" + }, + "type": { + "const": "state_empty", + "default": "state_empty", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["type", "id"], + "tags": ["loop", "state"], + "title": "Empty Loop State", + "type": "object", + "version": "1.0.0", + "output": { + "$ref": "#/components/schemas/LoopStateOutput" + } + }, + "StateGetInvocation": { + "category": "workflow", + "class": "invocation", + "classification": "stable", + "description": "Reads a value from loop state.", + "node_pack": "invokeai", + "properties": { + "id": { + "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", + "field_kind": "node_attribute", + "title": "Id", + "type": "string" + }, + "is_intermediate": { + "default": false, + "description": "Whether or not this is an intermediate invocation.", + "field_kind": "node_attribute", + "input": "direct", + "orig_required": true, + "title": "Is Intermediate", + "type": "boolean", + "ui_hidden": false, + "ui_type": "IsIntermediate" + }, + "use_cache": { + "default": true, + "description": "Whether or not to use the cache", + "field_kind": "node_attribute", + "title": "Use Cache", + "type": "boolean" + }, + "state": { + "anyOf": [ + { + "$ref": "#/components/schemas/LoopState" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The loop state to read", + "field_kind": "input", + "input": "any", + "orig_required": true + }, + "key": { + "default": "", + "description": "The state key to read", + "field_kind": "input", + "input": "any", + "orig_default": "", + "orig_required": false, + "title": "Key", + "type": "string" + }, + "default": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "description": "The value to return when the key is missing", + "input": "any", + "field_kind": "input", + "orig_required": false, + "orig_default": null, + "ui_type": "AnyField", + "title": "Default" + }, + "type": { + "const": "state_get", + "default": "state_get", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["type", "id"], + "tags": ["loop", "state"], + "title": "Get Loop State Value", + "type": "object", + "version": "1.0.2", + "output": { + "$ref": "#/components/schemas/LoopStateValueOutput" + } + }, + "StateMergeInvocation": { + "category": "workflow", + "class": "invocation", + "classification": "stable", + "description": "Returns loop state with multiple values merged.", + "node_pack": "invokeai", + "properties": { + "id": { + "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", + "field_kind": "node_attribute", + "title": "Id", + "type": "string" + }, + "is_intermediate": { + "default": false, + "description": "Whether or not this is an intermediate invocation.", + "field_kind": "node_attribute", + "input": "direct", + "orig_required": true, + "title": "Is Intermediate", + "type": "boolean", + "ui_hidden": false, + "ui_type": "IsIntermediate" + }, + "use_cache": { + "default": true, + "description": "Whether or not to use the cache", + "field_kind": "node_attribute", + "title": "Use Cache", + "type": "boolean" + }, + "state": { + "anyOf": [ + { + "$ref": "#/components/schemas/LoopState" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The loop state to update", + "field_kind": "input", + "input": "any", + "orig_default": null, + "orig_required": false + }, + "values": { + "additionalProperties": true, + "default": {}, + "description": "The values to merge into the loop state. Connect an output to this input.", + "field_kind": "input", + "input": "any", + "orig_default": {}, + "orig_required": false, + "title": "Values", + "type": "object", + "ui_type": "AnyField" + }, + "type": { + "const": "state_merge", + "default": "state_merge", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["type", "id"], + "tags": ["loop", "state"], + "title": "Merge Loop State Values", + "type": "object", + "version": "1.0.1", + "output": { + "$ref": "#/components/schemas/LoopStateOutput" + } + }, + "StateSetInvocation": { + "category": "workflow", + "class": "invocation", + "classification": "stable", + "description": "Returns loop state with one value set.", + "node_pack": "invokeai", + "properties": { + "id": { + "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", + "field_kind": "node_attribute", + "title": "Id", + "type": "string" + }, + "is_intermediate": { + "default": false, + "description": "Whether or not this is an intermediate invocation.", + "field_kind": "node_attribute", + "input": "direct", + "orig_required": true, + "title": "Is Intermediate", + "type": "boolean", + "ui_hidden": false, + "ui_type": "IsIntermediate" + }, + "use_cache": { + "default": true, + "description": "Whether or not to use the cache", + "field_kind": "node_attribute", + "title": "Use Cache", + "type": "boolean" + }, + "state": { + "anyOf": [ + { + "$ref": "#/components/schemas/LoopState" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The loop state to update", + "field_kind": "input", + "input": "any", + "orig_default": null, + "orig_required": false + }, + "key": { + "default": "", + "description": "The state key to set", + "field_kind": "input", + "input": "any", + "orig_default": "", + "orig_required": false, + "title": "Key", + "type": "string" + }, + "value": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "description": "The value to set. Connect an output to this input.", + "field_kind": "input", + "input": "any", + "orig_default": null, + "orig_required": false, + "title": "Value", + "ui_type": "AnyField" + }, + "type": { + "const": "state_set", + "default": "state_set", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["type", "id"], + "tags": ["loop", "state"], + "title": "Set Loop State Value", + "type": "object", + "version": "1.0.1", + "output": { + "$ref": "#/components/schemas/LoopStateOutput" + } + }, "String2Output": { "class": "output", "description": "Base class for invocations that output two strings", @@ -91327,7 +92576,7 @@ "type": "object" }, "UIType": { - "description": "Type hints for the UI for situations in which the field type is not enough to infer the correct UI type.\n\n- Model Fields\nThe most common node-author-facing use will be for model fields. Internally, there is no difference\nbetween SD-1, SD-2 and SDXL model fields - they all use the class `MainModelField`. To ensure the\nbase-model-specific UI is rendered, use e.g. `ui_type=UIType.SDXLMainModelField` to indicate that\nthe field is an SDXL main model field.\n\n- Any Field\nWe cannot infer the usage of `typing.Any` via schema parsing, so you *must* use `ui_type=UIType.Any` to\nindicate that the field accepts any type. Use with caution. This cannot be used on outputs.\n\n- Scheduler Field\nSpecial handling in the UI is needed for this field, which otherwise would be parsed as a plain enum field.\n\n- Internal Fields\nSimilar to the Any Field, the `collect` and `iterate` nodes make use of `typing.Any`. To facilitate\nhandling these types in the client, we use `UIType._Collection` and `UIType._CollectionItem`. These\nshould not be used by node authors.\n\n- DEPRECATED Fields\nThese types are deprecated and should not be used by node authors. A warning will be logged if one is\nused, and the type will be ignored. They are included here for backwards compatibility.", + "description": "Type hints for the UI for situations in which the field type is not enough to infer the correct UI type.\n\n- Model Fields\nThe most common node-author-facing use will be for model fields. Internally, there is no difference\nbetween SD-1, SD-2 and SDXL model fields - they all use the class `MainModelField`. To ensure the\nbase-model-specific UI is rendered, use e.g. `ui_type=UIType.SDXLMainModelField` to indicate that\nthe field is an SDXL main model field.\n\n- Any Field\nWe cannot infer the usage of `typing.Any` via schema parsing, so you *must* use `ui_type=UIType.Any` to\nindicate that the field accepts any type. Use with caution. On inputs, this renders as a connection-only field.\n\n- Scheduler Field\nSpecial handling in the UI is needed for this field, which otherwise would be parsed as a plain enum field.\n\n- Internal Fields\nSimilar to the Any Field, the `collect` and `iterate` nodes make use of `typing.Any`. To facilitate\nhandling these types in the client, we use `UIType._Collection` and `UIType._CollectionItem`. These\nshould not be used by node authors.\n\n- DEPRECATED Fields\nThese types are deprecated and should not be used by node authors. A warning will be logged if one is\nused, and the type will be ignored. They are included here for backwards compatibility.", "enum": [ "SchedulerField", "AnyField", diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 9fbe7cdb6c4..8437970dcf3 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1550,6 +1550,30 @@ "colorCodeEdges": "Color-Code Edges", "colorCodeEdgesHelp": "Color-code edges according to their connected fields", "connectionWouldCreateCycle": "Connection would create a cycle", + "loopOutputScopeConflict": "Final loop outputs cannot feed the loop body", + "forLoopMissingIterationOutput": "For loop must have an iteration output", + "forLoopReturnCount": "For loop body must have one return", + "forLoopUnterminatedBody": "All For loop body paths must terminate at its return", + "forLoopNestedUnsupported": "This nested For loop shape is not supported", + "forLoopIterateUnsupported": "This Iterate shape inside the For body is not supported", + "forLoopIteratorInputUnsupported": "Iterator-derived external inputs cannot feed a For loop body", + "forLoopFinalOutputInBody": "Final For loop outputs cannot feed its body", + "forLoopBodyEscape": "For loop body outputs cannot escape before its return", + "forLoopLinkageMissing": "For loop linkage is missing between For and ForReturn", + "forLoopLinkageInvalid": "For loop linkage must connect a For to its ForReturn", + "forLoopLinkageDuplicate": "For loop linkage must connect each For and ForReturn exactly once", + "forLoopInputCount": "For loop may have only one collection and state input each", + "forReturnInputCount": "ForReturn may have only one output and state input each", + "forReturnOwnership": "For return must belong to exactly one For loop", + "forLoopBodyBoundary": "For loop body", + "forLoopBodyBoundaryStatus": { + "missing_linkage": "missing loop linkage", + "invalid_linkage": "invalid loop linkage", + "duplicate_linkage": "duplicate loop linkage", + "missing_return": "missing ForReturn", + "multiple_returns": "multiple ForReturns", + "orphan_return": "unowned ForReturn" + }, "currentImage": "Current Image", "currentImageDescription": "Displays the current image in the Node Editor", "downloadWorkflow": "Download Workflow JSON", @@ -1562,6 +1586,7 @@ "executionStateError": "Error", "executionStateInProgress": "In Progress", "fieldTypesMustMatch": "Field types must match", + "finalOutputs": "Final Outputs", "fitViewportNodes": "Fit View", "float": "Float", "fullyContainNodes": "Fully Contain Nodes to Select", @@ -1573,6 +1598,7 @@ "hideLegendNodes": "Hide Field Type Legend", "hideMinimapnodes": "Hide MiniMap", "inputMayOnlyHaveOneConnection": "Input may only have one connection", + "iterationOutputs": "Iteration Outputs", "integer": "Integer", "ipAdapter": "IP-Adapter", "loadingNodes": "Loading Nodes...", diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk.mounted.test.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk.mounted.test.tsx new file mode 100644 index 00000000000..6702565ac97 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk.mounted.test.tsx @@ -0,0 +1,319 @@ +// @vitest-environment happy-dom +import { applyEdgeChanges, applyNodeChanges } from '@xyflow/react'; +import { + $addNodeCmdk, + $cursorPos, + $edgePendingUpdate, + $pendingConnection, + $templates, + edgesChanged, + nodesChanged, +} from 'features/nodes/store/nodesSlice'; +import type { PendingConnection } from 'features/nodes/store/types'; +import { CONNECTOR_INPUT_HANDLE, CONNECTOR_OUTPUT_HANDLE } from 'features/nodes/store/util/connectorTopology'; +import { buildEdge, buildLoopLinkageEdge, buildNode, for_loop, for_return } from 'features/nodes/store/util/testUtils'; +import type { AnyEdge, AnyNode } from 'features/nodes/types/invocation'; +import type { ChangeEvent, ReactNode } from 'react'; +import * as React from 'react'; +import { act } from 'react'; +import type { Root } from 'react-dom/client'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + nodes: [] as AnyNode[], + edges: [] as AnyEdge[], + dispatch: vi.fn(), + buildNode: vi.fn(), +})); + +vi.mock('app/store/storeHooks', () => { + const getState = () => ({ + nodes: { present: { nodes: mocks.nodes, edges: mocks.edges } }, + ui: { activeTab: 'workflows' }, + workflowSettings: { shouldGroupNodesByCategory: false }, + }); + + return { + useAppDispatch: () => mocks.dispatch, + useAppSelector: (selector: (state: ReturnType) => unknown) => selector(getState()), + useAppStore: () => ({ getState, dispatch: mocks.dispatch }), + }; +}); + +vi.mock('features/nodes/hooks/useBuildNode', () => ({ + useBuildNode: () => mocks.buildNode, +})); + +vi.mock('features/system/components/HotkeysModal/useHotkeyData', () => ({ + useRegisteredHotkeys: () => undefined, +})); + +vi.mock('features/toast/toast', () => ({ + toast: vi.fn(), +})); + +vi.mock('common/components/IAIImageFallback', () => ({ + IAINoContentFallback: () => null, +})); + +vi.mock('common/components/OverlayScrollbars/ScrollableContent', () => ({ + default: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => + ({ + 'nodes.nodeSearch': 'Search nodes', + 'common.noMatchingItems': 'No matching items', + 'common.expandAll': 'Expand all', + 'common.collapseAll': 'Collapse all', + })[key] ?? key, + }), +})); + +vi.mock('@invoke-ai/ui-library', () => { + type Props = { + children?: ReactNode; + onClick?: () => void; + onChange?: (event: ChangeEvent) => void; + placeholder?: string; + value?: string; + [key: string]: unknown; + }; + + const getDomProps = ({ children: _children, ...props }: Props): React.HTMLAttributes => + Object.fromEntries( + Object.entries(props).filter( + ([key]) => + key === 'aria-label' || + key === 'onClick' || + key === 'onChange' || + key === 'onKeyDown' || + key === 'onPointerMove' || + key === 'placeholder' || + key === 'role' || + key === 'tabIndex' || + key === 'value' || + key.startsWith('aria-') || + key.startsWith('data-') + ) + ) as React.HTMLAttributes; + + const Flex = React.forwardRef(function Flex( + { children, ...props }: Props, + ref: React.ForwardedRef + ) { + return ( +
+ {children} +
+ ); + }); + const Input = React.forwardRef(function Input( + { children, ...props }: Props, + ref: React.ForwardedRef + ) { + return ; + }); + const Box = ({ children }: Props) =>
{children}
; + const Text = ({ children }: Props) => {children}; + const Button = ({ children, ...props }: Props) => ( + + ); + const Modal = ({ children, isOpen }: Props & { isOpen?: boolean }) => (isOpen ?
{children}
: null); + const passthrough = ({ children }: Props) =>
{children}
; + const Icon = () => ; + const ModalOverlay = () => null; + const Spacer = () => ; + + Box.displayName = 'Box'; + Text.displayName = 'Text'; + Button.displayName = 'Button'; + Modal.displayName = 'Modal'; + passthrough.displayName = 'passthrough'; + Icon.displayName = 'Icon'; + ModalOverlay.displayName = 'ModalOverlay'; + Spacer.displayName = 'Spacer'; + + return { + Box, + Button, + Flex, + Icon, + Input, + Modal, + ModalBody: passthrough, + ModalContent: passthrough, + ModalOverlay, + Portal: passthrough, + Spacer, + Text, + }; +}); + +import { AddNodeCmdk } from './AddNodeCmdk/AddNodeCmdk'; + +declare global { + var IS_REACT_ACT_ENVIRONMENT: boolean; +} +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const setNodeId = (node: AnyNode, id: string): AnyNode => { + node.id = id; + node.data.id = id; + return node; +}; + +const buildConnector = (id: string): AnyNode => ({ + id, + type: 'connector', + position: { x: 0, y: 0 }, + data: { + id, + type: 'connector', + label: 'Connector', + isOpen: true, + }, +}); + +describe('AddNodeCmdk (mounted)', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + const forNode = setNodeId(buildNode(for_loop), 'for-node'); + mocks.nodes = [forNode]; + mocks.edges = []; + mocks.dispatch.mockReset(); + mocks.dispatch.mockImplementation((action: unknown) => { + if (nodesChanged.match(action)) { + mocks.nodes = applyNodeChanges(action.payload, mocks.nodes); + } + if (edgesChanged.match(action)) { + mocks.edges = applyEdgeChanges(action.payload, mocks.edges); + } + return action; + }); + mocks.buildNode.mockReset(); + mocks.buildNode.mockReturnValue(setNodeId(buildNode(for_return), 'return-node')); + + $templates.set({ for: for_loop, for_return }); + $addNodeCmdk.set(true); + $cursorPos.set({ x: 0, y: 0 }); + $edgePendingUpdate.set(null); + $pendingConnection.set({ + nodeId: 'for-node', + handleId: 'item', + handleType: 'source', + fieldTemplate: for_loop.outputs.item as PendingConnection['fieldTemplate'], + }); + + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + $addNodeCmdk.set(false); + $cursorPos.set(null); + $edgePendingUpdate.set(null); + $pendingConnection.set(null); + $templates.set({}); + }); + + it('adds ForReturn with loop linkage and auto-connects its output', () => { + act(() => { + root.render(); + }); + + const returnItem = Array.from(container.querySelectorAll('[role="button"]')).find((element) => + element.textContent?.trim().startsWith('ForReturn') + ); + expect(returnItem).toBeDefined(); + + act(() => { + returnItem?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(mocks.edges).toEqual([ + expect.objectContaining({ + source: 'for-node', + sourceHandle: 'item', + target: 'return-node', + targetHandle: 'output', + }), + expect.objectContaining({ + type: 'loop_linkage', + source: 'for-node', + sourceHandle: 'loop_linkage', + target: 'return-node', + targetHandle: 'loop_linkage', + }), + ]); + expect($addNodeCmdk.get()).toBe(false); + expect($pendingConnection.get()).toBeNull(); + }); + + it('adds loop linkage when an iteration output is routed through a connector', () => { + const connector = buildConnector('connector'); + mocks.nodes = [mocks.nodes[0]!, connector]; + mocks.edges = [buildEdge('for-node', 'item', connector.id, CONNECTOR_INPUT_HANDLE)]; + $pendingConnection.set({ + nodeId: connector.id, + handleId: CONNECTOR_OUTPUT_HANDLE, + handleType: 'source', + fieldTemplate: { + ...for_loop.outputs.item, + name: CONNECTOR_OUTPUT_HANDLE, + } as PendingConnection['fieldTemplate'], + }); + + act(() => { + root.render(); + }); + + const returnItem = Array.from(container.querySelectorAll('[role="button"]')).find((element) => + element.textContent?.trim().startsWith('ForReturn') + ); + act(() => { + returnItem?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(mocks.edges).toContainEqual( + expect.objectContaining({ + type: 'loop_linkage', + source: 'for-node', + sourceHandle: 'loop_linkage', + target: 'return-node', + targetHandle: 'loop_linkage', + }) + ); + }); + + it('does not add a duplicate linkage when the For is already paired', () => { + const existingReturn = setNodeId(buildNode(for_return), 'existing-return'); + mocks.nodes = [mocks.nodes[0]!, existingReturn]; + mocks.edges = [buildLoopLinkageEdge('for-node', existingReturn.id)]; + + act(() => { + root.render(); + }); + + const returnItem = Array.from(container.querySelectorAll('[role="button"]')).find((element) => + element.textContent?.trim().startsWith('ForReturn') + ); + act(() => { + returnItem?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(mocks.edges.filter((edge) => edge.type === 'loop_linkage')).toEqual([ + buildLoopLinkageEdge('for-node', existingReturn.id), + ]); + }); +}); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk.test.ts b/invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk.test.ts new file mode 100644 index 00000000000..0f31d8868fa --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk.test.ts @@ -0,0 +1,162 @@ +import type { PendingConnection } from 'features/nodes/store/types'; +import { CONNECTOR_INPUT_HANDLE, CONNECTOR_OUTPUT_HANDLE } from 'features/nodes/store/util/connectorTopology'; +import { add, buildEdge, buildNode, for_loop, for_return, templates } from 'features/nodes/store/util/testUtils'; +import type { InvocationTemplate } from 'features/nodes/types/invocation'; +import { describe, expect, it } from 'vitest'; + +import { getPendingConnectionNodeItems, sortNodeCommandItemGroups, sortNodeCommandItems } from './AddNodeCmdk'; + +describe('getPendingConnectionNodeItems', () => { + it('prioritizes ForReturn for an iteration output connection', () => { + const pendingConnection: PendingConnection = { + nodeId: 'for-node', + handleId: 'item', + handleType: 'source' as const, + fieldTemplate: for_loop.outputs.item as PendingConnection['fieldTemplate'], + }; + + const items = getPendingConnectionNodeItems([add, for_return], pendingConnection, ''); + + expect(items[0]?.value).toBe('for_return'); + }); + + it('only offers ForReturn for a For loop linkage connection', () => { + const pendingConnection: PendingConnection = { + nodeId: 'for-node', + handleId: 'loop_linkage', + handleType: 'source' as const, + fieldTemplate: for_loop.outputs.loop_linkage as PendingConnection['fieldTemplate'], + }; + + const items = getPendingConnectionNodeItems([add, for_return], pendingConnection, ''); + + expect(items.map((item) => item.value)).toEqual(['for_return']); + }); + + it('only offers For for a ForReturn loop linkage connection', () => { + const pendingConnection: PendingConnection = { + nodeId: 'return-node', + handleId: 'loop_linkage', + handleType: 'target' as const, + fieldTemplate: for_return.inputs.loop_linkage as PendingConnection['fieldTemplate'], + }; + + const items = getPendingConnectionNodeItems([add, for_loop], pendingConnection, ''); + + expect(items.map((item) => item.value)).toEqual(['for']); + }); + + it('keeps ForReturn first after exact-title search ranking', () => { + const pendingConnection: PendingConnection = { + nodeId: 'for-node', + handleId: 'item', + handleType: 'source' as const, + fieldTemplate: for_loop.outputs.item as PendingConnection['fieldTemplate'], + }; + + const items = getPendingConnectionNodeItems([for_loop, for_return], pendingConnection, 'for'); + + const sortedItems = sortNodeCommandItems(items, 'for', pendingConnection); + + expect(sortedItems.map((item) => item.value)).toEqual(['for_return', 'for']); + }); + + it('prioritizes ForReturn when the iteration output passes through a connector', () => { + const forNode = buildNode(for_loop); + forNode.id = 'for-node'; + const connector = { + id: 'connector-node', + type: 'connector' as const, + position: { x: 0, y: 0 }, + data: { id: 'connector-node', type: 'connector' as const, label: 'Connector', isOpen: true }, + }; + const pendingConnection: PendingConnection = { + nodeId: connector.id, + handleId: CONNECTOR_OUTPUT_HANDLE, + handleType: 'source' as const, + fieldTemplate: { + name: CONNECTOR_OUTPUT_HANDLE, + title: 'Connector Output', + description: '', + fieldKind: 'output', + ui_hidden: false, + type: { name: 'AnyField', cardinality: 'SINGLE', batch: false }, + }, + }; + + const items = getPendingConnectionNodeItems([add, for_return], pendingConnection, '', { + nodes: [forNode, connector], + edges: [buildEdge(forNode.id, 'item', connector.id, CONNECTOR_INPUT_HANDLE)], + templates: { ...templates, for: for_loop, for_return }, + }); + + expect(items[0]?.value).toBe('for_return'); + }); + + it('preserves generic pending connection ordering for non-loop outputs', () => { + const pendingConnection: PendingConnection = { + nodeId: 'add-node', + handleId: 'value', + handleType: 'source' as const, + fieldTemplate: add.outputs.value as PendingConnection['fieldTemplate'], + }; + + const items = getPendingConnectionNodeItems([add, for_return], pendingConnection, ''); + + expect(items.map((item) => item.value)).toEqual(['add', 'for_return']); + }); + + it('preserves exact-title ranking for a non-loop pending connection', () => { + const pendingConnection: PendingConnection = { + nodeId: 'add-node', + handleId: 'value', + handleType: 'source' as const, + fieldTemplate: add.outputs.value as PendingConnection['fieldTemplate'], + }; + const addOther = { ...add, title: 'Add Other', type: 'add_other' } as InvocationTemplate; + + const items = getPendingConnectionNodeItems([add, addOther], pendingConnection, 'add'); + const sortedItems = sortNodeCommandItems(items, 'add', pendingConnection); + + expect(sortedItems.map((item) => item.value)).toEqual(['add', 'add_other']); + }); +}); + +describe('sortNodeCommandItemGroups', () => { + it('promotes the category containing ForReturn for an iteration output connection', () => { + const addItem = getPendingConnectionNodeItems( + [add], + { + nodeId: 'add-node', + handleId: 'value', + handleType: 'source', + fieldTemplate: add.outputs.value as PendingConnection['fieldTemplate'], + }, + '' + )[0]; + const forReturnItem = getPendingConnectionNodeItems( + [for_return], + { + nodeId: 'for-node', + handleId: 'item', + handleType: 'source', + fieldTemplate: for_loop.outputs.item as PendingConnection['fieldTemplate'], + }, + '' + )[0]; + if (!addItem || !forReturnItem) { + throw new Error('Expected command items'); + } + + const groups = sortNodeCommandItemGroups( + [ + ['math', [addItem]], + ['other', [forReturnItem]], + ], + '', + true + ); + + expect(groups.map(([category]) => category)).toEqual(['other', 'math']); + }); +}); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk.tsx index 8f27a83c14f..10d5cbbfb45 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk.tsx @@ -31,12 +31,15 @@ import { nodesChanged, } from 'features/nodes/store/nodesSlice'; import { selectNodesSlice } from 'features/nodes/store/selectors'; +import type { PendingConnection, Templates } from 'features/nodes/store/types'; +import { resolvePendingConnectionSource } from 'features/nodes/store/util/connectorTopology'; import { findUnoccupiedPosition } from 'features/nodes/store/util/findUnoccupiedPosition'; import { getFirstValidConnection } from 'features/nodes/store/util/getFirstValidConnection'; import { connectionToEdge } from 'features/nodes/store/util/reactFlowUtil'; import { validateConnectionTypes } from 'features/nodes/store/util/validateConnectionTypes'; import { selectShouldGroupNodesByCategory } from 'features/nodes/store/workflowSettingsSlice'; -import type { AnyEdge, AnyNode } from 'features/nodes/types/invocation'; +import { LOOP_LINKAGE_FIELD } from 'features/nodes/types/constants'; +import type { AnyEdge, AnyNode, InvocationTemplate } from 'features/nodes/types/invocation'; import { isInvocationNode } from 'features/nodes/types/invocation'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { toast } from 'features/toast/toast'; @@ -54,7 +57,6 @@ import { PiLightningFill, } from 'react-icons/pi'; import type { S } from 'services/api/types'; -import { objectEntries } from 'tsafe'; import { useDebounce } from 'use-debounce'; const useAddNode = () => { @@ -130,6 +132,34 @@ const useAddNode = () => { if (connection) { const newEdge = connectionToEdge(connection); store.dispatch(edgesChanged([{ type: 'add', item: newEdge }])); + + const resolvedSource = resolvePendingConnectionSource(pendingConnection, nodes, edges, templates); + const sourceNode = resolvedSource + ? nodes.find((candidate) => candidate.id === resolvedSource.nodeId) + : nodes.find((candidate) => candidate.id === source); + if ( + newEdge.type === 'default' && + node.data.type === 'for_return' && + sourceNode && + isInvocationNode(sourceNode) && + sourceNode.data.type === 'for' && + resolvedSource?.outputScope === 'iteration' && + !edges.some((edge) => edge.type === 'loop_linkage' && edge.source === sourceNode.id) + ) { + store.dispatch( + edgesChanged([ + { + type: 'add', + item: connectionToEdge({ + source: sourceNode.id, + sourceHandle: LOOP_LINKAGE_FIELD, + target: node.id, + targetHandle: LOOP_LINKAGE_FIELD, + }), + }, + ]) + ); + } } } }, @@ -343,6 +373,161 @@ const filter = memoize( (item: FilterableItem, searchTerm: string) => `${item.type}-${searchTerm}` ); +type PendingConnectionContext = { + nodes: AnyNode[]; + edges: AnyEdge[]; + templates: Templates; +}; + +const isForIterationOutputConnection = ( + pendingConnection: PendingConnection | null, + context?: PendingConnectionContext +) => { + if (!pendingConnection || pendingConnection.handleType !== 'source') { + return false; + } + + const resolvedSource = context + ? resolvePendingConnectionSource(pendingConnection, context.nodes, context.edges, context.templates) + : null; + if (resolvedSource && context) { + const sourceNode = context.nodes.find((node) => node.id === resolvedSource.nodeId); + return ( + isInvocationNode(sourceNode) && + sourceNode.data.type === 'for' && + (resolvedSource.outputScope === 'iteration' || + (resolvedSource.nodeId === pendingConnection.nodeId && + pendingConnection.fieldTemplate.fieldKind === 'output' && + pendingConnection.fieldTemplate.output_scope === 'iteration')) + ); + } + + return ( + pendingConnection.fieldTemplate.fieldKind === 'output' && + pendingConnection.fieldTemplate.output_scope === 'iteration' + ); +}; + +export const getPendingConnectionNodeItems = ( + templatesArray: InvocationTemplate[], + pendingConnection: PendingConnection, + searchTerm: string, + context?: PendingConnectionContext +): NodeCommandItemData[] => { + const items: NodeCommandItemData[] = []; + + for (const template of templatesArray) { + if (!filter(template, searchTerm)) { + continue; + } + + if ( + pendingConnection.handleId === LOOP_LINKAGE_FIELD && + template.type !== (pendingConnection.handleType === 'source' ? 'for_return' : 'for') + ) { + continue; + } + + const candidateFields = pendingConnection.handleType === 'source' ? template.inputs : template.outputs; + for (const fieldTemplate of Object.values(candidateFields)) { + const sourceType = + pendingConnection.handleType === 'source' ? pendingConnection.fieldTemplate.type : fieldTemplate.type; + const targetType = + pendingConnection.handleType === 'target' ? pendingConnection.fieldTemplate.type : fieldTemplate.type; + + if (validateConnectionTypes(sourceType, targetType)) { + items.push({ + label: template.title, + value: template.type, + description: template.description, + classification: template.classification, + nodePack: template.nodePack, + category: template.category, + }); + break; + } + } + } + + return sortNodeCommandItems(items, searchTerm, pendingConnection, context); +}; + +export const sortNodeCommandItems = ( + items: NodeCommandItemData[], + searchTerm: string, + pendingConnection: PendingConnection | null, + context?: PendingConnectionContext +): NodeCommandItemData[] => { + const sortedItems = [...items]; + const shouldPromoteForReturn = isForIterationOutputConnection(pendingConnection, context); + const lowerSearch = searchTerm.toLowerCase(); + + sortedItems.sort((a, b) => { + // Contextual ForReturn priority is a hard first key, including when For is an exact title match. + if (shouldPromoteForReturn) { + if (a.value === 'for_return' && b.value !== 'for_return') { + return -1; + } + if (a.value !== 'for_return' && b.value === 'for_return') { + return 1; + } + } + + if (searchTerm) { + const aExact = a.label.toLowerCase() === lowerSearch; + const bExact = b.label.toLowerCase() === lowerSearch; + if (aExact && !bExact) { + return -1; + } + if (!aExact && bExact) { + return 1; + } + } + + return 0; + }); + + return sortedItems; +}; + +export const sortNodeCommandItemGroups = ( + groups: [string, NodeCommandItemData[]][], + searchTerm: string, + shouldPromoteForReturn: boolean +): [string, NodeCommandItemData[]][] => { + const lowerSearch = searchTerm.toLowerCase(); + return [...groups].sort(([a, aItems], [b, bItems]) => { + if (shouldPromoteForReturn) { + const aHasForReturn = aItems.some((item) => item.value === 'for_return'); + const bHasForReturn = bItems.some((item) => item.value === 'for_return'); + if (aHasForReturn && !bHasForReturn) { + return -1; + } + if (!aHasForReturn && bHasForReturn) { + return 1; + } + } + + if (searchTerm) { + const aHasExact = aItems.some((item) => item.label.toLowerCase() === lowerSearch); + const bHasExact = bItems.some((item) => item.label.toLowerCase() === lowerSearch); + if (aHasExact && !bHasExact) { + return -1; + } + if (!aHasExact && bHasExact) { + return 1; + } + } + if (a === 'other') { + return 1; + } + if (b === 'other') { + return -1; + } + return a.localeCompare(b); + }); +}; + const categoryItemSx: SystemStyleObject = { cursor: 'pointer', userSelect: 'none', @@ -396,7 +581,13 @@ const NodeCommandList = memo( }) => { const { t } = useTranslation(); const templatesArray = useStore($templatesArray); + const templates = useStore($templates); const pendingConnection = useStore($pendingConnection); + const { nodes, edges } = useAppSelector(selectNodesSlice); + const pendingConnectionContext = useMemo( + () => ({ nodes, edges, templates }), + [nodes, edges, templates] + ); const shouldGroupNodesByCategory = useAppSelector(selectShouldGroupNodesByCategory); const currentImageFilterItem = useMemo( () => ({ @@ -454,50 +645,22 @@ const NodeCommandList = memo( } } } else { - for (const template of templatesArray) { - if (filter(template, searchTerm)) { - const candidateFields = pendingConnection.handleType === 'source' ? template.inputs : template.outputs; - - for (const [_fieldName, fieldTemplate] of objectEntries(candidateFields)) { - const sourceType = - pendingConnection.handleType === 'source' ? pendingConnection.fieldTemplate.type : fieldTemplate.type; - const targetType = - pendingConnection.handleType === 'target' ? pendingConnection.fieldTemplate.type : fieldTemplate.type; - - if (validateConnectionTypes(sourceType, targetType)) { - _items.push({ - label: template.title, - value: template.type, - description: template.description, - classification: template.classification, - nodePack: template.nodePack, - category: template.category, - }); - break; - } - } - } - } + _items.push( + ...getPendingConnectionNodeItems(templatesArray, pendingConnection, searchTerm, pendingConnectionContext) + ); } - // Sort exact title matches to the top when searching - if (searchTerm) { - const lowerSearch = searchTerm.toLowerCase(); - _items.sort((a, b) => { - const aExact = a.label.toLowerCase() === lowerSearch; - const bExact = b.label.toLowerCase() === lowerSearch; - if (aExact && !bExact) { - return -1; - } - if (!aExact && bExact) { - return 1; - } - return 0; - }); - } + return sortNodeCommandItems(_items, searchTerm, pendingConnection, pendingConnectionContext); + }, [ + pendingConnection, + templatesArray, + pendingConnectionContext, + searchTerm, + currentImageFilterItem, + notesFilterItem, + ]); - return _items; - }, [pendingConnection, templatesArray, searchTerm, currentImageFilterItem, notesFilterItem]); + const shouldPromoteForReturn = isForIterationOutputConnection(pendingConnection, pendingConnectionContext); const groupedItems = useMemo(() => { const groups: Record = {}; @@ -508,29 +671,8 @@ const NodeCommandList = memo( } groups[cat].push(item); } - // Sort categories alphabetically, but put "other" last. - // When searching, prioritize categories that contain an exact title match. - const lowerSearch = searchTerm.toLowerCase(); - return Object.entries(groups).sort(([a, aItems], [b, bItems]) => { - if (searchTerm) { - const aHasExact = aItems.some((item) => item.label.toLowerCase() === lowerSearch); - const bHasExact = bItems.some((item) => item.label.toLowerCase() === lowerSearch); - if (aHasExact && !bHasExact) { - return -1; - } - if (!aHasExact && bHasExact) { - return 1; - } - } - if (a === 'other') { - return 1; - } - if (b === 'other') { - return -1; - } - return a.localeCompare(b); - }); - }, [items, searchTerm]); + return sortNodeCommandItemGroups(Object.entries(groups), searchTerm, shouldPromoteForReturn); + }, [items, searchTerm, shouldPromoteForReturn]); // When searching, auto-expand all categories; when not searching, use manual state const isSearching = searchTerm.length > 0; @@ -566,7 +708,10 @@ const NodeCommandList = memo( )} {groupedItems.map(([category, categoryItems]) => { - const isExpanded = isSearching || expandedCategories.has(category); + const isExpanded = + isSearching || + expandedCategories.has(category) || + (shouldPromoteForReturn && categoryItems.some((item) => item.value === 'for_return')); return ( diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx index 697d1a1182d..daa7147dafb 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx @@ -48,7 +48,10 @@ import { selectNodes, selectNodesSlice, } from 'features/nodes/store/selectors'; -import { getConnectorDeletionSpliceConnections } from 'features/nodes/store/util/connectorTopology'; +import { + getConnectorDeletionSpliceConnections, + getEdgesWithLoopLinkageAliases, +} from 'features/nodes/store/util/connectorTopology'; import { connectionToEdge } from 'features/nodes/store/util/reactFlowUtil'; import { validateConnection } from 'features/nodes/store/util/validateConnection'; import { selectSelectionMode, selectShouldSnapToGrid } from 'features/nodes/store/workflowSettingsSlice'; @@ -65,6 +68,8 @@ import { PiPlugsConnectedBold, PiTrashBold } from 'react-icons/pi'; import CustomConnectionLine from './connectionLines/CustomConnectionLine'; import InvocationCollapsedEdge from './edges/InvocationCollapsedEdge'; import InvocationDefaultEdge from './edges/InvocationDefaultEdge'; +import InvocationLoopLinkageEdge from './edges/InvocationLoopLinkageEdge'; +import LoopBodyBoundaryOverlay from './LoopBodyBoundaryOverlay'; import ConnectorNode from './nodes/Connector/ConnectorNode'; import CurrentImageNode from './nodes/CurrentImage/CurrentImageNode'; import InvocationNodeWrapper from './nodes/Invocation/InvocationNodeWrapper'; @@ -76,6 +81,7 @@ import { isWorkflowHotkeyEnabled, shouldIgnoreWorkflowCopyHotkey } from './workf const edgeTypes = { collapsed: InvocationCollapsedEdge, default: InvocationDefaultEdge, + loop_linkage: InvocationLoopLinkageEdge, } as const; const nodeTypes = { @@ -288,7 +294,7 @@ export const Flow = memo(() => { const onEdgeDoubleClick = useCallback>( (event, edge) => { - if (edge.type !== 'default' || edge.hidden) { + if (edge.hidden || (edge.type !== 'default' && edge.type !== 'loop_linkage')) { return; } const flow = $flow.get(); @@ -371,7 +377,7 @@ export const Flow = memo(() => { const renderedNodes = useMemo(() => nodes, [nodes]); - const renderedEdges = useMemo(() => edges, [edges]); + const renderedEdges = useMemo(() => getEdgesWithLoopLinkageAliases(nodes, edges), [edges, nodes]); const contextMenuPosition = contextMenuState ? { x: contextMenuState.pageX, y: contextMenuState.pageY } : null; const contextMenuKey = contextMenuPosition ? `${contextMenuPosition.x}-${contextMenuPosition.y}` : 'closed'; @@ -382,6 +388,7 @@ export const Flow = memo(() => { viewport={viewport} renderedNodes={renderedNodes} renderedEdges={renderedEdges} + boundaryEdges={edges} onInit={onInit} onMouseMove={onMouseMove} onNodesChange={onNodesChange} @@ -433,6 +440,7 @@ type FlowSurfaceProps = { viewport: ReactFlowProps['defaultViewport']; renderedNodes: AnyNode[]; renderedEdges: AnyEdge[]; + boundaryEdges: AnyEdge[]; onInit: OnInit; onMouseMove: (event: MouseEvent) => void; onNodesChange: OnNodesChange; @@ -458,6 +466,7 @@ const FlowSurface = memo((props: FlowSurfaceProps) => { viewport, renderedNodes, renderedEdges, + boundaryEdges, onInit, onMouseMove, onNodesChange, @@ -523,6 +532,7 @@ const FlowSurface = memo((props: FlowSurfaceProps) => { noPanClassName={NO_PAN_CLASS} > + ); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/LoopBodyBoundaryOverlay.test.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/LoopBodyBoundaryOverlay.test.tsx new file mode 100644 index 00000000000..e0eadcc6ad2 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/flow/LoopBodyBoundaryOverlay.test.tsx @@ -0,0 +1,129 @@ +// @vitest-environment happy-dom +import { buildEdge, buildLoopLinkageEdge, buildNode, for_loop, for_return } from 'features/nodes/store/util/testUtils'; +import type { AnyEdge, AnyNode } from 'features/nodes/types/invocation'; +import type { ReactNode } from 'react'; +import { act } from 'react'; +import type { Root } from 'react-dom/client'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import LoopBodyBoundaryOverlay from './LoopBodyBoundaryOverlay'; + +const flowMocks = vi.hoisted(() => ({ + nodes: [] as AnyNode[], + getNodesBounds: vi.fn(() => ({ x: 10, y: 20, width: 100, height: 200 })), +})); + +vi.mock('@xyflow/react', () => ({ + useNodes: () => flowMocks.nodes, + useReactFlow: () => ({ getNodesBounds: flowMocks.getNodesBounds }), + ViewportPortal: ({ children }: { children: ReactNode }) => children, +})); + +vi.mock('@invoke-ai/ui-library', () => ({ + Box: ({ children, ...props }: { children: ReactNode; [key: string]: unknown }) => { + const domProps = Object.fromEntries( + Object.entries(props).filter(([key]) => key === 'title' || key.startsWith('aria-') || key.startsWith('data-')) + ); + return
{children}
; + }, + Text: ({ children }: { children: ReactNode }) => {children}, +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + if (key === 'nodes.forLoopBodyBoundary') { + return 'For loop body'; + } + if (key === 'nodes.forLoopBodyBoundaryStatus.invalid_linkage') { + return 'invalid loop linkage'; + } + return key; + }, + }), +})); + +declare global { + var IS_REACT_ACT_ENVIRONMENT: boolean; +} +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const setNodeId = (node: AnyNode, id: string): AnyNode => { + node.id = id; + node.data.id = id; + return node; +}; + +const edge = (source: string, sourceHandle: string, target: string, targetHandle: string): AnyEdge => + buildEdge(source, sourceHandle, target, targetHandle); + +describe('LoopBodyBoundaryOverlay', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + flowMocks.nodes = []; + flowMocks.getNodesBounds.mockClear(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + const renderBoundary = (withLinkage: boolean) => { + const forNode = setNodeId(buildNode(for_loop), 'for'); + const returnNode = setNodeId(buildNode(for_return), 'return'); + flowMocks.nodes = [forNode, returnNode]; + + act(() => { + root.render( + + ); + }); + }; + + it('renders the loop body label for a valid linkage', () => { + renderBoundary(true); + + const boundary = container.querySelector('[data-loop-body-boundary="for"]'); + expect(boundary?.getAttribute('aria-label')).toBe('For loop body'); + expect(boundary?.textContent).toBe('For loop body'); + expect(boundary?.getAttribute('data-loop-body-status')).toBe('complete'); + }); + + it('labels a body with missing linkage', () => { + renderBoundary(false); + + const boundary = container.querySelector('[data-loop-body-boundary="for"]'); + expect(boundary?.getAttribute('aria-label')).toBe( + 'For loop body - nodes.forLoopBodyBoundaryStatus.missing_linkage' + ); + expect(boundary?.getAttribute('data-loop-body-status')).toBe('missing_linkage'); + }); + + it('includes validation status for a detached linkage', () => { + const forNode = setNodeId(buildNode(for_loop), 'for'); + const returnNode = setNodeId(buildNode(for_return), 'return'); + flowMocks.nodes = [forNode, returnNode]; + + act(() => { + root.render(); + }); + + const boundary = container.querySelector('[data-loop-body-boundary="for"]'); + expect(boundary?.getAttribute('aria-label')).toBe('For loop body - invalid loop linkage'); + expect(boundary?.getAttribute('data-loop-body-status')).toBe('invalid_linkage'); + }); +}); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/LoopBodyBoundaryOverlay.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/LoopBodyBoundaryOverlay.tsx new file mode 100644 index 00000000000..abb661d637a --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/flow/LoopBodyBoundaryOverlay.tsx @@ -0,0 +1,84 @@ +import { Box, Text } from '@invoke-ai/ui-library'; +import { useNodes, useReactFlow, ViewportPortal } from '@xyflow/react'; +import type { AnyEdge, AnyNode } from 'features/nodes/types/invocation'; +import { getForLoopBodyBoundaries, type LoopBodyBoundaryStatus } from 'features/nodes/util/graph/loopBodyBoundary'; +import { memo, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +const BOUNDARY_PADDING = 24; + +const getStatusColor = (status: LoopBodyBoundaryStatus) => { + if (status === 'complete') { + return { + border: 'var(--invoke-colors-teal-400)', + text: 'var(--invoke-colors-teal-200)', + }; + } + return { + border: 'var(--invoke-colors-orange-400)', + text: 'var(--invoke-colors-orange-200)', + }; +}; + +type Props = { + edges: AnyEdge[]; +}; + +const LoopBodyBoundaryOverlay = ({ edges }: Props) => { + const { t } = useTranslation(); + const nodes = useNodes(); + const { getNodesBounds } = useReactFlow(); + + const boundaries = useMemo(() => getForLoopBodyBoundaries(nodes, edges), [edges, nodes]); + + return ( + + {boundaries.map((boundary) => { + const bounds = getNodesBounds(boundary.bodyNodeIds); + if (bounds.width <= 0 || bounds.height <= 0) { + return null; + } + + const colors = getStatusColor(boundary.status); + const bodyLabel = t('nodes.forLoopBodyBoundary'); + const statusLabel = + boundary.status === 'complete' ? '' : t(`nodes.forLoopBodyBoundaryStatus.${boundary.status}`); + const label = statusLabel ? `${bodyLabel} - ${statusLabel}` : bodyLabel; + + return ( + + + {label} + + + ); + })} + + ); +}; + +export default memo(LoopBodyBoundaryOverlay); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/edges/InvocationLoopLinkageEdge.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/edges/InvocationLoopLinkageEdge.tsx new file mode 100644 index 00000000000..7e91962a500 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/flow/edges/InvocationLoopLinkageEdge.tsx @@ -0,0 +1,65 @@ +import type { SystemStyleObject } from '@invoke-ai/ui-library'; +import { chakra } from '@invoke-ai/ui-library'; +import type { EdgeProps } from '@xyflow/react'; +import { BaseEdge, getBezierPath } from '@xyflow/react'; +import { useAppSelector } from 'app/store/storeHooks'; +import { buildSelectAreConnectedNodesSelected } from 'features/nodes/components/flow/edges/util/buildEdgeSelectors'; +import { selectShouldAnimateEdges } from 'features/nodes/store/workflowSettingsSlice'; +import type { LoopLinkageInvocationNodeEdge } from 'features/nodes/types/invocation'; +import { memo, useMemo } from 'react'; + +const ChakraBaseEdge = chakra(BaseEdge); + +const edgeSx: SystemStyleObject = { + strokeWidth: '2px !important', + stroke: 'green.500 !important', + strokeDasharray: '6 4', + opacity: '0.75 !important', + '&[data-selected="true"]': { + opacity: '1 !important', + }, + '&[data-should-animate-edges="true"]': { + animation: 'dashdraw 0.5s linear infinite !important', + }, +}; + +const InvocationLoopLinkageEdge = ({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + markerEnd, + selected = false, + source, + target, +}: EdgeProps) => { + const shouldAnimateEdges = useAppSelector(selectShouldAnimateEdges); + const selectAreConnectedNodesSelected = useMemo( + () => buildSelectAreConnectedNodesSelected(source, target), + [source, target] + ); + const areConnectedNodesSelected = useAppSelector(selectAreConnectedNodesSelected); + const [edgePath] = getBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + + return ( + + ); +}; + +export default memo(InvocationLoopLinkageEdge); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Connector/ConnectorNode.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Connector/ConnectorNode.tsx index bc6867a9b9c..8f5a515b3e5 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Connector/ConnectorNode.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Connector/ConnectorNode.tsx @@ -120,9 +120,15 @@ const ConnectorNode = ({ id, selected }: NodeProps>) => justifyContent="center" borderRadius="full" bg={selected ? 'base.650' : 'base.700'} + data-connector-node-body="true" > - + { ); }); MissingFields.displayName = 'MissingFields'; - -const OutputFields = memo(({ nodeId }: { nodeId: string }) => { - const fieldNames = useOutputFieldNames(); - return ( - <> - {fieldNames.map((fieldName, i) => ( - - - - - - ))} - - ); -}); -OutputFields.displayName = 'OutputFields'; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeInfoIcon.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeInfoIcon.tsx index a257326f929..9368073f19a 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeInfoIcon.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeInfoIcon.tsx @@ -15,7 +15,7 @@ interface Props { export const InvocationNodeInfoIcon = memo(({ nodeId }: Props) => { return ( } placement="top" shouldWrapChildren> - + ); }); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeStatusIndicator.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeStatusIndicator.tsx index a740a2ee3df..2786a127db3 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeStatusIndicator.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeStatusIndicator.tsx @@ -30,7 +30,14 @@ const InvocationNodeStatusIndicator = ({ nodeId }: Props) => { return ( } placement="top"> - + diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/OutputFields.test.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/OutputFields.test.tsx new file mode 100644 index 00000000000..54f8f467e69 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/OutputFields.test.tsx @@ -0,0 +1,62 @@ +import type { OutputFieldNamesByScope } from 'features/nodes/util/node/getOutputFieldNamesByScope'; +import type { ReactNode } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; + +import { OutputFields } from './OutputFields'; + +const mocks = vi.hoisted(() => ({ + fieldNames: { + all: ['item', 'output_collection'], + unscoped: [], + iteration: ['item'], + final: ['output_collection'], + } as OutputFieldNamesByScope, +})); + +vi.mock('@invoke-ai/ui-library', () => ({ + GridItem: ({ children }: { children: ReactNode }) =>
{children}
, + Text: ({ children }: { children: ReactNode }) => {children}, +})); + +vi.mock('features/nodes/hooks/useOutputFieldNames', () => ({ + useOutputFieldNamesByScope: () => mocks.fieldNames, +})); + +vi.mock('features/nodes/components/flow/nodes/Invocation/fields/OutputFieldGate', () => ({ + OutputFieldGate: ({ children }: { children: ReactNode }) => children, +})); + +vi.mock('features/nodes/components/flow/nodes/Invocation/fields/OutputFieldNodesEditorView', () => ({ + OutputFieldNodesEditorView: ({ fieldName }: { fieldName: string }) => {fieldName}, +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +describe(OutputFields.name, () => { + it('renders scoped outputs under localized section headings', () => { + const html = renderToStaticMarkup(); + + expect(html).toContain('nodes.iterationOutputs'); + expect(html).toContain('nodes.finalOutputs'); + expect(html.indexOf('nodes.iterationOutputs')).toBeLessThan(html.indexOf('data-field="item"')); + expect(html.indexOf('nodes.finalOutputs')).toBeLessThan(html.indexOf('data-field="output_collection"')); + }); + + it('renders ordinary outputs without scope headings', () => { + mocks.fieldNames = { + all: ['value'], + unscoped: ['value'], + iteration: [], + final: [], + }; + + const html = renderToStaticMarkup(); + + expect(html).toContain('data-field="value"'); + expect(html).not.toContain('nodes.iterationOutputs'); + expect(html).not.toContain('nodes.finalOutputs'); + }); +}); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/OutputFields.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/OutputFields.tsx new file mode 100644 index 00000000000..a46dbf2a7ea --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/OutputFields.tsx @@ -0,0 +1,33 @@ +import { GridItem, Text } from '@invoke-ai/ui-library'; +import { OutputFieldGate } from 'features/nodes/components/flow/nodes/Invocation/fields/OutputFieldGate'; +import { OutputFieldNodesEditorView } from 'features/nodes/components/flow/nodes/Invocation/fields/OutputFieldNodesEditorView'; +import { useOutputFieldNamesByScope } from 'features/nodes/hooks/useOutputFieldNames'; +import { getOutputFieldRows } from 'features/nodes/util/node/getOutputFieldRows'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +export const OutputFields = memo(({ nodeId }: { nodeId: string }) => { + const { t } = useTranslation(); + const fieldNames = useOutputFieldNamesByScope(); + const rows = getOutputFieldRows(fieldNames); + return ( + <> + {rows.map((row, i) => + row.type === 'header' ? ( + + + {row.scope === 'iteration' ? t('nodes.iterationOutputs') : t('nodes.finalOutputs')} + + + ) : ( + + + + + + ) + )} + + ); +}); +OutputFields.displayName = 'OutputFields'; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldTitle.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldTitle.tsx index 396b05c2ac2..40c2db08ce3 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldTitle.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldTitle.tsx @@ -100,6 +100,7 @@ export const InputFieldTitle = memo((props: Props) => { className={NO_FIT_ON_DOUBLE_CLICK_CLASS} sx={labelSx} noOfLines={1} + data-node-input-field-title="true" data-is-invalid={isInvalid} data-is-disabled={isDisabled} data-is-added-to-form={isAddedToForm} diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts b/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts index eb8eeb052ab..90286610b70 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts @@ -83,10 +83,12 @@ export const useAutoLayout = (): (() => void) => { }); }); - let edgesToLayout: Edge[] = edges; + let edgesToLayout: Edge[] = edges.filter((edge) => edge.type !== 'loop_linkage'); if (isLayoutSelection) { const nodesToLayoutIds = new Set(nodesToLayout.map((n) => n.id)); - edgesToLayout = edges.filter((edge) => nodesToLayoutIds.has(edge.source) && nodesToLayoutIds.has(edge.target)); + edgesToLayout = edges.filter( + (edge) => edge.type !== 'loop_linkage' && nodesToLayoutIds.has(edge.source) && nodesToLayoutIds.has(edge.target) + ); } edgesToLayout.forEach((edge) => { diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useNodeCopyPaste.ts b/invokeai/frontend/web/src/features/nodes/hooks/useNodeCopyPaste.ts index 5c86a0b8f08..786724abb6f 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useNodeCopyPaste.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useNodeCopyPaste.ts @@ -16,7 +16,9 @@ import { import { selectNodesSlice } from 'features/nodes/store/selectors'; import { findUnoccupiedPosition } from 'features/nodes/store/util/findUnoccupiedPosition'; import { validateConnection } from 'features/nodes/store/util/validateConnection'; +import { LOOP_LINKAGE_FIELD } from 'features/nodes/types/constants'; import type { AnyEdge, AnyNode } from 'features/nodes/types/invocation'; +import { isInvocationNode } from 'features/nodes/types/invocation'; import { t } from 'i18next'; import { v4 as uuidv4 } from 'uuid'; @@ -134,6 +136,39 @@ const _pasteSelection = (withEdgesToCopiedNodes?: boolean) => { ); return; } + } else if (e.type === 'loop_linkage') { + const sourceNode = validationNodes.find((n) => n.id === e.source); + const targetNode = validationNodes.find((n) => n.id === e.target); + if ( + !sourceNode || + !targetNode || + !isInvocationNode(sourceNode) || + !isInvocationNode(targetNode) || + sourceNode.data.type !== 'for' || + targetNode.data.type !== 'for_return' || + e.sourceHandle !== LOOP_LINKAGE_FIELD || + e.targetHandle !== LOOP_LINKAGE_FIELD + ) { + log.warn( + { + edgeId: e.id, + source: e.source, + sourceHandle: e.sourceHandle, + target: e.target, + targetHandle: e.targetHandle, + }, + `Invalid loop linkage edge, cannot paste` + ); + return; + } + if ( + validationEdges.some( + (edge) => edge.type === 'loop_linkage' && (edge.source === e.source || edge.target === e.target) + ) + ) { + log.warn({ edgeId: e.id, source: e.source, target: e.target }, `Duplicate loop linkage edge, cannot paste`); + return; + } } else if (e.type === 'default') { const { type, source, sourceHandle, target, targetHandle } = e; @@ -165,7 +200,7 @@ const _pasteSelection = (withEdgesToCopiedNodes?: boolean) => { return; } } else { - // All our edges should be either "collapsed" or "default" type, so if we get here, something is wrong + // All our edges should be either "collapsed", "default", or "loop_linkage" type. const { type } = e; log.warn({ edge: { type } }, `Invalid edge type, cannot paste`); return; diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useOutputFieldNames.ts b/invokeai/frontend/web/src/features/nodes/hooks/useOutputFieldNames.ts index 81e89b0fe7d..b58bbab0ac3 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useOutputFieldNames.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useOutputFieldNames.ts @@ -1,17 +1,22 @@ import { createSelector } from '@reduxjs/toolkit'; import { useAppSelector } from 'app/store/storeHooks'; import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context'; -import { getSortedFilteredFieldNames } from 'features/nodes/util/node/getSortedFilteredFieldNames'; +import { + getOutputFieldNamesByScope, + type OutputFieldNamesByScope, +} from 'features/nodes/util/node/getOutputFieldNamesByScope'; import { useMemo } from 'react'; -export const useOutputFieldNames = (): string[] => { +export const useOutputFieldNamesByScope = (): OutputFieldNamesByScope => { const ctx = useInvocationNodeContext(); const selector = useMemo( () => createSelector([ctx.selectNodeTemplateOrThrow], (template) => - getSortedFilteredFieldNames(Object.values(template.outputs)) + getOutputFieldNamesByScope(Object.values(template.outputs)) ), [ctx] ); return useAppSelector(selector); }; + +export const useOutputFieldNames = (): string[] => useOutputFieldNamesByScope().all; diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.test.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.test.ts index 6703317f2c0..5ceb678d131 100644 --- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.test.ts +++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.test.ts @@ -7,14 +7,25 @@ import { describe, expect, it } from 'vitest'; import { callSavedWorkflowDynamicFieldsChanged, connectorInserted, + edgesChanged, fieldIntegerValueChanged, fieldStringValueChanged, fieldValueReset, + nodeIsOpenChanged, nodesChanged, nodesSliceConfig, } from './nodesSlice'; import { CONNECTOR_INPUT_HANDLE, CONNECTOR_OUTPUT_HANDLE } from './util/connectorTopology'; -import { add, buildEdge, buildNode, sub, templates } from './util/testUtils'; +import { + add, + buildEdge, + buildLoopLinkageEdge, + buildNode, + for_loop, + for_return, + sub, + templates, +} from './util/testUtils'; const callSavedWorkflowTemplate = templates.call_saved_workflow; const addTemplate = templates.add; @@ -498,6 +509,30 @@ describe('nodesSlice connector actions', () => { ]); }); + it('splits a direct loop linkage into a connector alias when inserting a connector', () => { + const forNode = buildNode(for_loop); + const returnNode = buildNode(for_return); + const connector = buildFixedConnectorNode('connector-1'); + const directEdge = buildLoopLinkageEdge(forNode.id, returnNode.id); + + const initialState = deepClone(nodesSliceConfig.slice.reducer(undefined, { type: 'test/init' })); + initialState.nodes = [forNode, returnNode]; + initialState.edges = [directEdge]; + + const nextState = nodesSliceConfig.slice.reducer( + initialState, + connectorInserted({ + edgeId: directEdge.id, + connector, + }) + ); + + expect(nextState.edges).toEqual([ + buildEdge(forNode.id, 'loop_linkage', connector.id, CONNECTOR_INPUT_HANDLE), + buildEdge(connector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), + ]); + }); + it('splices connector outputs back to the resolved upstream source when removed', () => { const source = buildNode(add); const target = buildNode(sub); @@ -612,3 +647,362 @@ describe('nodesSlice connector actions', () => { expect(nextState.edges).toEqual([buildEdge(source.id, 'value', target.id, 'a')]); }); }); + +describe('nodesSlice loop boundary actions', () => { + it('stores loop linkage as an explicit edge', () => { + const forNode = buildNode(for_loop); + const returnNode = buildNode(for_return); + const initialState = deepClone(nodesSliceConfig.slice.reducer(undefined, { type: 'test/init' })); + initialState.nodes = [forNode, returnNode]; + + const nextState = nodesSliceConfig.slice.reducer( + initialState, + edgesChanged([{ type: 'add', item: buildLoopLinkageEdge(forNode.id, returnNode.id) }]) + ); + expect(nextState.edges).toEqual([ + expect.objectContaining({ + type: 'loop_linkage', + source: forNode.id, + sourceHandle: 'loop_linkage', + target: returnNode.id, + targetHandle: 'loop_linkage', + }), + ]); + }); + + it('removes loop linkage when either boundary node is removed', () => { + const oldForNode = buildNode(for_loop); + const returnNode = buildNode(for_return); + + const initialState = deepClone(nodesSliceConfig.slice.reducer(undefined, { type: 'test/init' })); + initialState.nodes = [oldForNode, returnNode]; + initialState.edges = [buildLoopLinkageEdge(oldForNode.id, returnNode.id)]; + + const nextState = nodesSliceConfig.slice.reducer( + initialState, + nodesChanged([{ type: 'remove', id: oldForNode.id }]) + ); + expect(nextState.edges).toEqual([]); + }); + + it('removes all connector alias edges when a For boundary is removed', () => { + const forNode = buildNode(for_loop); + const connector = buildFixedConnectorNode('connector-1'); + const returnNode = buildNode(for_return); + + const initialState = deepClone(nodesSliceConfig.slice.reducer(undefined, { type: 'test/init' })); + initialState.nodes = [forNode, connector, returnNode]; + initialState.edges = [ + buildEdge(forNode.id, 'loop_linkage', connector.id, CONNECTOR_INPUT_HANDLE), + buildEdge(connector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), + ]; + + const nextState = nodesSliceConfig.slice.reducer(initialState, nodesChanged([{ type: 'remove', id: forNode.id }])); + + expect(nextState.edges).toEqual([]); + }); + + it('removes all connector alias edges when a For boundary is replaced by another node type', () => { + const forNode = buildNode(for_loop); + const replacement = buildNode(add); + const connector = buildFixedConnectorNode('connector-1'); + const returnNode = buildNode(for_return); + replacement.id = forNode.id; + replacement.data.id = forNode.id; + + const initialState = deepClone(nodesSliceConfig.slice.reducer(undefined, { type: 'test/init' })); + initialState.nodes = [forNode, connector, returnNode]; + initialState.edges = [ + buildEdge(forNode.id, 'loop_linkage', connector.id, CONNECTOR_INPUT_HANDLE), + buildEdge(connector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), + ]; + + const nextState = nodesSliceConfig.slice.reducer( + initialState, + nodesChanged([ + { type: 'remove', id: forNode.id }, + { type: 'add', item: replacement }, + ]) + ); + + expect(nextState.edges).toEqual([]); + }); + + it('removes an incomplete connector alias when a For boundary is replaced by another node type', () => { + const forNode = buildNode(for_loop); + const replacement = buildNode(add); + const connector = buildFixedConnectorNode('connector-1'); + replacement.id = forNode.id; + replacement.data.id = forNode.id; + + const initialState = deepClone(nodesSliceConfig.slice.reducer(undefined, { type: 'test/init' })); + initialState.nodes = [forNode, connector]; + initialState.edges = [buildEdge(forNode.id, 'loop_linkage', connector.id, CONNECTOR_INPUT_HANDLE)]; + + const nextState = nodesSliceConfig.slice.reducer( + initialState, + nodesChanged([ + { type: 'remove', id: forNode.id }, + { type: 'add', item: replacement }, + ]) + ); + + expect(nextState.edges).toEqual([]); + }); + + it('splices a connector loop linkage alias into a direct edge when removed', () => { + const forNode = buildNode(for_loop); + const connector = buildFixedConnectorNode('connector-1'); + const returnNode = buildNode(for_return); + + const initialState = deepClone(nodesSliceConfig.slice.reducer(undefined, { type: 'test/init' })); + initialState.nodes = [forNode, connector, returnNode]; + initialState.edges = [ + buildEdge(forNode.id, 'loop_linkage', connector.id, CONNECTOR_INPUT_HANDLE), + buildEdge(connector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), + ]; + + const nextState = nodesSliceConfig.slice.reducer( + initialState, + nodesChanged([{ type: 'remove', id: connector.id }]) + ); + + expect(nextState.edges).toEqual([ + expect.objectContaining({ + type: 'loop_linkage', + source: forNode.id, + sourceHandle: 'loop_linkage', + target: returnNode.id, + targetHandle: 'loop_linkage', + }), + ]); + }); + + it('splices a removed terminal loop linkage connector to the preceding connector', () => { + const forNode = buildNode(for_loop); + const firstConnector = buildFixedConnectorNode('connector-a'); + const terminalConnector = buildFixedConnectorNode('connector-b'); + const returnNode = buildNode(for_return); + + const initialState = deepClone(nodesSliceConfig.slice.reducer(undefined, { type: 'test/init' })); + initialState.nodes = [forNode, firstConnector, terminalConnector, returnNode]; + initialState.edges = [ + buildEdge(forNode.id, 'loop_linkage', firstConnector.id, CONNECTOR_INPUT_HANDLE), + buildEdge(firstConnector.id, CONNECTOR_OUTPUT_HANDLE, terminalConnector.id, CONNECTOR_INPUT_HANDLE), + buildEdge(terminalConnector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), + ]; + + const nextState = nodesSliceConfig.slice.reducer( + initialState, + nodesChanged([{ type: 'remove', id: terminalConnector.id }]) + ); + + expect(nextState.nodes.map((node) => node.id)).toEqual([forNode.id, firstConnector.id, returnNode.id]); + expect(nextState.edges).toEqual([ + buildEdge(forNode.id, 'loop_linkage', firstConnector.id, CONNECTOR_INPUT_HANDLE), + expect.objectContaining({ + type: 'default', + source: firstConnector.id, + sourceHandle: CONNECTOR_OUTPUT_HANDLE, + target: returnNode.id, + targetHandle: 'loop_linkage', + }), + ]); + }); + + it('splices a removed interior loop linkage connector to the preceding connector', () => { + const forNode = buildNode(for_loop); + const firstConnector = buildFixedConnectorNode('connector-a'); + const removedConnector = buildFixedConnectorNode('connector-b'); + const terminalConnector = buildFixedConnectorNode('connector-c'); + const returnNode = buildNode(for_return); + + const initialState = deepClone(nodesSliceConfig.slice.reducer(undefined, { type: 'test/init' })); + initialState.nodes = [forNode, firstConnector, removedConnector, terminalConnector, returnNode]; + initialState.edges = [ + buildEdge(forNode.id, 'loop_linkage', firstConnector.id, CONNECTOR_INPUT_HANDLE), + buildEdge(firstConnector.id, CONNECTOR_OUTPUT_HANDLE, removedConnector.id, CONNECTOR_INPUT_HANDLE), + buildEdge(removedConnector.id, CONNECTOR_OUTPUT_HANDLE, terminalConnector.id, CONNECTOR_INPUT_HANDLE), + buildEdge(terminalConnector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), + ]; + + const nextState = nodesSliceConfig.slice.reducer( + initialState, + nodesChanged([{ type: 'remove', id: removedConnector.id }]) + ); + + expect(nextState.nodes.map((node) => node.id)).toEqual([ + forNode.id, + firstConnector.id, + terminalConnector.id, + returnNode.id, + ]); + expect(nextState.edges).toEqual( + expect.arrayContaining([ + buildEdge(forNode.id, 'loop_linkage', firstConnector.id, CONNECTOR_INPUT_HANDLE), + buildEdge(firstConnector.id, CONNECTOR_OUTPUT_HANDLE, terminalConnector.id, CONNECTOR_INPUT_HANDLE), + buildEdge(terminalConnector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), + ]) + ); + }); + + it('splices a chain when multiple loop linkage connectors are removed together', () => { + const forNode = buildNode(for_loop); + const firstConnector = buildFixedConnectorNode('connector-a'); + const secondConnector = buildFixedConnectorNode('connector-b'); + const returnNode = buildNode(for_return); + + const initialState = deepClone(nodesSliceConfig.slice.reducer(undefined, { type: 'test/init' })); + initialState.nodes = [forNode, firstConnector, secondConnector, returnNode]; + initialState.edges = [ + buildEdge(forNode.id, 'loop_linkage', firstConnector.id, CONNECTOR_INPUT_HANDLE), + buildEdge(firstConnector.id, CONNECTOR_OUTPUT_HANDLE, secondConnector.id, CONNECTOR_INPUT_HANDLE), + buildEdge(secondConnector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), + ]; + + const nextState = nodesSliceConfig.slice.reducer( + initialState, + nodesChanged([ + { type: 'remove', id: firstConnector.id }, + { type: 'remove', id: secondConnector.id }, + ]) + ); + + expect(nextState.nodes.map((node) => node.id)).toEqual([forNode.id, returnNode.id]); + expect(nextState.edges).toEqual([ + expect.objectContaining({ + type: 'loop_linkage', + source: forNode.id, + sourceHandle: 'loop_linkage', + target: returnNode.id, + targetHandle: 'loop_linkage', + }), + ]); + }); + + it('does not create invalid edges when removing a loop linkage connector with an ordinary fanout', () => { + const forNode = buildNode(for_loop); + const connector = buildFixedConnectorNode('connector'); + const returnNode = buildNode(for_return); + const ordinaryTarget = buildNode(sub); + + const initialState = deepClone(nodesSliceConfig.slice.reducer(undefined, { type: 'test/init' })); + initialState.nodes = [forNode, connector, returnNode, ordinaryTarget]; + initialState.edges = [ + buildEdge(forNode.id, 'loop_linkage', connector.id, CONNECTOR_INPUT_HANDLE), + buildEdge(connector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), + buildEdge(connector.id, CONNECTOR_OUTPUT_HANDLE, ordinaryTarget.id, 'a'), + ]; + + const nextState = nodesSliceConfig.slice.reducer( + initialState, + nodesChanged([{ type: 'remove', id: connector.id }]) + ); + + expect(nextState.nodes.map((node) => node.id)).toEqual([forNode.id, returnNode.id, ordinaryTarget.id]); + expect(nextState.edges).toEqual([]); + }); + + it('preserves linkage when a boundary is replaced with the same node id', () => { + const forNode = buildNode(for_loop); + const replacement = buildNode(for_loop); + const returnNode = buildNode(for_return); + replacement.id = forNode.id; + replacement.data.id = forNode.id; + + const initialState = deepClone(nodesSliceConfig.slice.reducer(undefined, { type: 'test/init' })); + initialState.nodes = [forNode, returnNode]; + initialState.edges = [buildLoopLinkageEdge(forNode.id, returnNode.id)]; + + const nextState = nodesSliceConfig.slice.reducer( + initialState, + nodesChanged([ + { type: 'remove', id: forNode.id }, + { type: 'add', item: replacement }, + ]) + ); + expect(nextState.edges).toEqual([buildLoopLinkageEdge(forNode.id, returnNode.id)]); + }); + + it('removes linkage when a boundary is replaced by a different node type', () => { + const forNode = buildNode(for_loop); + const replacement = buildNode(add); + const returnNode = buildNode(for_return); + replacement.id = forNode.id; + replacement.data.id = forNode.id; + + const initialState = deepClone(nodesSliceConfig.slice.reducer(undefined, { type: 'test/init' })); + initialState.nodes = [forNode, returnNode]; + initialState.edges = [buildLoopLinkageEdge(forNode.id, returnNode.id)]; + + const nextState = nodesSliceConfig.slice.reducer( + initialState, + nodesChanged([ + { type: 'remove', id: forNode.id }, + { type: 'add', item: replacement }, + ]) + ); + + expect(nextState.edges).toEqual([]); + }); + + it('removes linkage when its ForReturn is removed', () => { + const forNode = buildNode(for_loop); + const returnNode = buildNode(for_return); + + const initialState = deepClone(nodesSliceConfig.slice.reducer(undefined, { type: 'test/init' })); + initialState.nodes = [forNode, returnNode]; + initialState.edges = [buildLoopLinkageEdge(forNode.id, returnNode.id)]; + + const nextState = nodesSliceConfig.slice.reducer( + initialState, + nodesChanged([{ type: 'remove', id: returnNode.id }]) + ); + expect(nextState.edges).toEqual([]); + }); + + it('does not collapse loop linkage when both boundary nodes are closed', () => { + const forNode = buildNode(for_loop); + const returnNode = buildNode(for_return); + returnNode.data.isOpen = false; + + const initialState = deepClone(nodesSliceConfig.slice.reducer(undefined, { type: 'test/init' })); + initialState.nodes = [forNode, returnNode]; + initialState.edges = [buildLoopLinkageEdge(forNode.id, returnNode.id)]; + + const nextState = nodesSliceConfig.slice.reducer( + initialState, + nodeIsOpenChanged({ nodeId: forNode.id, isOpen: false }) + ); + + expect(nextState.edges).toEqual([buildLoopLinkageEdge(forNode.id, returnNode.id)]); + }); + + it('does not treat loop linkage as a hidden edge of a collapsed data-flow edge', () => { + const forNode = buildNode(for_loop); + const returnNode = buildNode(for_return); + returnNode.data.isOpen = false; + const loopLinkageEdge = buildLoopLinkageEdge(forNode.id, returnNode.id); + const dataFlowEdge = buildEdge(forNode.id, 'item', returnNode.id, 'output'); + + const initialState = deepClone(nodesSliceConfig.slice.reducer(undefined, { type: 'test/init' })); + initialState.nodes = [forNode, returnNode]; + initialState.edges = [dataFlowEdge, loopLinkageEdge]; + + const closedState = nodesSliceConfig.slice.reducer( + initialState, + nodeIsOpenChanged({ nodeId: forNode.id, isOpen: false }) + ); + const collapsedEdge = closedState.edges.find((edge) => edge.type === 'collapsed'); + if (!collapsedEdge) { + throw new Error('Expected collapsed edge'); + } + + const nextState = nodesSliceConfig.slice.reducer( + closedState, + edgesChanged([{ type: 'remove', id: collapsedEdge.id }]) + ); + + expect(nextState.edges).toEqual([loopLinkageEdge]); + }); +}); diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts index 3faabbce8f0..7ca12180a6e 100644 --- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts @@ -24,11 +24,11 @@ import { type NodesState, zNodesState } from 'features/nodes/store/types'; import { CONNECTOR_INPUT_HANDLE, CONNECTOR_OUTPUT_HANDLE, - getConnectorOutputEdges, - resolveConnectorSource, + getConnectorDeletionSpliceConnections, + getLoopLinkageAliasEdgeIdsForBoundary, } from 'features/nodes/store/util/connectorTopology'; -import { connectionToEdge } from 'features/nodes/store/util/reactFlowUtil'; -import { SHARED_NODE_PROPERTIES } from 'features/nodes/types/constants'; +import { connectionToEdge, isLoopLinkageEdge } from 'features/nodes/store/util/reactFlowUtil'; +import { LOOP_LINKAGE_FIELD, SHARED_NODE_PROPERTIES } from 'features/nodes/types/constants'; import type { BoardFieldValue, BooleanFieldValue, @@ -248,37 +248,96 @@ const removeCallSavedWorkflowDynamicFieldsFromForm = ( } }; +const isValidLoopLinkageEdge = (edge: AnyEdge, nodes: AnyNode[]): boolean => { + if (!isLoopLinkageEdge(edge)) { + return true; + } + + const sourceNode = nodes.find((node) => node.id === edge.source); + const targetNode = nodes.find((node) => node.id === edge.target); + return Boolean( + sourceNode && + targetNode && + isInvocationNode(sourceNode) && + sourceNode.data.type === 'for' && + isInvocationNode(targetNode) && + targetNode.data.type === 'for_return' && + edge.sourceHandle === LOOP_LINKAGE_FIELD && + edge.targetHandle === LOOP_LINKAGE_FIELD + ); +}; + const slice = createSlice({ name: 'nodes', initialState: getInitialState(), reducers: { nodesChanged: (state, action: PayloadAction[]>) => { - const removedConnectorSpliceEdges: AnyEdge[] = action.payload.flatMap((change) => { - if (change.type !== 'remove') { - return []; + const replacementNodesById = new Map(); + for (const change of action.payload) { + if (change.type === 'add' || change.type === 'replace') { + replacementNodesById.set(change.item.id, change.item); } + } - const node = state.nodes.find((candidate) => candidate.id === change.id); - if (!isConnectorNode(node)) { - return []; + const removedBoundaryAliasEdgeIds = new Set(); + for (const change of action.payload) { + if (change.type !== 'remove' && change.type !== 'replace') { + continue; } - const resolvedSource = resolveConnectorSource(node.id, state.nodes, state.edges); - if (!resolvedSource) { - return []; + const oldNode = state.nodes.find((candidate) => candidate.id === change.id); + if (!isInvocationNode(oldNode) || !['for', 'for_return'].includes(oldNode.data.type)) { + continue; } - return getConnectorOutputEdges(node.id, state.edges) - .filter((edge): edge is AnyEdge & { type: 'default'; targetHandle: string } => edge.type === 'default') - .map((edge) => - connectionToEdge({ - source: resolvedSource.nodeId, - sourceHandle: resolvedSource.fieldName, - target: edge.target, - targetHandle: edge.targetHandle, - }) - ); - }); + const replacementNode = replacementNodesById.get(change.id); + if (isInvocationNode(replacementNode) && replacementNode.data.type === oldNode.data.type) { + continue; + } + + getLoopLinkageAliasEdgeIdsForBoundary(oldNode.id, state.nodes, state.edges).forEach((edgeId) => + removedBoundaryAliasEdgeIds.add(edgeId) + ); + } + + const replacementNodeIds = new Set( + action.payload.flatMap((change) => (change.type === 'add' || change.type === 'replace' ? [change.item.id] : [])) + ); + const removedConnectorIds = new Set( + action.payload + .flatMap((change) => (change.type === 'remove' && !replacementNodeIds.has(change.id) ? [change.id] : [])) + .filter((nodeId) => isConnectorNode(state.nodes.find((node) => node.id === nodeId))) + ); + const removedNodeIds = new Set( + action.payload.flatMap((change) => + change.type === 'remove' && !replacementNodeIds.has(change.id) ? [change.id] : [] + ) + ); + const removedConnectorSpliceEdgesById = new Map(); + for (const change of action.payload) { + if (change.type !== 'remove') { + continue; + } + + const node = state.nodes.find((candidate) => candidate.id === change.id); + if (!isConnectorNode(node) || !removedConnectorIds.has(node.id)) { + continue; + } + + const spliceEdges = + getConnectorDeletionSpliceConnections( + node.id, + state.nodes, + state.edges, + undefined, + undefined, + removedConnectorIds + ) + ?.filter((connection) => !removedNodeIds.has(connection.source) && !removedNodeIds.has(connection.target)) + .map((connection) => connectionToEdge(connection)) ?? []; + spliceEdges.forEach((edge) => removedConnectorSpliceEdgesById.set(edge.id, edge)); + } + const removedConnectorSpliceEdges = [...removedConnectorSpliceEdgesById.values()]; // TODO(psyche): The below TS issue was recently fixed upstream. Need to upgrade @xyflow/react and then we // should be able to remove this cast. @@ -304,7 +363,11 @@ const slice = createSlice({ for (const e of state.edges) { const sourceExists = state.nodes.some((n) => n.id === e.source); const targetExists = state.nodes.some((n) => n.id === e.target); - if (!(sourceExists && targetExists)) { + if ( + !(sourceExists && targetExists) || + !isValidLoopLinkageEdge(e, state.nodes) || + removedBoundaryAliasEdgeIds.has(e.id) + ) { edgeChanges.push({ type: 'remove', id: e.id }); } } @@ -350,7 +413,9 @@ const slice = createSlice({ const edge = state.edges.find((e) => e.id === change.id); // If we deleted or selected a collapsed edge, we need to find its "hidden" edges and do the same to them if (edge && edge.type === 'collapsed') { - const hiddenEdges = state.edges.filter((e) => e.source === edge.source && e.target === edge.target); + const hiddenEdges = state.edges.filter( + (e) => e.type === 'default' && e.source === edge.source && e.target === edge.target + ); for (const { id } of hiddenEdges) { if (change.type === 'remove') { changes.push({ type: 'remove', id }); @@ -431,6 +496,7 @@ const slice = createSlice({ // - if the edge was just closed, we need to check all its edges and hide them if both nodes are closed const connectedEdges = getConnectedEdges([node], state.edges); + const executableConnectedEdges = connectedEdges.filter((edge) => edge.type !== 'loop_linkage'); if (isOpen) { // reset hidden status of all edges @@ -444,18 +510,19 @@ const slice = createSlice({ } }); } else { - const closedIncomers = getIncomers(node, state.nodes, state.edges).filter( + const executableEdges = state.edges.filter((edge) => edge.type !== 'loop_linkage'); + const closedIncomers = getIncomers(node, state.nodes, executableEdges).filter( (node) => isInvocationNode(node) && node.data.isOpen === false ); - const closedOutgoers = getOutgoers(node, state.nodes, state.edges).filter( + const closedOutgoers = getOutgoers(node, state.nodes, executableEdges).filter( (node) => isInvocationNode(node) && node.data.isOpen === false ); const collapsedEdgesToCreate: AnyEdge[] = []; // hide all edges - connectedEdges.forEach((edge) => { + executableConnectedEdges.forEach((edge) => { if (edge.target === nodeId && closedIncomers.find((node) => node.id === edge.source)) { edge.hidden = true; const collapsedEdge = collapsedEdgesToCreate.find( @@ -516,7 +583,7 @@ const slice = createSlice({ ) => { const { edgeId, connector } = action.payload; const edge = state.edges.find((candidate) => candidate.id === edgeId); - if (!edge || edge.type !== 'default') { + if (!edge || (edge.type !== 'default' && edge.type !== 'loop_linkage')) { return; } state.nodes.push({ ...SHARED_NODE_PROPERTIES, ...connector } as (typeof state.nodes)[number]); diff --git a/invokeai/frontend/web/src/features/nodes/store/util/connectorTopology.test.ts b/invokeai/frontend/web/src/features/nodes/store/util/connectorTopology.test.ts index e87ebcde792..3a6e2030798 100644 --- a/invokeai/frontend/web/src/features/nodes/store/util/connectorTopology.test.ts +++ b/invokeai/frontend/web/src/features/nodes/store/util/connectorTopology.test.ts @@ -7,10 +7,13 @@ import { getConnectorDeletionSpliceConnections, getConnectorInputEdge, getConnectorOutputEdges, + getEdgesWithLoopLinkageAliases, resolveConnectorSource, resolveConnectorSourceFieldType, + resolveLoopLinkagePath, + resolvePendingConnectionSource, } from './connectorTopology'; -import { add, buildEdge, buildNode, img_resize, sub, templates } from './testUtils'; +import { add, buildEdge, buildNode, for_loop, for_return, img_resize, sub, templates } from './testUtils'; const buildConnectorNode = (id: string): ConnectorNode => ({ id, @@ -25,6 +28,32 @@ const buildConnectorNode = (id: string): ConnectorNode => ({ }); describe('connectorTopology', () => { + it('resolves pending connector source metadata, including output scope', () => { + const source = buildNode(for_loop); + const connector = buildConnectorNode('connector-1'); + const pendingConnection = { + nodeId: connector.id, + handleId: CONNECTOR_OUTPUT_HANDLE, + handleType: 'source' as const, + fieldTemplate: { + name: CONNECTOR_OUTPUT_HANDLE, + title: 'Connector Output', + description: '', + fieldKind: 'output' as const, + ui_hidden: false, + type: { name: 'AnyField', cardinality: 'SINGLE' as const, batch: false }, + }, + }; + const nodes: AnyNode[] = [source, connector]; + const edges = [buildEdge(source.id, 'item', connector.id, CONNECTOR_INPUT_HANDLE)]; + + expect(resolvePendingConnectionSource(pendingConnection, nodes, edges, { ...templates, for: for_loop })).toEqual({ + nodeId: source.id, + fieldName: 'item', + outputScope: 'iteration', + }); + }); + it('resolves the effective upstream source through one connector', () => { const source = buildNode(add); const connector = buildConnectorNode('connector-1'); @@ -58,6 +87,92 @@ describe('connectorTopology', () => { }); }); + it('resolves a one-to-one connector loop linkage path', () => { + const forNode = buildNode(for_loop); + const connector = buildConnectorNode('connector-1'); + const returnNode = buildNode(for_return); + const inputEdge = buildEdge(forNode.id, 'loop_linkage', connector.id, CONNECTOR_INPUT_HANDLE); + const outputEdge = buildEdge(connector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'); + const nodes: AnyNode[] = [forNode, connector, returnNode]; + + expect(resolveLoopLinkagePath(outputEdge, nodes, [inputEdge, outputEdge])).toEqual({ + forNodeId: forNode.id, + returnNodeId: returnNode.id, + edgeIds: [inputEdge.id, outputEdge.id], + connectorNodeIds: [connector.id], + }); + }); + + it('resolves a connector loop linkage path through a connector chain', () => { + const forNode = buildNode(for_loop); + const firstConnector = buildConnectorNode('connector-1'); + const secondConnector = buildConnectorNode('connector-2'); + const returnNode = buildNode(for_return); + const firstInputEdge = buildEdge(forNode.id, 'loop_linkage', firstConnector.id, CONNECTOR_INPUT_HANDLE); + const chainEdge = buildEdge(firstConnector.id, CONNECTOR_OUTPUT_HANDLE, secondConnector.id, CONNECTOR_INPUT_HANDLE); + const outputEdge = buildEdge(secondConnector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'); + const nodes: AnyNode[] = [forNode, firstConnector, secondConnector, returnNode]; + + expect(resolveLoopLinkagePath(outputEdge, nodes, [firstInputEdge, chainEdge, outputEdge])).toEqual({ + forNodeId: forNode.id, + returnNodeId: returnNode.id, + edgeIds: [firstInputEdge.id, chainEdge.id, outputEdge.id], + connectorNodeIds: [firstConnector.id, secondConnector.id], + }); + }); + + it('marks every complete connector loop linkage segment for dashed rendering', () => { + const forNode = buildNode(for_loop); + const connector = buildConnectorNode('connector-1'); + const returnNode = buildNode(for_return); + const inputEdge = buildEdge(forNode.id, 'loop_linkage', connector.id, CONNECTOR_INPUT_HANDLE); + const outputEdge = buildEdge(connector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'); + const edges = [inputEdge, outputEdge]; + + expect(getEdgesWithLoopLinkageAliases([forNode, connector, returnNode], edges)).toEqual([ + { ...inputEdge, type: 'loop_linkage' }, + { ...outputEdge, type: 'loop_linkage' }, + ]); + expect(edges).toEqual([inputEdge, outputEdge]); + }); + + it('marks every segment in a chained connector loop linkage for dashed rendering', () => { + const forNode = buildNode(for_loop); + const firstConnector = buildConnectorNode('connector-1'); + const secondConnector = buildConnectorNode('connector-2'); + const returnNode = buildNode(for_return); + const edges = [ + buildEdge(forNode.id, 'loop_linkage', firstConnector.id, CONNECTOR_INPUT_HANDLE), + buildEdge(firstConnector.id, CONNECTOR_OUTPUT_HANDLE, secondConnector.id, CONNECTOR_INPUT_HANDLE), + buildEdge(secondConnector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), + ]; + + expect(getEdgesWithLoopLinkageAliases([forNode, firstConnector, secondConnector, returnNode], edges)).toEqual( + edges.map((edge) => ({ ...edge, type: 'loop_linkage' })) + ); + }); + + it('leaves an incomplete connector loop linkage path as a default edge', () => { + const forNode = buildNode(for_loop); + const connector = buildConnectorNode('connector-1'); + const edges = [buildEdge(forNode.id, 'loop_linkage', connector.id, CONNECTOR_INPUT_HANDLE)]; + + expect(getEdgesWithLoopLinkageAliases([forNode, connector], edges)).toEqual(edges); + }); + + it('rejects a connector loop linkage path that fans out', () => { + const forNode = buildNode(for_loop); + const connector = buildConnectorNode('connector-1'); + const firstReturnNode = buildNode(for_return); + const secondReturnNode = buildNode(for_return); + const inputEdge = buildEdge(forNode.id, 'loop_linkage', connector.id, CONNECTOR_INPUT_HANDLE); + const firstOutputEdge = buildEdge(connector.id, CONNECTOR_OUTPUT_HANDLE, firstReturnNode.id, 'loop_linkage'); + const secondOutputEdge = buildEdge(connector.id, CONNECTOR_OUTPUT_HANDLE, secondReturnNode.id, 'loop_linkage'); + const nodes: AnyNode[] = [forNode, connector, firstReturnNode, secondReturnNode]; + + expect(resolveLoopLinkagePath(firstOutputEdge, nodes, [inputEdge, firstOutputEdge, secondOutputEdge])).toBe(null); + }); + it('returns no source or type for an unresolved connector chain', () => { const connectorA = buildConnectorNode('connector-a'); const connectorB = buildConnectorNode('connector-b'); @@ -115,6 +230,54 @@ describe('connectorTopology', () => { ]); }); + it('splices a terminal loop linkage connector to its immediate upstream connector', () => { + const forNode = buildNode(for_loop); + const firstConnector = buildConnectorNode('connector-a'); + const terminalConnector = buildConnectorNode('connector-b'); + const returnNode = buildNode(for_return); + const edges = [ + buildEdge(forNode.id, 'loop_linkage', firstConnector.id, CONNECTOR_INPUT_HANDLE), + buildEdge(firstConnector.id, CONNECTOR_OUTPUT_HANDLE, terminalConnector.id, CONNECTOR_INPUT_HANDLE), + buildEdge(terminalConnector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), + ]; + + expect( + getConnectorDeletionSpliceConnections( + terminalConnector.id, + [forNode, firstConnector, terminalConnector, returnNode], + edges, + { ...templates, for: for_loop, for_return } + ) + ).toEqual([ + { + source: firstConnector.id, + sourceHandle: CONNECTOR_OUTPUT_HANDLE, + target: returnNode.id, + targetHandle: 'loop_linkage', + }, + ]); + }); + + it('does not splice a loop linkage connector with an invalid ordinary fanout', () => { + const forNode = buildNode(for_loop); + const connector = buildConnectorNode('connector'); + const returnNode = buildNode(for_return); + const ordinaryTarget = buildNode(sub); + const edges = [ + buildEdge(forNode.id, 'loop_linkage', connector.id, CONNECTOR_INPUT_HANDLE), + buildEdge(connector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), + buildEdge(connector.id, CONNECTOR_OUTPUT_HANDLE, ordinaryTarget.id, 'a'), + ]; + + expect( + getConnectorDeletionSpliceConnections(connector.id, [forNode, connector, returnNode, ordinaryTarget], edges, { + ...templates, + for: for_loop, + for_return, + }) + ).toBe(null); + }); + it('returns no splice-through edges when a connector has downstream targets but no upstream source', () => { const connector = buildConnectorNode('connector-1'); const target = buildNode(sub); diff --git a/invokeai/frontend/web/src/features/nodes/store/util/connectorTopology.ts b/invokeai/frontend/web/src/features/nodes/store/util/connectorTopology.ts index e1267763d70..6d96d03d4be 100644 --- a/invokeai/frontend/web/src/features/nodes/store/util/connectorTopology.ts +++ b/invokeai/frontend/web/src/features/nodes/store/util/connectorTopology.ts @@ -1,4 +1,5 @@ -import type { Templates } from 'features/nodes/store/types'; +import type { PendingConnection, Templates } from 'features/nodes/store/types'; +import { LOOP_LINKAGE_FIELD } from 'features/nodes/types/constants'; import type { FieldType } from 'features/nodes/types/field'; import type { AnyEdge, AnyNode } from 'features/nodes/types/invocation'; import { isConnectorNode, isInvocationNode } from 'features/nodes/types/invocation'; @@ -11,6 +12,13 @@ type ResolvedConnectorSource = { fieldName: string; }; +type ResolvedLoopLinkagePath = { + forNodeId: string; + returnNodeId: string; + edgeIds: string[]; + connectorNodeIds: string[]; +}; + type SpliceConnection = { source: string; sourceHandle: string; @@ -27,6 +35,17 @@ type SpliceConnectionValidator = ( strict?: boolean ) => string | null; +type ResolvedConnectorOutputEdges = { + edges: AnyEdge[]; + traversedEdgeIds: Set; +}; + +type ResolvedPendingConnectionSource = { + nodeId: string; + fieldName: string; + outputScope?: 'iteration' | 'final'; +}; + export const getConnectorInputEdge = (connectorId: string, edges: AnyEdge[]): AnyEdge | undefined => edges.find( (edge) => @@ -45,6 +64,84 @@ export const getConnectorOutputEdges = (connectorId: string, edges: AnyEdge[]): typeof edge.targetHandle === 'string' ); +const getConnectorDeletionOutputEdges = ( + connectorId: string, + nodes: AnyNode[], + edges: AnyEdge[], + removedConnectorIds: ReadonlySet +): ResolvedConnectorOutputEdges | null => { + const visitedConnectorIds = new Set(); + const traversedEdgeIds = new Set(); + const outputEdges: AnyEdge[] = []; + + const resolve = (currentConnectorId: string): boolean => { + if (visitedConnectorIds.has(currentConnectorId)) { + return false; + } + visitedConnectorIds.add(currentConnectorId); + + for (const edge of getConnectorOutputEdges(currentConnectorId, edges)) { + traversedEdgeIds.add(edge.id); + const targetNode = nodes.find((node) => node.id === edge.target); + if (removedConnectorIds.has(edge.target) && isConnectorNode(targetNode)) { + if (!resolve(targetNode.id)) { + return false; + } + } else { + outputEdges.push(edge); + } + } + return true; + }; + + return resolve(connectorId) ? { edges: outputEdges, traversedEdgeIds } : null; +}; + +const getConnectorDeletionInputEdgeIds = ( + connectorId: string, + nodes: AnyNode[], + edges: AnyEdge[], + removedConnectorIds: ReadonlySet +): Set => { + const inputEdgeIds = new Set(); + const visitedConnectorIds = new Set(); + let currentConnectorId: string | null = connectorId; + + while (currentConnectorId && !visitedConnectorIds.has(currentConnectorId)) { + visitedConnectorIds.add(currentConnectorId); + const inputEdge = getConnectorInputEdge(currentConnectorId, edges); + if (!inputEdge) { + break; + } + inputEdgeIds.add(inputEdge.id); + + const sourceNode = nodes.find((node) => node.id === inputEdge.source); + currentConnectorId = isConnectorNode(sourceNode) && removedConnectorIds.has(sourceNode.id) ? sourceNode.id : null; + } + + return inputEdgeIds; +}; + +const resolveSurvivingConnectorDeletionSource = ( + connectorId: string, + nodes: AnyNode[], + edges: AnyEdge[], + removedConnectorIds: ReadonlySet +): ResolvedConnectorSource | null => { + const visitedConnectorIds = new Set(); + let resolvedSource = resolveConnectorDeletionSource(connectorId, nodes, edges); + + while (resolvedSource && removedConnectorIds.has(resolvedSource.nodeId)) { + if (visitedConnectorIds.has(resolvedSource.nodeId)) { + return null; + } + visitedConnectorIds.add(resolvedSource.nodeId); + resolvedSource = resolveConnectorDeletionSource(resolvedSource.nodeId, nodes, edges); + } + + return resolvedSource; +}; + export const resolveConnectorSource = ( connectorId: string, nodes: AnyNode[], @@ -85,6 +182,284 @@ export const resolveConnectorSource = ( return resolve(connectorId); }; +/** + * Resolves a connector alias used for the visual loop linkage between a For and ForReturn. + * Every connector on this path must have exactly one input and one output, so the alias cannot + * branch or be reused as ordinary data flow. + */ +export const resolveLoopLinkagePath = ( + edge: AnyEdge, + nodes: AnyNode[], + edges: AnyEdge[] +): ResolvedLoopLinkagePath | null => { + if (edge.type !== 'default' || edge.targetHandle !== LOOP_LINKAGE_FIELD || typeof edge.sourceHandle !== 'string') { + return null; + } + + const returnNode = nodes.find((node) => node.id === edge.target); + if (!returnNode || !isInvocationNode(returnNode) || returnNode.data.type !== 'for_return') { + return null; + } + + const edgeIds = [edge.id]; + const connectorNodeIds: string[] = []; + const visitedConnectors = new Set(); + let currentEdge: AnyEdge = edge; + + while (true) { + const sourceNode = nodes.find((node) => node.id === currentEdge.source); + if (!sourceNode || typeof currentEdge.sourceHandle !== 'string') { + return null; + } + + if (isInvocationNode(sourceNode)) { + if (sourceNode.data.type !== 'for' || currentEdge.sourceHandle !== LOOP_LINKAGE_FIELD) { + return null; + } + return { + forNodeId: sourceNode.id, + returnNodeId: returnNode.id, + edgeIds: [...edgeIds].reverse(), + connectorNodeIds: [...connectorNodeIds].reverse(), + }; + } + + if (!isConnectorNode(sourceNode) || currentEdge.sourceHandle !== CONNECTOR_OUTPUT_HANDLE) { + return null; + } + if (visitedConnectors.has(sourceNode.id)) { + return null; + } + visitedConnectors.add(sourceNode.id); + connectorNodeIds.push(sourceNode.id); + + const inputEdges = edges.filter( + (candidate) => + candidate.type === 'default' && + candidate.target === sourceNode.id && + candidate.targetHandle === CONNECTOR_INPUT_HANDLE && + typeof candidate.sourceHandle === 'string' + ); + const outputEdges = getConnectorOutputEdges(sourceNode.id, edges); + if ( + inputEdges.length !== 1 || + outputEdges.length !== 1 || + (outputEdges[0] !== currentEdge && outputEdges[0]?.id !== currentEdge.id) + ) { + return null; + } + + const inputEdge = inputEdges[0]; + if (!inputEdge) { + return null; + } + edgeIds.push(inputEdge.id); + currentEdge = inputEdge; + } +}; + +const getResolvedLoopLinkagePathForConnector = ( + connectorId: string, + nodes: AnyNode[], + edges: AnyEdge[] +): ResolvedLoopLinkagePath | null => { + for (const edge of edges) { + if ( + edge.type !== 'default' || + edge.sourceHandle !== CONNECTOR_OUTPUT_HANDLE || + edge.targetHandle !== LOOP_LINKAGE_FIELD + ) { + continue; + } + + const path = resolveLoopLinkagePath(edge, nodes, edges); + if (path?.connectorNodeIds.includes(connectorId)) { + return path; + } + } + return null; +}; + +/** + * Finds the serialized connector edges attached to a boundary's loop-linkage alias, + * including an alias that has not reached its opposite boundary yet. + */ +export const getLoopLinkageAliasEdgeIdsForBoundary = ( + boundaryNodeId: string, + nodes: AnyNode[], + edges: AnyEdge[] +): Set => { + const edgeIds = new Set(); + const visitedForwardConnectorIds = new Set(); + const visitedBackwardConnectorIds = new Set(); + + const visitForward = (connectorId: string): void => { + if (visitedForwardConnectorIds.has(connectorId)) { + return; + } + visitedForwardConnectorIds.add(connectorId); + for (const edge of getConnectorOutputEdges(connectorId, edges)) { + edgeIds.add(edge.id); + const targetNode = nodes.find((node) => node.id === edge.target); + if (isConnectorNode(targetNode) && edge.targetHandle === CONNECTOR_INPUT_HANDLE) { + visitForward(targetNode.id); + } + } + }; + + const visitBackward = (connectorId: string): void => { + if (visitedBackwardConnectorIds.has(connectorId)) { + return; + } + visitedBackwardConnectorIds.add(connectorId); + const inputEdge = getConnectorInputEdge(connectorId, edges); + if (!inputEdge) { + return; + } + edgeIds.add(inputEdge.id); + const sourceNode = nodes.find((node) => node.id === inputEdge.source); + if (isConnectorNode(sourceNode) && inputEdge.sourceHandle === CONNECTOR_OUTPUT_HANDLE) { + visitBackward(sourceNode.id); + } + }; + + for (const edge of edges) { + if (edge.type !== 'default') { + continue; + } + + if ( + edge.source === boundaryNodeId && + edge.sourceHandle === LOOP_LINKAGE_FIELD && + edge.targetHandle === CONNECTOR_INPUT_HANDLE + ) { + const targetNode = nodes.find((node) => node.id === edge.target); + if (isConnectorNode(targetNode)) { + edgeIds.add(edge.id); + visitForward(targetNode.id); + } + } + + if ( + edge.target === boundaryNodeId && + edge.targetHandle === LOOP_LINKAGE_FIELD && + edge.sourceHandle === CONNECTOR_OUTPUT_HANDLE + ) { + const sourceNode = nodes.find((node) => node.id === edge.source); + if (isConnectorNode(sourceNode)) { + edgeIds.add(edge.id); + visitBackward(sourceNode.id); + } + } + } + + return edgeIds; +}; + +/** + * Builds the edge list used only for React Flow rendering. Complete connector + * aliases are presented as loop-linkage edges so they use the dashed green + * renderer; the supplied edge list is never mutated. + */ +export const getEdgesWithLoopLinkageAliases = (nodes: AnyNode[], edges: AnyEdge[]): AnyEdge[] => { + const loopLinkageEdgeIds = new Set(); + for (const edge of edges) { + if ( + edge.type !== 'default' || + edge.sourceHandle !== CONNECTOR_OUTPUT_HANDLE || + edge.targetHandle !== LOOP_LINKAGE_FIELD || + !isConnectorNode(nodes.find((node) => node.id === edge.source)) + ) { + continue; + } + + const path = resolveLoopLinkagePath(edge, nodes, edges); + path?.edgeIds.forEach((edgeId) => loopLinkageEdgeIds.add(edgeId)); + } + + return edges.map((edge) => (loopLinkageEdgeIds.has(edge.id) ? ({ ...edge, type: 'loop_linkage' } as AnyEdge) : edge)); +}; + +/** + * Resolves the source to use when removing a connector. Loop-linkage aliases + * preserve the remaining connector chain; ordinary data connectors keep + * splicing back to their invocation source. + */ +const resolveConnectorDeletionSource = ( + connectorId: string, + nodes: AnyNode[], + edges: AnyEdge[] +): ResolvedConnectorSource | null => { + const resolvedSource = resolveConnectorSource(connectorId, nodes, edges); + if (!resolvedSource) { + return null; + } + + const sourceNode = nodes.find((node) => node.id === resolvedSource.nodeId); + if ( + isInvocationNode(sourceNode) && + sourceNode.data.type === 'for' && + resolvedSource.fieldName === LOOP_LINKAGE_FIELD + ) { + const outputEdges = getConnectorOutputEdges(connectorId, edges); + if ( + outputEdges.length !== 1 || + outputEdges.some((edge) => { + const targetNode = nodes.find((node) => node.id === edge.target); + return !( + (isConnectorNode(targetNode) && edge.targetHandle === CONNECTOR_INPUT_HANDLE) || + (isInvocationNode(targetNode) && + targetNode.data.type === 'for_return' && + edge.targetHandle === LOOP_LINKAGE_FIELD) + ); + }) + ) { + return null; + } + } + + const linkagePath = getResolvedLoopLinkagePathForConnector(connectorId, nodes, edges); + const inputEdge = getConnectorInputEdge(connectorId, edges); + if (linkagePath && inputEdge && typeof inputEdge.sourceHandle === 'string') { + return { + nodeId: inputEdge.source, + fieldName: inputEdge.sourceHandle, + }; + } + + return resolvedSource; +}; + +export const resolvePendingConnectionSource = ( + pendingConnection: PendingConnection | null, + nodes: AnyNode[], + edges: AnyEdge[], + templates?: Templates +): ResolvedPendingConnectionSource | null => { + if (!pendingConnection || pendingConnection.handleType !== 'source') { + return null; + } + + const pendingNode = nodes.find((node) => node.id === pendingConnection.nodeId); + const resolvedSource = + pendingNode && isConnectorNode(pendingNode) + ? resolveConnectorSource(pendingNode.id, nodes, edges) + : isInvocationNode(pendingNode) + ? { nodeId: pendingNode.id, fieldName: pendingConnection.handleId } + : null; + if (!resolvedSource) { + return null; + } + + const sourceNode = nodes.find((node) => node.id === resolvedSource.nodeId); + const outputScope = + sourceNode && isInvocationNode(sourceNode) + ? (templates?.[sourceNode.data.type]?.outputs[resolvedSource.fieldName]?.output_scope ?? undefined) + : undefined; + + return { ...resolvedSource, outputScope }; +}; + export const resolveConnectorSourceFieldType = ( connectorId: string, nodes: AnyNode[], @@ -109,15 +484,21 @@ export const getConnectorDeletionSpliceConnections = ( connectorId: string, nodes: AnyNode[], edges: AnyEdge[], - templates: Templates, - validateConnection?: SpliceConnectionValidator + templates?: Templates, + validateConnection?: SpliceConnectionValidator, + removedConnectorIds: ReadonlySet = new Set() ): SpliceConnection[] | null => { - const resolvedSource = resolveConnectorSource(connectorId, nodes, edges); + const resolvedSource = resolveSurvivingConnectorDeletionSource(connectorId, nodes, edges, removedConnectorIds); if (!resolvedSource) { return null; } - const outputEdges = getConnectorOutputEdges(connectorId, edges); + const resolvedOutputEdges = getConnectorDeletionOutputEdges(connectorId, nodes, edges, removedConnectorIds); + if (!resolvedOutputEdges) { + return null; + } + const { edges: outputEdges, traversedEdgeIds } = resolvedOutputEdges; + const inputEdgeIds = getConnectorDeletionInputEdgeIds(connectorId, nodes, edges, removedConnectorIds); const spliceConnections = outputEdges .filter((edge): edge is AnyEdge & { type: 'default'; targetHandle: string } => edge.type === 'default') .map((edge) => ({ @@ -136,13 +517,16 @@ export const getConnectorDeletionSpliceConnections = ( deduped.add(key); } + if (!templates) { + return validateConnection ? null : spliceConnections; + } + if (!validateConnection) { const sourceType = resolveConnectorSourceFieldType(connectorId, nodes, edges, templates); if (!sourceType) { return null; } - const inputEdgeId = getConnectorInputEdge(connectorId, edges)?.id; - const outputEdgeIds = new Set(outputEdges.map((edge) => edge.id)); + const outputEdgeIds = traversedEdgeIds; for (const connection of spliceConnections) { const targetNode = nodes.find((node) => node.id === connection.target); @@ -173,7 +557,7 @@ export const getConnectorDeletionSpliceConnections = ( const existingTargetConflict = edges.some( (edge) => edge.type === 'default' && - edge.id !== inputEdgeId && + !inputEdgeIds.has(edge.id) && !outputEdgeIds.has(edge.id) && edge.target === connection.target && edge.targetHandle === connection.targetHandle @@ -196,10 +580,7 @@ export const getConnectorDeletionSpliceConnections = ( return spliceConnections; } - const ignoredEdgeIds = new Set([ - getConnectorInputEdge(connectorId, edges)?.id, - ...outputEdges.map((edge) => edge.id), - ]); + const ignoredEdgeIds = new Set([...inputEdgeIds, ...traversedEdgeIds]); const existingEdges = edges.filter((edge) => !ignoredEdgeIds.has(edge.id)); const stagedConnections: SpliceConnection[] = []; diff --git a/invokeai/frontend/web/src/features/nodes/store/util/getFirstValidConnection.test.ts b/invokeai/frontend/web/src/features/nodes/store/util/getFirstValidConnection.test.ts index b4374a920c1..0f7487517a0 100644 --- a/invokeai/frontend/web/src/features/nodes/store/util/getFirstValidConnection.test.ts +++ b/invokeai/frontend/web/src/features/nodes/store/util/getFirstValidConnection.test.ts @@ -6,7 +6,16 @@ import { getSourceCandidateFields, getTargetCandidateFields, } from 'features/nodes/store/util/getFirstValidConnection'; -import { add, buildEdge, buildNode, img_resize, sub, templates } from 'features/nodes/store/util/testUtils'; +import { + add, + buildEdge, + buildNode, + for_loop, + for_return, + img_resize, + sub, + templates, +} from 'features/nodes/store/util/testUtils'; import { describe, expect, it } from 'vitest'; const buildConnectorNode = (id: string) => ({ @@ -160,6 +169,77 @@ describe('getFirstValidConnection', () => { targetHandle: 'width', }); }); + + it('should resolve a connector output candidate for a ForReturn linkage input', () => { + const forNode = buildNode(for_loop); + const connector = buildConnectorNode('connector-1'); + const returnNode = buildNode(for_return); + const edges = [buildEdge(forNode.id, 'loop_linkage', connector.id, CONNECTOR_INPUT_HANDLE)]; + const loopTemplates = { ...templates, for: for_loop, for_return }; + + expect( + getFirstValidConnection( + connector.id, + null, + returnNode.id, + 'loop_linkage', + [forNode, connector, returnNode], + edges, + loopTemplates, + null + ) + ).toEqual({ + source: connector.id, + sourceHandle: CONNECTOR_OUTPUT_HANDLE, + target: returnNode.id, + targetHandle: 'loop_linkage', + }); + }); + + it('should auto-wire a For iteration item output to the ForReturn output input', () => { + const forNode = buildNode(for_loop); + const returnNode = buildNode(for_return); + const loopTemplates = { for: for_loop, for_return }; + + expect( + getFirstValidConnection(forNode.id, 'item', returnNode.id, null, [forNode, returnNode], [], loopTemplates, null) + ).toEqual({ + source: forNode.id, + sourceHandle: 'item', + target: returnNode.id, + targetHandle: 'output', + }); + }); + + it('should auto-wire a For state output to the ForReturn state input', () => { + const forNode = buildNode(for_loop); + const returnNode = buildNode(for_return); + const loopTemplates = { for: for_loop, for_return }; + + expect( + getFirstValidConnection(forNode.id, 'state', returnNode.id, null, [forNode, returnNode], [], loopTemplates, null) + ).toEqual({ + source: forNode.id, + sourceHandle: 'state', + target: returnNode.id, + targetHandle: 'state', + }); + }); + + it('should auto-wire the exact For state output to a fixed ForReturn state input', () => { + const forNode = buildNode(for_loop); + const returnNode = buildNode(for_return); + const loopTemplates = { for: for_loop, for_return }; + + expect( + getFirstValidConnection(forNode.id, null, returnNode.id, 'state', [forNode, returnNode], [], loopTemplates, null) + ).toEqual({ + source: forNode.id, + sourceHandle: 'state', + target: returnNode.id, + targetHandle: 'state', + }); + }); }); describe('getTargetCandidateFields', () => { diff --git a/invokeai/frontend/web/src/features/nodes/store/util/getFirstValidConnection.ts b/invokeai/frontend/web/src/features/nodes/store/util/getFirstValidConnection.ts index 17b068ad0c5..70b1fd5bef2 100644 --- a/invokeai/frontend/web/src/features/nodes/store/util/getFirstValidConnection.ts +++ b/invokeai/frontend/web/src/features/nodes/store/util/getFirstValidConnection.ts @@ -1,16 +1,31 @@ import type { Connection } from '@xyflow/react'; import { map } from 'es-toolkit/compat'; import type { Templates } from 'features/nodes/store/types'; +import { areTypesEqual } from 'features/nodes/store/util/areTypesEqual'; import { CONNECTOR_INPUT_HANDLE, CONNECTOR_OUTPUT_HANDLE, resolveConnectorSourceFieldType, } from 'features/nodes/store/util/connectorTopology'; import { validateConnection } from 'features/nodes/store/util/validateConnection'; -import type { FieldInputTemplate, FieldOutputTemplate } from 'features/nodes/types/field'; +import type { FieldInputTemplate, FieldOutputTemplate, FieldType } from 'features/nodes/types/field'; import type { AnyEdge, AnyNode } from 'features/nodes/types/invocation'; import { getInvocationNodeInputTemplate, isConnectorNode, isInvocationNode } from 'features/nodes/types/invocation'; +const rankCandidateFieldsByType = ( + fields: T[], + type: FieldType | null | undefined +): T[] => { + if (!type) { + return fields; + } + + return [ + ...fields.filter((field) => areTypesEqual(type, field.type)), + ...fields.filter((field) => !areTypesEqual(type, field.type)), + ]; +}; + /** * * @param source The source (node id) @@ -127,7 +142,10 @@ export const getTargetCandidateFields = ( return []; } - if (!isConnectorNode(sourceNode)) { + let sourceFieldType: FieldType | null | undefined; + if (isConnectorNode(sourceNode)) { + sourceFieldType = resolveConnectorSourceFieldType(sourceNode.id, nodes, edges, templates); + } else { const sourceTemplate = templates[sourceNode.data.type]; if (!sourceTemplate) { return []; @@ -138,6 +156,8 @@ export const getTargetCandidateFields = ( if (!sourceField) { return []; } + + sourceFieldType = sourceField.type; } const targetCandidateFields = Object.entries(targetNode.data.inputs).flatMap(([fieldName, input]) => { @@ -150,7 +170,7 @@ export const getTargetCandidateFields = ( return connectionErrorTKey === null ? [field] : []; }); - return targetCandidateFields; + return rankCandidateFieldsByType(targetCandidateFields, sourceFieldType); }; export const getSourceCandidateFields = ( @@ -198,6 +218,7 @@ export const getSourceCandidateFields = ( return []; } + let targetFieldType: FieldType | undefined; if (!isConnectorNode(targetNode)) { if (!isInvocationNode(targetNode)) { return []; @@ -212,6 +233,7 @@ export const getSourceCandidateFields = ( if (!targetField) { return []; } + targetFieldType = targetField.type; } else if (targetHandle !== CONNECTOR_INPUT_HANDLE) { return []; } @@ -222,5 +244,5 @@ export const getSourceCandidateFields = ( return connectionErrorTKey === null; }); - return sourceCandidateFields; + return rankCandidateFieldsByType(sourceCandidateFields, targetFieldType); }; diff --git a/invokeai/frontend/web/src/features/nodes/store/util/getHasCycles.ts b/invokeai/frontend/web/src/features/nodes/store/util/getHasCycles.ts index 9b0b99e48de..29b79bb18cd 100644 --- a/invokeai/frontend/web/src/features/nodes/store/util/getHasCycles.ts +++ b/invokeai/frontend/web/src/features/nodes/store/util/getHasCycles.ts @@ -19,7 +19,9 @@ export const getHasCycles = (source: string, target: string, nodes: Node[], edge }); edges.forEach((e) => { - g.setEdge(e.source, e.target); + if (e.type === 'default') { + g.setEdge(e.source, e.target); + } }); // add the candidate edge diff --git a/invokeai/frontend/web/src/features/nodes/store/util/reactFlowUtil.test.ts b/invokeai/frontend/web/src/features/nodes/store/util/reactFlowUtil.test.ts index b70eda4bdaa..b0856dfab1a 100644 --- a/invokeai/frontend/web/src/features/nodes/store/util/reactFlowUtil.test.ts +++ b/invokeai/frontend/web/src/features/nodes/store/util/reactFlowUtil.test.ts @@ -20,4 +20,22 @@ describe('connectionToEdge', () => { id: 'reactflow__edge-source-nodevalue-target-nodea', }); }); + + it('creates a loop linkage edge when both handles are loop linkage handles', () => { + expect( + connectionToEdge({ + source: 'for-node', + sourceHandle: 'loop_linkage', + target: 'return-node', + targetHandle: 'loop_linkage', + }) + ).toEqual({ + type: 'loop_linkage', + source: 'for-node', + sourceHandle: 'loop_linkage', + target: 'return-node', + targetHandle: 'loop_linkage', + id: 'reactflow__edge-for-nodeloop_linkage-return-nodeloop_linkage', + }); + }); }); diff --git a/invokeai/frontend/web/src/features/nodes/store/util/reactFlowUtil.ts b/invokeai/frontend/web/src/features/nodes/store/util/reactFlowUtil.ts index 3eaece154fb..451d8ff384e 100644 --- a/invokeai/frontend/web/src/features/nodes/store/util/reactFlowUtil.ts +++ b/invokeai/frontend/web/src/features/nodes/store/util/reactFlowUtil.ts @@ -1,7 +1,18 @@ import type { Connection } from '@xyflow/react'; +import { LOOP_LINKAGE_FIELD } from 'features/nodes/types/constants'; import type { AnyEdge } from 'features/nodes/types/invocation'; import { assert } from 'tsafe'; +export const getEdgeTypeFromHandles = ( + sourceHandle: string | null | undefined, + targetHandle: string | null | undefined +): 'default' | 'loop_linkage' => + sourceHandle === LOOP_LINKAGE_FIELD && targetHandle === LOOP_LINKAGE_FIELD ? 'loop_linkage' : 'default'; + +export const isLoopLinkageEdge = (edge: Pick): boolean => + edge.type === 'loop_linkage' || + (edge.type === 'default' && getEdgeTypeFromHandles(edge.sourceHandle, edge.targetHandle) === 'loop_linkage'); + /** * Gets the edge id for a connection * Copied from: https://github.com/xyflow/xyflow/blob/v11/packages/core/src/utils/graph.ts#L44-L45 @@ -24,7 +35,7 @@ export const connectionToEdge = (connection: Connection): AnyEdge => { const { source, sourceHandle, target, targetHandle } = connection; assert(source && sourceHandle && target && targetHandle, 'Invalid connection'); return { - type: 'default', + type: getEdgeTypeFromHandles(sourceHandle, targetHandle), source, sourceHandle, target, diff --git a/invokeai/frontend/web/src/features/nodes/store/util/testUtils.ts b/invokeai/frontend/web/src/features/nodes/store/util/testUtils.ts index 67c477408f3..6632e270486 100644 --- a/invokeai/frontend/web/src/features/nodes/store/util/testUtils.ts +++ b/invokeai/frontend/web/src/features/nodes/store/util/testUtils.ts @@ -12,6 +12,15 @@ export const buildEdge = (source: string, sourceHandle: string, target: string, id: `reactflow__edge-${source}${sourceHandle}-${target}${targetHandle}`, }); +export const buildLoopLinkageEdge = (source: string, target: string): AnyEdge => ({ + source, + sourceHandle: 'loop_linkage', + target, + targetHandle: 'loop_linkage', + type: 'loop_linkage', + id: `reactflow__edge-${source}-loop_linkage-${target}-loop_linkage`, +}); + export const buildNode = (template: InvocationTemplate) => buildInvocationNode({ x: 0, y: 0 }, template); export const add: InvocationTemplate = { @@ -631,6 +640,251 @@ const iterate: InvocationTemplate = { category: 'collections', }; +export const for_loop: InvocationTemplate = { + title: 'For', + type: 'for', + version: '1.3.0', + tags: [], + description: '', + outputType: 'for_output', + inputs: { + collection: { + name: 'collection', + title: 'Collection', + required: false, + default: undefined, + description: 'The list of items to iterate over', + fieldKind: 'input', + input: 'connection', + ui_hidden: false, + ui_type: 'CollectionField', + type: { + name: 'CollectionField', + cardinality: 'COLLECTION', + batch: false, + }, + }, + state: { + name: 'state', + title: 'State', + required: false, + default: undefined, + description: 'Optional initial loop state', + fieldKind: 'input', + input: 'connection', + ui_hidden: false, + type: { + name: 'LoopState', + cardinality: 'SINGLE', + batch: false, + }, + }, + }, + outputs: { + loop_linkage: { + fieldKind: 'output', + name: 'loop_linkage', + title: 'Loop Linkage', + description: 'The loop linkage to the matching ForReturn', + type: { + name: 'AnyField', + cardinality: 'SINGLE', + batch: false, + }, + ui_hidden: false, + ui_type: 'AnyField', + }, + item: { + fieldKind: 'output', + name: 'item', + title: 'Collection Item', + description: 'The item for the current loop iteration, or None when the collection is empty', + type: { + name: 'CollectionItemField', + cardinality: 'SINGLE', + batch: false, + }, + ui_hidden: false, + ui_type: 'CollectionItemField', + output_scope: 'iteration', + }, + index: { + fieldKind: 'output', + name: 'index', + title: 'Index', + description: 'The index for the current loop iteration', + type: { + name: 'IntegerField', + cardinality: 'SINGLE', + batch: false, + }, + ui_hidden: false, + output_scope: 'iteration', + }, + total: { + fieldKind: 'output', + name: 'total', + title: 'Total', + description: 'The total number of items in the loop collection', + type: { + name: 'IntegerField', + cardinality: 'SINGLE', + batch: false, + }, + ui_hidden: false, + output_scope: 'iteration', + }, + state: { + fieldKind: 'output', + name: 'state', + title: 'State', + description: 'The state for the current loop iteration', + type: { + name: 'LoopState', + cardinality: 'SINGLE', + batch: false, + }, + ui_hidden: false, + output_scope: 'iteration', + }, + output_collection: { + fieldKind: 'output', + name: 'output_collection', + title: 'Output Collection', + description: 'The collected loop body outputs', + type: { + name: 'CollectionField', + cardinality: 'COLLECTION', + batch: false, + }, + ui_hidden: false, + ui_type: 'CollectionField', + output_scope: 'final', + }, + final_state: { + fieldKind: 'output', + name: 'final_state', + title: 'Final State', + description: 'The final loop state', + type: { + name: 'LoopState', + cardinality: 'SINGLE', + batch: false, + }, + ui_hidden: false, + output_scope: 'final', + }, + }, + useCache: true, + nodePack: 'invokeai', + classification: 'stable', + category: 'other', +}; + +export const for_return: InvocationTemplate = { + title: 'ForReturn', + type: 'for_return', + version: '1.3.2', + tags: [], + description: '', + outputType: 'for_return_output', + inputs: { + loop_linkage: { + name: 'loop_linkage', + title: 'Loop Linkage', + required: false, + default: undefined, + description: 'The loop linkage from the matching For', + fieldKind: 'input', + input: 'connection', + ui_hidden: false, + ui_type: 'AnyField', + type: { + name: 'AnyField', + cardinality: 'SINGLE', + batch: false, + }, + }, + output: { + name: 'output', + title: 'Output', + required: false, + default: undefined, + description: 'The output item to append to the loop output collection', + fieldKind: 'input', + input: 'connection', + ui_hidden: false, + ui_type: 'CollectionItemField', + type: { + name: 'CollectionItemField', + cardinality: 'SINGLE', + batch: false, + }, + }, + state: { + name: 'state', + title: 'State', + required: false, + default: undefined, + description: 'The state to pass to the next loop iteration', + fieldKind: 'input', + input: 'connection', + ui_hidden: false, + type: { + name: 'LoopState', + cardinality: 'SINGLE', + batch: false, + }, + }, + continue_condition: { + name: 'continue_condition', + title: 'Continue Condition', + required: false, + default: true, + description: 'Whether to schedule the next loop iteration; false finalizes the loop', + fieldKind: 'input', + input: 'any', + ui_hidden: false, + type: { + name: 'BooleanField', + cardinality: 'SINGLE', + batch: false, + }, + }, + }, + outputs: { + output: { + fieldKind: 'output', + name: 'output', + title: 'Output', + description: 'The output item to append to the loop output collection', + type: { + name: 'CollectionItemField', + cardinality: 'SINGLE', + batch: false, + }, + ui_hidden: true, + ui_type: 'CollectionItemField', + }, + state: { + fieldKind: 'output', + name: 'state', + title: 'State', + description: 'The state to pass to the next loop iteration', + type: { + name: 'LoopState', + cardinality: 'SINGLE', + batch: false, + }, + ui_hidden: true, + }, + }, + useCache: true, + nodePack: 'invokeai', + classification: 'stable', + category: 'other', +}; + export const templates: Templates = { add, call_saved_workflow, diff --git a/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.test.ts b/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.test.ts index 1eef0794436..f870084eeb9 100644 --- a/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.test.ts +++ b/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.test.ts @@ -2,7 +2,7 @@ import { deepClone } from 'common/util/deepClone'; import { set } from 'es-toolkit/compat'; import { callSavedWorkflowDynamicFieldsChanged, nodesSliceConfig } from 'features/nodes/store/nodesSlice'; import type { IntegerFieldInputTemplate } from 'features/nodes/types/field'; -import type { InvocationTemplate } from 'features/nodes/types/invocation'; +import type { AnyEdge, InvocationTemplate } from 'features/nodes/types/invocation'; import { describe, expect, it } from 'vitest'; import { @@ -13,9 +13,12 @@ import { import { add, buildEdge, + buildLoopLinkageEdge, buildNode, call_saved_workflow, collect, + for_loop, + for_return, img_resize, main_model_loader, sub, @@ -101,6 +104,18 @@ const ifTemplate: InvocationTemplate = { classification: 'stable', }; +const buildConnectorNode = (id: string) => ({ + id, + type: 'connector' as const, + position: { x: 0, y: 0 }, + data: { + id, + type: 'connector' as const, + label: 'Connector', + isOpen: true, + }, +}); + const floatOutputTemplate: InvocationTemplate = { title: 'Float Output', type: 'float_output', @@ -219,18 +234,6 @@ const workflowReturnTemplate: InvocationTemplate = { classification: 'beta', }; -const buildConnectorNode = (id: string) => ({ - id, - type: 'connector' as const, - position: { x: 0, y: 0 }, - data: { - id, - type: 'connector' as const, - label: 'Connector', - isOpen: true, - }, -}); - describe(validateConnection.name, () => { it('should reject invalid connection to self', () => { const c = { source: 'add', sourceHandle: 'value', target: 'add', targetHandle: 'a' }; @@ -485,6 +488,595 @@ describe(validateConnection.name, () => { expect(r).toEqual('nodes.fieldTypesMustMatch'); }); + describe('loop linkage', () => { + it('accepts a For to ForReturn linkage connection', () => { + const forNode = buildNode(for_loop); + const returnNode = buildNode(for_return); + + expect( + validateConnection( + { + source: forNode.id, + sourceHandle: 'loop_linkage', + target: returnNode.id, + targetHandle: 'loop_linkage', + }, + [forNode, returnNode], + [], + templates, + null + ) + ).toBeNull(); + }); + + it('rejects a linkage connection with a non-linkage handle', () => { + const forNode = buildNode(for_loop); + const returnNode = buildNode(for_return); + + expect( + validateConnection( + { + source: forNode.id, + sourceHandle: 'item', + target: returnNode.id, + targetHandle: 'loop_linkage', + }, + [forNode, returnNode], + [], + templates, + null + ) + ).toBe('nodes.forLoopLinkageInvalid'); + }); + + it('accepts the For side of a connector linkage alias', () => { + const forNode = buildNode(for_loop); + const connector = buildConnectorNode('connector-1'); + + expect( + validateConnection( + { + source: forNode.id, + sourceHandle: 'loop_linkage', + target: connector.id, + targetHandle: CONNECTOR_INPUT_HANDLE, + }, + [forNode, connector], + [], + templates, + null + ) + ).toBeNull(); + }); + + it('rejects the For side of an alias that would reuse an already-linked ForReturn', () => { + const forNode = buildNode(for_loop); + const existingForNode = buildNode(for_loop); + const connector = buildConnectorNode('connector-1'); + const returnNode = buildNode(for_return); + const edges = [ + buildLoopLinkageEdge(existingForNode.id, returnNode.id), + buildEdge(connector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), + ]; + + expect( + validateConnection( + { + source: forNode.id, + sourceHandle: 'loop_linkage', + target: connector.id, + targetHandle: CONNECTOR_INPUT_HANDLE, + }, + [forNode, existingForNode, connector, returnNode], + edges, + { ...templates, for: for_loop, for_return }, + null + ) + ).toBe('nodes.forLoopLinkageDuplicate'); + }); + + it('rejects the For side of a connector linkage alias with an occupied connector input', () => { + const forNode = buildNode(for_loop); + const sourceNode = buildNode(sub); + const connector = buildConnectorNode('connector-1'); + const edges = [buildEdge(sourceNode.id, 'value', connector.id, CONNECTOR_INPUT_HANDLE)]; + + expect( + validateConnection( + { + source: forNode.id, + sourceHandle: 'loop_linkage', + target: connector.id, + targetHandle: CONNECTOR_INPUT_HANDLE, + }, + [forNode, sourceNode, connector], + edges, + { ...templates, for: for_loop }, + null + ) + ).toBe('nodes.inputMayOnlyHaveOneConnection'); + }); + + it('accepts the ForReturn side of a complete connector linkage alias', () => { + const forNode = buildNode(for_loop); + const connector = buildConnectorNode('connector-1'); + const returnNode = buildNode(for_return); + const edges = [buildEdge(forNode.id, 'loop_linkage', connector.id, CONNECTOR_INPUT_HANDLE)]; + + expect( + validateConnection( + { + source: connector.id, + sourceHandle: CONNECTOR_OUTPUT_HANDLE, + target: returnNode.id, + targetHandle: 'loop_linkage', + }, + [forNode, connector, returnNode], + edges, + templates, + null + ) + ).toBeNull(); + }); + + it('accepts a connector chain edge between an attached For and ForReturn', () => { + const forNode = buildNode(for_loop); + const firstConnector = buildConnectorNode('connector-1'); + const secondConnector = buildConnectorNode('connector-2'); + const returnNode = buildNode(for_return); + const edges = [ + buildEdge(forNode.id, 'loop_linkage', firstConnector.id, CONNECTOR_INPUT_HANDLE), + buildEdge(secondConnector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), + ]; + + expect( + validateConnection( + { + source: firstConnector.id, + sourceHandle: CONNECTOR_OUTPUT_HANDLE, + target: secondConnector.id, + targetHandle: CONNECTOR_INPUT_HANDLE, + }, + [forNode, firstConnector, secondConnector, returnNode], + edges, + templates, + null + ) + ).toBeNull(); + }); + + it('accepts a connector deletion splice that preserves a loop linkage chain', () => { + const forNode = buildNode(for_loop); + const firstConnector = buildConnectorNode('connector-1'); + const terminalConnector = buildConnectorNode('connector-2'); + const returnNode = buildNode(for_return); + const edges = [ + buildEdge(forNode.id, 'loop_linkage', firstConnector.id, CONNECTOR_INPUT_HANDLE), + buildEdge(firstConnector.id, CONNECTOR_OUTPUT_HANDLE, terminalConnector.id, CONNECTOR_INPUT_HANDLE), + buildEdge(terminalConnector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), + ]; + + expect( + getConnectorDeletionSpliceConnections( + terminalConnector.id, + [forNode, firstConnector, terminalConnector, returnNode], + edges, + templates, + validateConnection + ) + ).toEqual([ + { + source: firstConnector.id, + sourceHandle: CONNECTOR_OUTPUT_HANDLE, + target: returnNode.id, + targetHandle: 'loop_linkage', + }, + ]); + }); + + it('rejects a connector loop linkage chain that creates a cycle', () => { + const forNode = buildNode(for_loop); + const firstConnector = buildConnectorNode('connector-1'); + const secondConnector = buildConnectorNode('connector-2'); + const edges = [ + buildEdge(forNode.id, 'loop_linkage', firstConnector.id, CONNECTOR_INPUT_HANDLE), + buildEdge(secondConnector.id, CONNECTOR_OUTPUT_HANDLE, firstConnector.id, CONNECTOR_INPUT_HANDLE), + ]; + + expect( + validateConnection( + { + source: firstConnector.id, + sourceHandle: CONNECTOR_OUTPUT_HANDLE, + target: secondConnector.id, + targetHandle: CONNECTOR_INPUT_HANDLE, + }, + [forNode, firstConnector, secondConnector], + edges, + { ...templates, for: for_loop }, + null + ) + ).toBe('nodes.connectionWouldCreateCycle'); + }); + + it('rejects an unresolved connector linkage alias for an already-owned ForReturn', () => { + const connector = buildConnectorNode('connector-1'); + const forNode = buildNode(for_loop); + const returnNode = buildNode(for_return); + const existingForNode = buildNode(for_loop); + const existingReturnNode = buildNode(for_return); + const edges = [buildLoopLinkageEdge(existingForNode.id, returnNode.id)]; + + expect( + validateConnection( + { + source: connector.id, + sourceHandle: CONNECTOR_OUTPUT_HANDLE, + target: returnNode.id, + targetHandle: 'loop_linkage', + }, + [connector, forNode, returnNode, existingForNode, existingReturnNode], + edges, + templates, + null + ) + ).toBe('nodes.forLoopLinkageDuplicate'); + }); + + it('rejects a connector linkage alias reused by a second ForReturn', () => { + const forNode = buildNode(for_loop); + const connector = buildConnectorNode('connector-1'); + const firstReturnNode = buildNode(for_return); + const secondReturnNode = buildNode(for_return); + const edges = [ + buildEdge(forNode.id, 'loop_linkage', connector.id, CONNECTOR_INPUT_HANDLE), + buildEdge(connector.id, CONNECTOR_OUTPUT_HANDLE, firstReturnNode.id, 'loop_linkage'), + ]; + + expect( + validateConnection( + { + source: connector.id, + sourceHandle: CONNECTOR_OUTPUT_HANDLE, + target: secondReturnNode.id, + targetHandle: 'loop_linkage', + }, + [forNode, connector, firstReturnNode, secondReturnNode], + edges, + templates, + null + ) + ).toBe('nodes.forLoopLinkageInvalid'); + }); + + it('rejects a connector linkage alias that shares a ForReturn with another alias', () => { + const firstForNode = buildNode(for_loop); + const secondForNode = buildNode(for_loop); + const firstConnector = buildConnectorNode('connector-1'); + const secondConnector = buildConnectorNode('connector-2'); + const returnNode = buildNode(for_return); + const edges = [ + buildEdge(firstForNode.id, 'loop_linkage', firstConnector.id, CONNECTOR_INPUT_HANDLE), + buildEdge(firstConnector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), + buildEdge(secondConnector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), + ]; + + expect( + validateConnection( + { + source: secondForNode.id, + sourceHandle: 'loop_linkage', + target: secondConnector.id, + targetHandle: CONNECTOR_INPUT_HANDLE, + }, + [firstForNode, secondForNode, firstConnector, secondConnector, returnNode], + edges, + templates, + null + ) + ).toBe('nodes.forLoopLinkageDuplicate'); + }); + + it('rejects a loop linkage connector reused for ordinary data', () => { + const forNode = buildNode(for_loop); + const connector = buildConnectorNode('connector-1'); + const targetNode = buildNode(sub); + const edges = [buildEdge(forNode.id, 'loop_linkage', connector.id, CONNECTOR_INPUT_HANDLE)]; + + expect( + validateConnection( + { + source: connector.id, + sourceHandle: CONNECTOR_OUTPUT_HANDLE, + target: targetNode.id, + targetHandle: 'a', + }, + [forNode, connector, targetNode], + edges, + { ...templates, for: for_loop }, + null + ) + ).toBe('nodes.forLoopLinkageInvalid'); + }); + + it('rejects linkage connections that do not join For and ForReturn', () => { + const sourceNode = buildNode(add); + const returnNode = buildNode(for_return); + + expect( + validateConnection( + { + source: sourceNode.id, + sourceHandle: 'loop_linkage', + target: returnNode.id, + targetHandle: 'loop_linkage', + }, + [sourceNode, returnNode], + [], + templates, + null + ) + ).toBe('nodes.forLoopLinkageInvalid'); + }); + + it('rejects linkage connections that duplicate either endpoint', () => { + const firstForNode = buildNode(for_loop); + const secondForNode = buildNode(for_loop); + const firstReturnNode = buildNode(for_return); + const secondReturnNode = buildNode(for_return); + const existingEdge = buildLoopLinkageEdge(firstForNode.id, firstReturnNode.id); + const nodes = [firstForNode, secondForNode, firstReturnNode, secondReturnNode]; + + expect( + validateConnection( + { + source: firstForNode.id, + sourceHandle: 'loop_linkage', + target: secondReturnNode.id, + targetHandle: 'loop_linkage', + }, + nodes, + [existingEdge], + templates, + null + ) + ).toBe('nodes.forLoopLinkageDuplicate'); + + expect( + validateConnection( + { + source: secondForNode.id, + sourceHandle: 'loop_linkage', + target: firstReturnNode.id, + targetHandle: 'loop_linkage', + }, + nodes, + [existingEdge], + templates, + null + ) + ).toBe('nodes.forLoopLinkageDuplicate'); + }); + }); + + describe('loop output scopes', () => { + const loopTemplates = { for: for_loop, for_return }; + const loopSinkTemplate: InvocationTemplate = { + ...for_return, + title: 'Loop Sink', + type: 'loop_sink', + outputType: 'loop_sink_output', + outputs: {}, + }; + + it('rejects a final-scoped output connected into an existing iteration body', () => { + const forNode = buildNode(for_loop); + const returnNode = buildNode(for_return); + const nodes = [forNode, returnNode]; + const edges = [buildEdge(forNode.id, 'item', returnNode.id, 'output')]; + const connection = { + source: forNode.id, + sourceHandle: 'final_state', + target: returnNode.id, + targetHandle: 'state', + }; + + expect(validateConnection(connection, nodes, edges, loopTemplates, null)).toEqual( + 'nodes.loopOutputScopeConflict' + ); + }); + + it('rejects an iteration-scoped output that makes an existing final output part of the body', () => { + const forNode = buildNode(for_loop); + const returnNode = buildNode(for_return); + const nodes = [forNode, returnNode]; + const edges = [buildEdge(forNode.id, 'final_state', returnNode.id, 'state')]; + const connection = { + source: forNode.id, + sourceHandle: 'item', + target: returnNode.id, + targetHandle: 'output', + }; + + expect(validateConnection(connection, nodes, edges, loopTemplates, null)).toEqual( + 'nodes.loopOutputScopeConflict' + ); + }); + + it('rejects a final-scoped output connected to a descendant of an iteration body node', () => { + const forNode = buildNode(for_loop); + const bodyNode = buildNode(ifTemplate); + const returnNode = buildNode(for_return); + const nodes = [forNode, bodyNode, returnNode]; + const edges = [ + buildEdge(forNode.id, 'item', bodyNode.id, 'true_input'), + buildEdge(bodyNode.id, 'value', returnNode.id, 'output'), + ]; + const connection = { + source: forNode.id, + sourceHandle: 'final_state', + target: returnNode.id, + targetHandle: 'state', + }; + + expect(validateConnection(connection, nodes, edges, { ...loopTemplates, if: ifTemplate }, null)).toEqual( + 'nodes.loopOutputScopeConflict' + ); + }); + + it('rejects a final-scoped output routed into the iteration body through a connector', () => { + const forNode = buildNode(for_loop); + const connectorNode = buildConnectorNode('connector'); + const returnNode = buildNode(for_return); + const nodes = [forNode, connectorNode, returnNode]; + const edges = [ + buildEdge(forNode.id, 'item', returnNode.id, 'output'), + buildEdge(forNode.id, 'final_state', connectorNode.id, CONNECTOR_INPUT_HANDLE), + ]; + const connection = { + source: connectorNode.id, + sourceHandle: CONNECTOR_OUTPUT_HANDLE, + target: returnNode.id, + targetHandle: 'state', + }; + + expect(validateConnection(connection, nodes, edges, loopTemplates, null)).toEqual( + 'nodes.loopOutputScopeConflict' + ); + }); + + it('rejects a final-scoped output that reaches the iteration body through downstream nodes', () => { + const forNode = buildNode(for_loop); + const bodyNode = buildNode(ifTemplate); + const downstreamNode = buildNode(ifTemplate); + const downstreamNode2 = buildNode(ifTemplate); + const returnNode = buildNode(for_return); + const nodes = [forNode, bodyNode, downstreamNode, downstreamNode2, returnNode]; + const edges = [ + buildEdge(forNode.id, 'item', bodyNode.id, 'true_input'), + buildEdge(bodyNode.id, 'value', returnNode.id, 'output'), + buildEdge(downstreamNode.id, 'value', downstreamNode2.id, 'true_input'), + buildEdge(downstreamNode2.id, 'value', returnNode.id, 'state'), + ]; + const connection = { + source: forNode.id, + sourceHandle: 'final_state', + target: downstreamNode.id, + targetHandle: 'true_input', + }; + + expect(validateConnection(connection, nodes, edges, { ...loopTemplates, if: ifTemplate }, null)).toEqual( + 'nodes.loopOutputScopeConflict' + ); + }); + + it('accepts iteration-scoped outputs within the body', () => { + const forNode = buildNode(for_loop); + const returnNode = buildNode(for_return); + const nodes = [forNode, returnNode]; + const edges = [buildEdge(forNode.id, 'item', returnNode.id, 'output')]; + const connection = { + source: forNode.id, + sourceHandle: 'state', + target: returnNode.id, + targetHandle: 'state', + }; + + expect(validateConnection(connection, nodes, edges, loopTemplates, null)).toBeNull(); + }); + + it('accepts final-scoped outputs outside the iteration body', () => { + const forNode = buildNode(for_loop); + const bodyReturnNode = buildNode(for_return); + const afterLoopNode = buildNode(for_loop); + const nodes = [forNode, bodyReturnNode, afterLoopNode]; + const edges = [buildEdge(forNode.id, 'item', bodyReturnNode.id, 'output')]; + const connection = { + source: forNode.id, + sourceHandle: 'final_state', + target: afterLoopNode.id, + targetHandle: 'state', + }; + + expect(validateConnection(connection, nodes, edges, loopTemplates, null)).toBeNull(); + }); + + it.each([ + ['iteration', 'final', 'extension'], + ['final', 'iteration', 'extension'], + ['extension', 'final', 'iteration'], + ] as const)('rejects scope overlap regardless of incremental edge order: %s, %s, %s', (...order) => { + const forNode = buildNode(for_loop); + const bodyNode = buildNode(ifTemplate); + const afterLoopNode = buildNode(loopSinkTemplate); + const nodes = [forNode, bodyNode, afterLoopNode]; + const edgeByName = { + iteration: buildEdge(forNode.id, 'item', bodyNode.id, 'true_input'), + final: buildEdge(forNode.id, 'final_state', afterLoopNode.id, 'state'), + extension: buildEdge(bodyNode.id, 'value', afterLoopNode.id, 'output'), + }; + const templates = { + ...loopTemplates, + if: ifTemplate, + loop_sink: loopSinkTemplate, + }; + const acceptedEdges: AnyEdge[] = []; + const results = order.map((name) => { + const edge = edgeByName[name]; + if (edge.type !== 'default' || !edge.sourceHandle || !edge.targetHandle) { + throw new Error('Expected a default edge with field handles'); + } + const result = validateConnection( + { + source: edge.source, + sourceHandle: edge.sourceHandle, + target: edge.target, + targetHandle: edge.targetHandle, + }, + nodes, + acceptedEdges, + templates, + null + ); + if (result === null) { + acceptedEdges.push(edge); + } + return result; + }); + + expect(results).toContain('nodes.loopOutputScopeConflict'); + }); + + it('allows an unrelated connection when the graph already contains a scope conflict', () => { + const forNode = buildNode(for_loop); + const bodyNode = buildNode(ifTemplate); + const afterLoopNode = buildNode(loopSinkTemplate); + const addNode = buildNode(add); + const subNode = buildNode(sub); + const nodes = [forNode, bodyNode, afterLoopNode, addNode, subNode]; + const edges = [ + buildEdge(forNode.id, 'item', bodyNode.id, 'true_input'), + buildEdge(forNode.id, 'final_state', afterLoopNode.id, 'state'), + buildEdge(bodyNode.id, 'value', afterLoopNode.id, 'output'), + ]; + const templates = { + ...loopTemplates, + add, + sub, + if: ifTemplate, + loop_sink: loopSinkTemplate, + }; + const connection = { + source: addNode.id, + sourceHandle: 'value', + target: subNode.id, + targetHandle: 'a', + }; + + expect(validateConnection(connection, nodes, edges, templates, null)).toBeNull(); + }); + }); + it('should reject mismatched types between if node branch inputs', () => { const n1 = buildNode(add); const n2 = buildNode(img_resize); diff --git a/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.ts b/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.ts index 710e49de7cf..5d2a88fd0b9 100644 --- a/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.ts +++ b/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.ts @@ -5,15 +5,19 @@ import { CONNECTOR_INPUT_HANDLE, CONNECTOR_OUTPUT_HANDLE, resolveConnectorSource, + resolveLoopLinkagePath, } from 'features/nodes/store/util/connectorTopology'; import { getCollectItemType } from 'features/nodes/store/util/getCollectItemType'; import { getHasCycles } from 'features/nodes/store/util/getHasCycles'; import { validateConnectionTypes } from 'features/nodes/store/util/validateConnectionTypes'; +import { LOOP_LINKAGE_FIELD } from 'features/nodes/types/constants'; import type { FieldType } from 'features/nodes/types/field'; import type { AnyEdge, AnyNode, InvocationNode } from 'features/nodes/types/invocation'; import { getInvocationNodeInputTemplate, isConnectorNode, isInvocationNode } from 'features/nodes/types/invocation'; import type { SetNonNullable } from 'type-fest'; +import { isLoopLinkageEdge } from './reactFlowUtil'; + type Connection = SetNonNullable; type ValidateConnectionFunc = ( @@ -195,6 +199,182 @@ const getEffectiveSourceForEdge = ( return getEffectiveSource(edge.source, edge.sourceHandle, nodes, edges, templates); }; +const getOutputScopeConflicts = (nodes: AnyNode[], edges: AnyEdge[], templates: Templates) => { + const nodeById = new Map(nodes.map((node) => [node.id, node])); + const connectorInputById = new Map(); + const scopedOutputByEndpoint = new Map(); + + for (const node of nodes) { + if (!isInvocationNode(node)) { + continue; + } + const template = templates[node.data.type]; + if (!template) { + continue; + } + for (const output of Object.values(template.outputs)) { + if (output.output_scope) { + scopedOutputByEndpoint.set(`${node.id}\0${output.name}`, { + nodeId: node.id, + scope: output.output_scope, + }); + } + } + } + + if (scopedOutputByEndpoint.size === 0) { + return new Set(); + } + + for (const edge of edges) { + if ( + edge.type === 'default' && + edge.targetHandle === CONNECTOR_INPUT_HANDLE && + !connectorInputById.has(edge.target) + ) { + connectorInputById.set(edge.target, edge); + } + } + + type ScopedOutput = { nodeId: string; scope: 'iteration' | 'final' }; + const scopedOutputCache = new Map(); + const resolveScopedOutput = ( + sourceId: string, + sourceHandle: string, + visited = new Set() + ): ScopedOutput | null => { + const endpoint = `${sourceId}\0${sourceHandle}`; + const cached = scopedOutputCache.get(endpoint); + if (cached !== undefined) { + return cached; + } + + const directScopedOutput = scopedOutputByEndpoint.get(endpoint); + if (directScopedOutput) { + scopedOutputCache.set(endpoint, directScopedOutput); + return directScopedOutput; + } + + const sourceNode = nodeById.get(sourceId); + if ( + !sourceNode || + !isConnectorNode(sourceNode) || + sourceHandle !== CONNECTOR_OUTPUT_HANDLE || + visited.has(sourceId) + ) { + scopedOutputCache.set(endpoint, null); + return null; + } + + visited.add(sourceId); + const connectorInput = connectorInputById.get(sourceId); + if (connectorInput?.type !== 'default' || typeof connectorInput.sourceHandle !== 'string') { + scopedOutputCache.set(endpoint, null); + return null; + } + + const scopedOutput = resolveScopedOutput(connectorInput.source, connectorInput.sourceHandle, visited); + scopedOutputCache.set(endpoint, scopedOutput); + return scopedOutput; + }; + + const targetsByScopeByNode = new Map; finalTargets: Set }>(); + const targetsBySource = new Map>(); + for (const edge of edges) { + if (edge.type !== 'default' || typeof edge.sourceHandle !== 'string') { + continue; + } + + const targets = targetsBySource.get(edge.source) ?? new Set(); + targets.add(edge.target); + targetsBySource.set(edge.source, targets); + + const scopedOutput = resolveScopedOutput(edge.source, edge.sourceHandle); + if (!scopedOutput) { + continue; + } + const targetsByScope = targetsByScopeByNode.get(scopedOutput.nodeId) ?? { + iterationTargets: new Set(), + finalTargets: new Set(), + }; + if (scopedOutput.scope === 'iteration') { + targetsByScope.iterationTargets.add(edge.target); + } else { + targetsByScope.finalTargets.add(edge.target); + } + targetsByScopeByNode.set(scopedOutput.nodeId, targetsByScope); + } + + const conflicts = new Set(); + for (const [nodeId, { iterationTargets, finalTargets }] of targetsByScopeByNode) { + const reachableBodyNodes = new Set(iterationTargets); + const pendingBodyNodes = [...iterationTargets]; + while (pendingBodyNodes.length > 0) { + const currentNodeId = pendingBodyNodes.pop(); + if (!currentNodeId) { + continue; + } + + for (const targetNodeId of targetsBySource.get(currentNodeId) ?? []) { + if (reachableBodyNodes.has(targetNodeId)) { + continue; + } + reachableBodyNodes.add(targetNodeId); + pendingBodyNodes.push(targetNodeId); + } + } + + const reachableFinalNodes = new Set(finalTargets); + const pendingFinalNodes = [...finalTargets]; + while (pendingFinalNodes.length > 0) { + const currentNodeId = pendingFinalNodes.pop(); + if (!currentNodeId) { + continue; + } + + for (const targetNodeId of targetsBySource.get(currentNodeId) ?? []) { + if (reachableFinalNodes.has(targetNodeId)) { + continue; + } + reachableFinalNodes.add(targetNodeId); + pendingFinalNodes.push(targetNodeId); + } + } + + for (const targetId of reachableFinalNodes) { + if (reachableBodyNodes.has(targetId)) { + conflicts.add(`${nodeId}\0${targetId}`); + } + } + } + + return conflicts; +}; + +const hasOutputScopeConflict = (connection: Connection, nodes: AnyNode[], edges: AnyEdge[], templates: Templates) => { + const existingConflicts = getOutputScopeConflicts(nodes, edges, templates); + const candidateEdge: AnyEdge = { + ...connection, + id: '__candidate_connection__', + type: 'default', + }; + const stagedConflicts = getOutputScopeConflicts(nodes, [...edges, candidateEdge], templates); + return [...stagedConflicts].some((conflict) => !existingConflicts.has(conflict)); +}; + +const getResolvedLoopLinkages = (nodes: AnyNode[], edges: AnyEdge[]) => + edges.flatMap((edge) => { + if ( + edge.type !== 'default' || + edge.targetHandle !== LOOP_LINKAGE_FIELD || + !isConnectorNode(nodes.find((node) => node.id === edge.source)) + ) { + return []; + } + const path = resolveLoopLinkagePath(edge, nodes, edges); + return path ? [path] : []; + }); + /** * Validates a connection between two fields * @returns A translation key for an error if the connection is invalid, otherwise null @@ -207,6 +387,204 @@ export const validateConnection: ValidateConnectionFunc = ( ignoreEdge, strict = true ): string | null => { + const sourceNode = nodes.find((node) => node.id === c.source); + const targetNode = nodes.find((node) => node.id === c.target); + const filteredEdges = edges.filter((edge) => edge.id !== ignoreEdge?.id); + const resolvedConnectorSource = + sourceNode && isConnectorNode(sourceNode) && c.sourceHandle === CONNECTOR_OUTPUT_HANDLE + ? resolveConnectorSource(sourceNode.id, nodes, filteredEdges) + : null; + const hasConnectorLoopLinkage = + c.sourceHandle === CONNECTOR_OUTPUT_HANDLE && + c.targetHandle === CONNECTOR_INPUT_HANDLE && + isConnectorNode(sourceNode) && + isConnectorNode(targetNode) && + resolvedConnectorSource?.fieldName === LOOP_LINKAGE_FIELD; + const hasLoopLinkageHandle = + c.sourceHandle === LOOP_LINKAGE_FIELD || c.targetHandle === LOOP_LINKAGE_FIELD || hasConnectorLoopLinkage; + if (hasLoopLinkageHandle) { + if (!sourceNode || !targetNode) { + return 'nodes.missingNode'; + } + + if ( + c.sourceHandle === LOOP_LINKAGE_FIELD && + isInvocationNode(sourceNode) && + sourceNode.data.type === 'for' && + isConnectorNode(targetNode) && + c.targetHandle === CONNECTOR_INPUT_HANDLE + ) { + if ( + filteredEdges.some( + (edge) => + edge.type === 'default' && edge.target === targetNode.id && edge.targetHandle === CONNECTOR_INPUT_HANDLE + ) + ) { + return 'nodes.inputMayOnlyHaveOneConnection'; + } + if ( + filteredEdges.some( + (edge) => + edge.source === c.source && + ((edge.type === 'loop_linkage' && edge.sourceHandle === LOOP_LINKAGE_FIELD) || + (edge.type === 'default' && edge.sourceHandle === LOOP_LINKAGE_FIELD)) + ) + ) { + return 'nodes.forLoopLinkageDuplicate'; + } + + const candidateEdge = { ...c, id: '__candidate_loop_linkage__', type: 'default' } satisfies AnyEdge; + const stagedEdges = [...filteredEdges, candidateEdge]; + if (getHasCycles(c.source, c.target, nodes, stagedEdges)) { + return 'nodes.connectionWouldCreateCycle'; + } + const terminalTargetEdges = getConnectorTerminalTargetEdges(targetNode.id, nodes, stagedEdges); + for (const terminalTargetEdge of terminalTargetEdges) { + const terminalNode = nodes.find((node) => node.id === terminalTargetEdge.target); + if ( + !terminalNode || + !isInvocationNode(terminalNode) || + terminalNode.data.type !== 'for_return' || + terminalTargetEdge.targetHandle !== LOOP_LINKAGE_FIELD || + !resolveLoopLinkagePath(terminalTargetEdge, nodes, stagedEdges) + ) { + return 'nodes.forLoopLinkageInvalid'; + } + } + + const candidatePaths = getResolvedLoopLinkages(nodes, stagedEdges).filter((path) => + path.edgeIds.includes(candidateEdge.id) + ); + if (candidatePaths.length > 1) { + return 'nodes.forLoopLinkageInvalid'; + } + const candidatePath = candidatePaths[0]; + if ( + candidatePath && + (filteredEdges.some( + (edge) => + isLoopLinkageEdge(edge) && + (edge.source === candidatePath.forNodeId || edge.target === candidatePath.returnNodeId) + ) || + getResolvedLoopLinkages(nodes, filteredEdges).some( + (existingPath) => + existingPath.forNodeId === candidatePath.forNodeId || + existingPath.returnNodeId === candidatePath.returnNodeId + )) + ) { + return 'nodes.forLoopLinkageDuplicate'; + } + return null; + } + + if (hasConnectorLoopLinkage) { + if (filteredEdges.some(getTargetEqualityPredicate(c))) { + return 'nodes.inputMayOnlyHaveOneConnection'; + } + + const candidateEdge = { ...c, id: '__candidate_loop_linkage__', type: 'default' } satisfies AnyEdge; + const stagedEdges = [...filteredEdges, candidateEdge]; + if (getHasCycles(c.source, c.target, nodes, stagedEdges)) { + return 'nodes.connectionWouldCreateCycle'; + } + const terminalTargetEdges = getConnectorTerminalTargetEdges(targetNode.id, nodes, stagedEdges); + for (const terminalTargetEdge of terminalTargetEdges) { + const terminalNode = nodes.find((node) => node.id === terminalTargetEdge.target); + if ( + !terminalNode || + !isInvocationNode(terminalNode) || + terminalNode.data.type !== 'for_return' || + terminalTargetEdge.targetHandle !== LOOP_LINKAGE_FIELD || + !resolveLoopLinkagePath(terminalTargetEdge, nodes, stagedEdges) + ) { + return 'nodes.forLoopLinkageInvalid'; + } + } + + const candidatePaths = getResolvedLoopLinkages(nodes, stagedEdges).filter((path) => + path.edgeIds.includes(candidateEdge.id) + ); + if (candidatePaths.length > 1) { + return 'nodes.forLoopLinkageInvalid'; + } + + const candidatePath = candidatePaths[0]; + if ( + candidatePath && + (filteredEdges.some( + (edge) => + isLoopLinkageEdge(edge) && + (edge.source === candidatePath.forNodeId || edge.target === candidatePath.returnNodeId) + ) || + getResolvedLoopLinkages(nodes, filteredEdges).some( + (existingPath) => + existingPath.forNodeId === candidatePath.forNodeId || + existingPath.returnNodeId === candidatePath.returnNodeId + )) + ) { + return 'nodes.forLoopLinkageDuplicate'; + } + return null; + } + + if ( + isConnectorNode(sourceNode) && + sourceNode && + c.sourceHandle === CONNECTOR_OUTPUT_HANDLE && + isInvocationNode(targetNode) && + targetNode.data.type === 'for_return' && + c.targetHandle === LOOP_LINKAGE_FIELD + ) { + if ( + filteredEdges.some((edge) => isLoopLinkageEdge(edge) && edge.target === targetNode.id) || + getResolvedLoopLinkages(nodes, filteredEdges).some((path) => path.returnNodeId === targetNode.id) + ) { + return 'nodes.forLoopLinkageDuplicate'; + } + const resolvedSource = resolveConnectorSource(sourceNode.id, nodes, filteredEdges); + if (resolvedSource && resolvedSource.fieldName !== LOOP_LINKAGE_FIELD) { + return 'nodes.forLoopLinkageInvalid'; + } + + const candidateEdge = { ...c, id: '__candidate_loop_linkage__', type: 'default' } satisfies AnyEdge; + const stagedEdges = [...filteredEdges, candidateEdge]; + const path = resolveLoopLinkagePath(candidateEdge, nodes, stagedEdges); + if (!path) { + // A connector can be wired to ForReturn before its upstream source is attached. + return resolvedSource ? 'nodes.forLoopLinkageInvalid' : null; + } + + const hasDuplicateLinkage = filteredEdges.some( + (edge) => isLoopLinkageEdge(edge) && (edge.source === path.forNodeId || edge.target === path.returnNodeId) + ); + const hasDuplicateConnectorPath = getResolvedLoopLinkages(nodes, filteredEdges).some( + (existingPath) => existingPath.forNodeId === path.forNodeId || existingPath.returnNodeId === path.returnNodeId + ); + if (hasDuplicateLinkage || hasDuplicateConnectorPath) { + return 'nodes.forLoopLinkageDuplicate'; + } + return null; + } + + if ( + c.sourceHandle === LOOP_LINKAGE_FIELD && + c.targetHandle === LOOP_LINKAGE_FIELD && + isInvocationNode(sourceNode) && + sourceNode.data.type === 'for' && + isInvocationNode(targetNode) && + targetNode.data.type === 'for_return' + ) { + if ( + filteredEdges.some((edge) => isLoopLinkageEdge(edge) && (edge.source === c.source || edge.target === c.target)) + ) { + return 'nodes.forLoopLinkageDuplicate'; + } + return null; + } + + return 'nodes.forLoopLinkageInvalid'; + } + if (c.source === c.target) { return 'nodes.cannotConnectToSelf'; } @@ -335,6 +713,14 @@ export const validateConnection: ValidateConnectionFunc = ( const { node: resolvedSourceNode, handle: sourceHandle, fieldTemplate: sourceFieldTemplate } = effectiveSource; + if (sourceHandle === LOOP_LINKAGE_FIELD) { + return 'nodes.forLoopLinkageInvalid'; + } + + if (hasOutputScopeConflict(c, nodes, filteredEdges, templates)) { + return 'nodes.loopOutputScopeConflict'; + } + if (targetNode.data.type === 'collect' && c.targetHandle === 'item') { // Collect nodes shouldn't mix and match field types. const collectItemType = getCollectItemType(templates, nodes, filteredEdges, targetNode.id); diff --git a/invokeai/frontend/web/src/features/nodes/store/util/validateConnectionTypes.test.ts b/invokeai/frontend/web/src/features/nodes/store/util/validateConnectionTypes.test.ts index fc9ce27cb94..2bb7ca05f28 100644 --- a/invokeai/frontend/web/src/features/nodes/store/util/validateConnectionTypes.test.ts +++ b/invokeai/frontend/web/src/features/nodes/store/util/validateConnectionTypes.test.ts @@ -109,6 +109,24 @@ describe(validateConnectionTypes.name, () => { }); }); + describe('LoopState', () => { + it('should accept LoopState connections', () => { + const r = validateConnectionTypes( + { name: 'LoopState', cardinality: 'SINGLE', batch: false }, + { name: 'LoopState', cardinality: 'SINGLE', batch: false } + ); + expect(r).toBe(true); + }); + + it('should reject LoopState connections to other field types', () => { + const r = validateConnectionTypes( + { name: 'LoopState', cardinality: 'SINGLE', batch: false }, + { name: 'IntegerField', cardinality: 'SINGLE', batch: false } + ); + expect(r).toBe(false); + }); + }); + describe('SINGLE_OR_COLLECTION', () => { it('should accept any SINGLE of same type to SINGLE_OR_COLLECTION', () => { const r = validateConnectionTypes( diff --git a/invokeai/frontend/web/src/features/nodes/types/constants.ts b/invokeai/frontend/web/src/features/nodes/types/constants.ts index 7f8b8891f40..ebda317dafd 100644 --- a/invokeai/frontend/web/src/features/nodes/types/constants.ts +++ b/invokeai/frontend/web/src/features/nodes/types/constants.ts @@ -5,6 +5,9 @@ import type { AnyNode } from 'features/nodes/types/invocation'; */ export const HANDLE_TOOLTIP_OPEN_DELAY = 500; +/** The non-executable association between a For and its matching ForReturn. */ +export const LOOP_LINKAGE_FIELD = 'loop_linkage'; + /** * The width of a node in the UI in pixels. */ diff --git a/invokeai/frontend/web/src/features/nodes/types/field.ts b/invokeai/frontend/web/src/features/nodes/types/field.ts index abcba64496d..ca81335f7d9 100644 --- a/invokeai/frontend/web/src/features/nodes/types/field.ts +++ b/invokeai/frontend/web/src/features/nodes/types/field.ts @@ -52,6 +52,7 @@ import { // #region Base schemas & misc const zFieldInput = z.enum(['connection', 'direct', 'any']); +const zFieldOutputScope = z.enum(['iteration', 'final']); const zFieldUIComponent = z.enum(['none', 'textarea', 'slider', 'video-frame-index']); const zFieldInputInstanceBase = z.object({ name: z.string().trim().min(1), @@ -81,6 +82,7 @@ const zFieldInputTemplateBase = zFieldTemplateBase.extend({ }); const zFieldOutputTemplateBase = zFieldTemplateBase.extend({ fieldKind: z.literal('output'), + output_scope: zFieldOutputScope.nullish(), }); const SINGLE = 'SINGLE' as const; diff --git a/invokeai/frontend/web/src/features/nodes/types/invocation.ts b/invokeai/frontend/web/src/features/nodes/types/invocation.ts index 4013e2518cd..05d6b45ed75 100644 --- a/invokeai/frontend/web/src/features/nodes/types/invocation.ts +++ b/invokeai/frontend/web/src/features/nodes/types/invocation.ts @@ -161,6 +161,14 @@ const zDefaultInvocationNodeEdge = z.custom, 'default ); export type DefaultInvocationNodeEdge = z.infer; +const zLoopLinkageInvocationNodeEdgeValidationSchema = z.looseObject({ + type: z.literal('loop_linkage'), +}); +const zLoopLinkageInvocationNodeEdge = z.custom, 'loop_linkage'>>( + (val) => zLoopLinkageInvocationNodeEdgeValidationSchema.safeParse(val).success +); +export type LoopLinkageInvocationNodeEdge = z.infer; + const zInvocationNodeEdgeCollapsedData = z.object({ count: z.number().int().min(1), }); @@ -174,7 +182,11 @@ const zCollapsedInvocationNodeEdge = z.custom zInvocationNodeEdgeCollapsedValidationSchema.safeParse(val).success ); export type CollapsedInvocationNodeEdge = z.infer; -export const zAnyEdge = z.union([zDefaultInvocationNodeEdge, zCollapsedInvocationNodeEdge]); +export const zAnyEdge = z.union([ + zDefaultInvocationNodeEdge, + zLoopLinkageInvocationNodeEdge, + zCollapsedInvocationNodeEdge, +]); export type AnyEdge = z.infer; // #endregion diff --git a/invokeai/frontend/web/src/features/nodes/types/openapi.ts b/invokeai/frontend/web/src/features/nodes/types/openapi.ts index 5945f44ac1d..a77b13a0b6a 100644 --- a/invokeai/frontend/web/src/features/nodes/types/openapi.ts +++ b/invokeai/frontend/web/src/features/nodes/types/openapi.ts @@ -39,7 +39,9 @@ type InvocationOutputSchemaObject = Omit }; }; -export type InvocationFieldSchema = OpenAPIV3_1.SchemaObject & InputFieldJSONSchemaExtra; +export type InvocationInputFieldSchema = OpenAPIV3_1.SchemaObject & InputFieldJSONSchemaExtra; +export type InvocationOutputFieldSchema = OpenAPIV3_1.SchemaObject & OutputFieldJSONSchemaExtra; +export type InvocationFieldSchema = InvocationInputFieldSchema | InvocationOutputFieldSchema; export type OpenAPIV3_1SchemaOrRef = OpenAPIV3_1.ReferenceObject | OpenAPIV3_1.SchemaObject; @@ -77,6 +79,11 @@ export const isInvocationOutputSchemaObject = ( obj: OpenAPIV3_1.ReferenceObject | OpenAPIV3_1.SchemaObject | InvocationOutputSchemaObject ): obj is InvocationOutputSchemaObject => 'class' in obj && obj.class === 'output'; -export const isInvocationFieldSchema = ( +export const isInvocationInputFieldSchema = ( obj: OpenAPIV3_1.ReferenceObject | OpenAPIV3_1.SchemaObject -): obj is InvocationFieldSchema => 'field_kind' in obj; +): obj is InvocationInputFieldSchema => + 'field_kind' in obj && ['input', 'internal', 'node_attribute'].includes(String(obj.field_kind)); + +export const isInvocationOutputFieldSchema = ( + obj: OpenAPIV3_1.ReferenceObject | OpenAPIV3_1.SchemaObject +): obj is InvocationOutputFieldSchema => 'field_kind' in obj && obj.field_kind === 'output'; diff --git a/invokeai/frontend/web/src/features/nodes/types/workflow.ts b/invokeai/frontend/web/src/features/nodes/types/workflow.ts index bfb7b92b18f..0b47b19b044 100644 --- a/invokeai/frontend/web/src/features/nodes/types/workflow.ts +++ b/invokeai/frontend/web/src/features/nodes/types/workflow.ts @@ -57,10 +57,15 @@ const zWorkflowEdgeDefault = zWorkflowEdgeBase.extend({ targetHandle: z.string().trim().min(1), hidden: z.boolean().optional(), }); +const zWorkflowEdgeLoopLinkage = zWorkflowEdgeBase.extend({ + type: z.literal('loop_linkage'), + sourceHandle: z.string().trim().min(1), + targetHandle: z.string().trim().min(1), +}); const zWorkflowEdgeCollapsed = zWorkflowEdgeBase.extend({ type: z.literal('collapsed'), }); -const zWorkflowEdge = z.union([zWorkflowEdgeDefault, zWorkflowEdgeCollapsed]); +const zWorkflowEdge = z.union([zWorkflowEdgeDefault, zWorkflowEdgeLoopLinkage, zWorkflowEdgeCollapsed]); // #endregion // #region Workflow Builder diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.test.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.test.ts index ea772a16bff..1c92f04b30d 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.test.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.test.ts @@ -1,7 +1,17 @@ import { deepClone } from 'common/util/deepClone'; import { callSavedWorkflowDynamicFieldsChanged, nodesSliceConfig } from 'features/nodes/store/nodesSlice'; import { CONNECTOR_INPUT_HANDLE, CONNECTOR_OUTPUT_HANDLE } from 'features/nodes/store/util/connectorTopology'; -import { add, buildEdge, buildNode, img_resize, sub, templates } from 'features/nodes/store/util/testUtils'; +import { + add, + buildEdge, + buildLoopLinkageEdge, + buildNode, + for_loop, + for_return, + img_resize, + sub, + templates, +} from 'features/nodes/store/util/testUtils'; import type { IntegerFieldInputTemplate } from 'features/nodes/types/field'; import { zInvocationNodeData } from 'features/nodes/types/invocation'; import { describe, expect, it } from 'vitest'; @@ -73,6 +83,188 @@ const buildState = (nodes: unknown[], edges: unknown[]) => }) as unknown as Parameters[0]; describe('buildNodesGraph', () => { + it('rejects an invalid For loop before queue submission', () => { + const forNode = buildNode(for_loop); + const bodyNode = buildNode(add); + const state = buildState([forNode, bodyNode], [buildEdge(forNode.id, 'item', bodyNode.id, 'a')]); + + expect(() => buildNodesGraph(state, { ...templates, for: for_loop })).toThrow('nodes.forLoopLinkageMissing'); + }); + + it('preserves the explicit loop linkage in a simple For graph', () => { + const forNode = buildNode(for_loop); + const returnNode = buildNode(for_return); + const state = buildState( + [forNode, returnNode], + [buildEdge(forNode.id, 'item', returnNode.id, 'output'), buildLoopLinkageEdge(forNode.id, returnNode.id)] + ); + + const graph = buildNodesGraph(state, { ...templates, for: for_loop, for_return }); + + expect(graph.edges).toEqual([ + expect.objectContaining({ + type: 'default', + source: { node_id: forNode.id, field: 'item' }, + destination: { node_id: returnNode.id, field: 'output' }, + }), + expect.objectContaining({ + type: 'loop_linkage', + source: { node_id: forNode.id, field: 'loop_linkage' }, + destination: { node_id: returnNode.id, field: 'loop_linkage' }, + }), + ]); + }); + + it('canonicalizes connector loop linkage into one direct execution edge', () => { + const forNode = buildNode(for_loop); + const connector = buildConnectorNode('connector-1'); + const returnNode = buildNode(for_return); + const state = buildState( + [forNode, connector, returnNode], + [ + buildEdge(forNode.id, 'item', returnNode.id, 'output'), + buildEdge(forNode.id, 'loop_linkage', connector.id, CONNECTOR_INPUT_HANDLE), + buildEdge(connector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), + ] + ); + + const graph = buildNodesGraph(state, { ...templates, for: for_loop, for_return }); + + expect(graph.edges).toEqual([ + expect.objectContaining({ + type: 'default', + source: { node_id: forNode.id, field: 'item' }, + destination: { node_id: returnNode.id, field: 'output' }, + }), + expect.objectContaining({ + type: 'loop_linkage', + source: { node_id: forNode.id, field: 'loop_linkage' }, + destination: { node_id: returnNode.id, field: 'loop_linkage' }, + }), + ]); + }); + + it('canonicalizes a chained connector loop linkage into one direct execution edge', () => { + const forNode = buildNode(for_loop); + const firstConnector = buildConnectorNode('connector-1'); + const secondConnector = buildConnectorNode('connector-2'); + const returnNode = buildNode(for_return); + const state = buildState( + [forNode, firstConnector, secondConnector, returnNode], + [ + buildEdge(forNode.id, 'item', returnNode.id, 'output'), + buildEdge(forNode.id, 'loop_linkage', firstConnector.id, CONNECTOR_INPUT_HANDLE), + buildEdge(firstConnector.id, CONNECTOR_OUTPUT_HANDLE, secondConnector.id, CONNECTOR_INPUT_HANDLE), + buildEdge(secondConnector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), + ] + ); + + const graph = buildNodesGraph(state, { ...templates, for: for_loop, for_return }); + + expect(graph.edges).toContainEqual( + expect.objectContaining({ + type: 'loop_linkage', + source: { node_id: forNode.id, field: 'loop_linkage' }, + destination: { node_id: returnNode.id, field: 'loop_linkage' }, + }) + ); + expect(graph.edges).toHaveLength(2); + }); + + it('rejects a connector loop linkage that branches to multiple ForReturns', () => { + const forNode = buildNode(for_loop); + const connector = buildConnectorNode('connector-1'); + const firstReturnNode = buildNode(for_return); + const secondReturnNode = buildNode(for_return); + const state = buildState( + [forNode, connector, firstReturnNode, secondReturnNode], + [ + buildEdge(forNode.id, 'loop_linkage', connector.id, CONNECTOR_INPUT_HANDLE), + buildEdge(connector.id, CONNECTOR_OUTPUT_HANDLE, firstReturnNode.id, 'loop_linkage'), + buildEdge(connector.id, CONNECTOR_OUTPUT_HANDLE, secondReturnNode.id, 'loop_linkage'), + ] + ); + + expect(() => buildNodesGraph(state, { ...templates, for: for_loop, for_return })).toThrow( + 'nodes.forLoopLinkageInvalid' + ); + }); + + it('rejects a loop linkage connector reused for ordinary data before a ForReturn is attached', () => { + const forNode = buildNode(for_loop); + const connector = buildConnectorNode('connector-1'); + const targetNode = buildNode(sub); + const state = buildState( + [forNode, connector, targetNode], + [ + buildEdge(forNode.id, 'loop_linkage', connector.id, CONNECTOR_INPUT_HANDLE), + buildEdge(connector.id, CONNECTOR_OUTPUT_HANDLE, targetNode.id, 'a'), + ] + ); + + expect(() => buildNodesGraph(state, { ...templates, for: for_loop })).toThrow('nodes.forLoopLinkageInvalid'); + }); + + it('rejects connector loop linkages that share a ForReturn', () => { + const forNode = buildNode(for_loop); + const firstConnector = buildConnectorNode('connector-1'); + const secondConnector = buildConnectorNode('connector-2'); + const returnNode = buildNode(for_return); + const state = buildState( + [forNode, firstConnector, secondConnector, returnNode], + [ + buildEdge(forNode.id, 'loop_linkage', firstConnector.id, CONNECTOR_INPUT_HANDLE), + buildEdge(firstConnector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), + buildEdge(forNode.id, 'loop_linkage', secondConnector.id, CONNECTOR_INPUT_HANDLE), + buildEdge(secondConnector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), + ] + ); + + expect(() => buildNodesGraph(state, { ...templates, for: for_loop, for_return })).toThrow( + 'nodes.forLoopLinkageDuplicate' + ); + }); + + it('normalizes loop linkage handles when an edge is missing its linkage type', () => { + const forNode = buildNode(for_loop); + const returnNode = buildNode(for_return); + const state = buildState( + [forNode, returnNode], + [ + buildEdge(forNode.id, 'item', returnNode.id, 'output'), + buildEdge(forNode.id, 'loop_linkage', returnNode.id, 'loop_linkage'), + ] + ); + + const graph = buildNodesGraph(state, { ...templates, for: for_loop, for_return }); + + expect(graph.edges).toEqual([ + expect.objectContaining({ + type: 'default', + source: { node_id: forNode.id, field: 'item' }, + destination: { node_id: returnNode.id, field: 'output' }, + }), + expect.objectContaining({ + type: 'loop_linkage', + source: { node_id: forNode.id, field: 'loop_linkage' }, + destination: { node_id: returnNode.id, field: 'loop_linkage' }, + }), + ]); + }); + + it('continues to omit collapsed edges while normalizing linkage edges', () => { + const sourceNode = buildNode(add); + const targetNode = buildNode(add); + const state = buildState( + [sourceNode, targetNode], + [{ ...buildEdge(sourceNode.id, 'value', targetNode.id, 'a'), type: 'collapsed', data: { count: 1 } }] + ); + + const graph = buildNodesGraph(state, templates); + + expect(graph.edges).toEqual([]); + }); + it('serializes dynamic saved workflow inputs into workflow_inputs', () => { const state = nodesSliceConfig.getInitialState(); const node = buildNode(callSavedWorkflowTemplate); @@ -161,6 +353,7 @@ describe('buildNodesGraph', () => { workflow_inputs: {}, }); expect(graph.edges).toContainEqual({ + type: 'default', source: { node_id: sourceNode.id, field: 'value' }, destination: { node_id: callNode.id, field: 'saved_workflow_input::node-1::a' }, }); @@ -223,6 +416,7 @@ describe('buildNodesGraph', () => { expect(graph.nodes).not.toHaveProperty(connector.id); expect(graph.edges).toEqual([ { + type: 'default', source: { node_id: source.id, field: 'value' }, destination: { node_id: target.id, field: 'a' }, }, @@ -247,6 +441,7 @@ describe('buildNodesGraph', () => { expect(graph.edges).toEqual([ { + type: 'default', source: { node_id: source.id, field: 'value' }, destination: { node_id: target.id, field: 'a' }, }, @@ -271,10 +466,12 @@ describe('buildNodesGraph', () => { expect(graph.edges).toEqual([ { + type: 'default', source: { node_id: source.id, field: 'value' }, destination: { node_id: targetA.id, field: 'a' }, }, { + type: 'default', source: { node_id: source.id, field: 'value' }, destination: { node_id: targetB.id, field: 'width' }, }, @@ -309,6 +506,7 @@ describe('buildNodesGraph', () => { expect(graph.edges).toEqual([ { + type: 'default', source: { node_id: source.id, field: 'value' }, destination: { node_id: target.id, field: 'a' }, }, @@ -337,6 +535,7 @@ describe('buildNodesGraph', () => { expect(graph.edges).toEqual([ { + type: 'default', source: { node_id: source.id, field: 'value' }, destination: { node_id: target.id, field: 'a' }, }, diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.ts index 47e4d779c2d..dde7ad80993 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.ts @@ -4,8 +4,15 @@ import { omit, reduce } from 'es-toolkit/compat'; import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors'; import { selectNodesSlice } from 'features/nodes/store/selectors'; import type { Templates } from 'features/nodes/store/types'; -import { resolveConnectorSource } from 'features/nodes/store/util/connectorTopology'; +import { + CONNECTOR_INPUT_HANDLE, + CONNECTOR_OUTPUT_HANDLE, + resolveConnectorSource, + resolveLoopLinkagePath, +} from 'features/nodes/store/util/connectorTopology'; +import { isLoopLinkageEdge } from 'features/nodes/store/util/reactFlowUtil'; import type { BoardField } from 'features/nodes/types/common'; +import { LOOP_LINKAGE_FIELD } from 'features/nodes/types/constants'; import { nodeAcceptsExtraInputs } from 'features/nodes/types/extraInputs'; import type { BoardFieldInputInstance } from 'features/nodes/types/field'; import { isBoardFieldInputInstance, isBoardFieldInputTemplate } from 'features/nodes/types/field'; @@ -15,6 +22,8 @@ import { isExecutableNode, isInvocationNode, } from 'features/nodes/types/invocation'; +import { validateForLoopGraph } from 'features/nodes/util/graph/validateForLoopGraph'; +import { t } from 'i18next'; import type { AnyInvocation, Graph } from 'services/api/types'; import { v4 as uuidv4 } from 'uuid'; @@ -88,6 +97,7 @@ export const buildNodesGraph = (state: RootState, templates: Templates): Require } return inputsAccumulator; } + if (isBoardFieldInputTemplate(fieldTemplate) && isBoardFieldInputInstance(input)) { inputsAccumulator[name] = getBoardField(input, state); } else { @@ -124,9 +134,57 @@ export const buildNodesGraph = (state: RootState, templates: Templates): Require const filteredNodeIds = filteredNodes.map(({ id }) => id); + for (const edge of edges) { + if ( + edge.type !== 'default' || + edge.sourceHandle !== CONNECTOR_OUTPUT_HANDLE || + !isConnectorNode(nodes.find((node) => node.id === edge.source)) + ) { + continue; + } + + const resolvedSource = resolveConnectorSource(edge.source, nodes, edges); + if (!resolvedSource || resolvedSource.fieldName !== LOOP_LINKAGE_FIELD) { + continue; + } + + const targetNode = nodes.find((node) => node.id === edge.target); + if (isConnectorNode(targetNode) && edge.targetHandle === CONNECTOR_INPUT_HANDLE) { + continue; + } + if ( + isInvocationNode(targetNode) && + targetNode.data.type === 'for_return' && + edge.targetHandle === LOOP_LINKAGE_FIELD + ) { + continue; + } + throw new Error(t('nodes.forLoopLinkageInvalid') || 'nodes.forLoopLinkageInvalid'); + } + + const connectorLoopLinkagePaths = edges.flatMap((edge) => { + if ( + edge.type !== 'default' || + edge.sourceHandle !== CONNECTOR_OUTPUT_HANDLE || + edge.targetHandle !== LOOP_LINKAGE_FIELD || + !isConnectorNode(nodes.find((node) => node.id === edge.source)) + ) { + return []; + } + + const path = resolveLoopLinkagePath(edge, nodes, edges); + if (!path) { + throw new Error(t('nodes.forLoopLinkageInvalid') || 'nodes.forLoopLinkageInvalid'); + } + return [path]; + }); + const connectorLoopLinkageEdgeIds = new Set(connectorLoopLinkagePaths.flatMap((path) => path.edgeIds)); + // skip out the "dummy" edges between collapsed nodes const flattenedEdges = edges - .filter((edge) => edge.type === 'default') + .filter( + (edge) => edge.type !== 'collapsed' && !isLoopLinkageEdge(edge) && !connectorLoopLinkageEdgeIds.has(edge.id) + ) .flatMap((edge) => { const targetNode = nodes.find((node) => node.id === edge.target); if (!targetNode || !isInvocationNode(targetNode) || !isExecutableNode(targetNode)) { @@ -174,6 +232,21 @@ export const buildNodesGraph = (state: RootState, templates: Templates): Require ); }); + const loopLinkageEdges = edges.filter(isLoopLinkageEdge).filter((edge) => { + const sourceNode = nodes.find((node) => node.id === edge.source); + const targetNode = nodes.find((node) => node.id === edge.target); + return Boolean( + sourceNode && + targetNode && + isInvocationNode(sourceNode) && + isInvocationNode(targetNode) && + isExecutableNode(sourceNode) && + isExecutableNode(targetNode) && + filteredNodeIds.includes(sourceNode.id) && + filteredNodeIds.includes(targetNode.id) + ); + }); + // Reduce the node editor edges into invocation graph edges const parsedEdges = flattenedEdges.reduce>((edgesAccumulator, edge) => { const { source, target, sourceHandle, targetHandle } = edge; @@ -185,6 +258,7 @@ export const buildNodesGraph = (state: RootState, templates: Templates): Require // Format the edges and add to the edges array edgesAccumulator.push({ + type: 'default', source: { node_id: source, field: sourceHandle, @@ -198,6 +272,47 @@ export const buildNodesGraph = (state: RootState, templates: Templates): Require return edgesAccumulator; }, []); + loopLinkageEdges.forEach((edge) => { + if (!edge.sourceHandle || !edge.targetHandle) { + log.warn( + { + edgeId: edge.id, + source: edge.source, + sourceHandle: edge.sourceHandle, + target: edge.target, + targetHandle: edge.targetHandle, + }, + 'Missing source or target handle for loop linkage edge' + ); + return; + } + parsedEdges.push({ + type: 'loop_linkage', + source: { + node_id: edge.source, + field: edge.sourceHandle, + }, + destination: { + node_id: edge.target, + field: edge.targetHandle, + }, + }); + }); + + connectorLoopLinkagePaths.forEach(({ forNodeId, returnNodeId }) => { + parsedEdges.push({ + type: 'loop_linkage', + source: { + node_id: forNodeId, + field: LOOP_LINKAGE_FIELD, + }, + destination: { + node_id: returnNodeId, + field: LOOP_LINKAGE_FIELD, + }, + }); + }); + /** * Omit all inputs that have edges connected. * @@ -208,6 +323,9 @@ export const buildNodesGraph = (state: RootState, templates: Templates): Require * even though the actual value that will be used comes from the connection. */ parsedEdges.forEach((edge) => { + if (edge.type !== 'default') { + return; + } const destination_node = parsedNodes[edge.destination.node_id]; if (!destination_node) { return; @@ -233,5 +351,10 @@ export const buildNodesGraph = (state: RootState, templates: Templates): Require edges: parsedEdges, }; + const forLoopError = validateForLoopGraph(graph); + if (forLoopError !== null) { + throw new Error(t(forLoopError) || forLoopError); + } + return graph; }; diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/Graph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/Graph.ts index db94b946e8f..c912d0800cc 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/Graph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/Graph.ts @@ -125,7 +125,7 @@ export class Graph { }); } - Object.assign(node, changes); + Object.assign(node, changes as object); return node; } diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/loopBodyBoundary.test.ts b/invokeai/frontend/web/src/features/nodes/util/graph/loopBodyBoundary.test.ts new file mode 100644 index 00000000000..1ba01ff1bf3 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/util/graph/loopBodyBoundary.test.ts @@ -0,0 +1,212 @@ +import { CONNECTOR_INPUT_HANDLE, CONNECTOR_OUTPUT_HANDLE } from 'features/nodes/store/util/connectorTopology'; +import { + add, + buildEdge, + buildLoopLinkageEdge, + buildNode, + for_loop, + for_return, +} from 'features/nodes/store/util/testUtils'; +import type { AnyEdge, AnyNode } from 'features/nodes/types/invocation'; +import { describe, expect, it } from 'vitest'; + +import { getForLoopBodyBoundaries } from './loopBodyBoundary'; + +const setNodeId = (node: AnyNode, id: string): AnyNode => { + node.id = id; + node.data.id = id; + return node; +}; + +const edge = (source: string, sourceHandle: string, target: string, targetHandle: string): AnyEdge => + buildEdge(source, sourceHandle, target, targetHandle); + +const connector = (id: string): AnyNode => ({ + id, + type: 'connector', + position: { x: 0, y: 0 }, + data: { id, type: 'connector', label: 'Connector', isOpen: true }, +}); + +describe(getForLoopBodyBoundaries.name, () => { + it('resolves a body using its explicit loop linkage', () => { + const forNode = setNodeId(buildNode(for_loop), 'for'); + const bodyNode = setNodeId(buildNode(add), 'body'); + const returnNode = setNodeId(buildNode(for_return), 'return'); + + const boundaries = getForLoopBodyBoundaries( + [forNode, bodyNode, returnNode], + [ + edge('for', 'item', 'body', 'a'), + edge('body', 'value', 'return', 'output'), + buildLoopLinkageEdge('for', 'return'), + ] + ); + + expect(boundaries).toEqual([ + expect.objectContaining({ + forNodeId: 'for', + returnNodeId: 'return', + bodyNodeIds: ['for', 'body', 'return'], + status: 'complete', + }), + ]); + }); + + it('resolves a body using a connector loop linkage alias', () => { + const forNode = setNodeId(buildNode(for_loop), 'for'); + const bodyNode = setNodeId(buildNode(add), 'body'); + const connectorNode = connector('connector'); + const returnNode = setNodeId(buildNode(for_return), 'return'); + + const boundaries = getForLoopBodyBoundaries( + [forNode, bodyNode, connectorNode, returnNode], + [ + edge('for', 'item', 'body', 'a'), + edge('body', 'value', 'return', 'output'), + edge('for', 'loop_linkage', 'connector', CONNECTOR_INPUT_HANDLE), + edge('connector', CONNECTOR_OUTPUT_HANDLE, 'return', 'loop_linkage'), + ] + ); + + expect(boundaries).toEqual([ + expect.objectContaining({ + forNodeId: 'for', + returnNodeId: 'return', + bodyNodeIds: ['for', 'body', 'connector', 'return'], + status: 'complete', + }), + ]); + }); + + it('includes every connector in a loop linkage alias chain', () => { + const forNode = setNodeId(buildNode(for_loop), 'for'); + const firstConnector = connector('connector-a'); + const secondConnector = connector('connector-b'); + const returnNode = setNodeId(buildNode(for_return), 'return'); + + const boundaries = getForLoopBodyBoundaries( + [forNode, firstConnector, secondConnector, returnNode], + [ + edge('for', 'loop_linkage', 'connector-a', CONNECTOR_INPUT_HANDLE), + edge('connector-a', CONNECTOR_OUTPUT_HANDLE, 'connector-b', CONNECTOR_INPUT_HANDLE), + edge('connector-b', CONNECTOR_OUTPUT_HANDLE, 'return', 'loop_linkage'), + ] + ); + + expect(boundaries[0]?.bodyNodeIds).toEqual(['for', 'connector-a', 'connector-b', 'return']); + }); + + it('reports missing loop linkage even when the data path is complete', () => { + const forNode = setNodeId(buildNode(for_loop), 'for'); + const bodyNode = setNodeId(buildNode(add), 'body'); + const returnNode = setNodeId(buildNode(for_return), 'return'); + + const boundaries = getForLoopBodyBoundaries( + [forNode, bodyNode, returnNode], + [edge('for', 'item', 'body', 'a'), edge('body', 'value', 'return', 'output')] + ); + + expect(boundaries[0]).toEqual( + expect.objectContaining({ forNodeId: 'for', returnNodeId: 'return', status: 'missing_linkage' }) + ); + }); + + it('reports a linkage whose return is detached from the body', () => { + const forNode = setNodeId(buildNode(for_loop), 'for'); + const bodyNode = setNodeId(buildNode(add), 'body'); + const returnNode = setNodeId(buildNode(for_return), 'return'); + + const boundaries = getForLoopBodyBoundaries( + [forNode, bodyNode, returnNode], + [edge('for', 'item', 'body', 'a'), buildLoopLinkageEdge('for', 'return')] + ); + + expect(boundaries[0]).toEqual( + expect.objectContaining({ forNodeId: 'for', returnNodeId: 'return', status: 'invalid_linkage' }) + ); + expect(boundaries).toHaveLength(1); + }); + + it('reports duplicate linkage edges', () => { + const forNode = setNodeId(buildNode(for_loop), 'for'); + const returnNode = setNodeId(buildNode(for_return), 'return'); + + const boundaries = getForLoopBodyBoundaries( + [forNode, returnNode], + [ + edge('for', 'item', 'return', 'output'), + buildLoopLinkageEdge('for', 'return'), + { ...buildLoopLinkageEdge('for', 'return'), id: 'duplicate-linkage' }, + ] + ); + + expect(boundaries[0]).toEqual(expect.objectContaining({ status: 'duplicate_linkage' })); + }); + + it('reports duplicate linkage ownership from multiple For nodes', () => { + const firstForNode = setNodeId(buildNode(for_loop), 'first-for'); + const secondForNode = setNodeId(buildNode(for_loop), 'second-for'); + const returnNode = setNodeId(buildNode(for_return), 'return'); + + const boundaries = getForLoopBodyBoundaries( + [firstForNode, secondForNode, returnNode], + [ + edge('first-for', 'item', 'return', 'output'), + edge('second-for', 'item', 'return', 'output'), + buildLoopLinkageEdge('first-for', 'return'), + buildLoopLinkageEdge('second-for', 'return'), + ] + ); + + expect(boundaries).toEqual([ + expect.objectContaining({ forNodeId: 'first-for', status: 'duplicate_linkage' }), + expect.objectContaining({ forNodeId: 'second-for', status: 'duplicate_linkage' }), + ]); + }); + + it('does not include final-scoped For outputs in the body boundary', () => { + const forNode = setNodeId(buildNode(for_loop), 'for'); + const bodyNode = setNodeId(buildNode(add), 'body'); + const returnNode = setNodeId(buildNode(for_return), 'return'); + const afterNode = setNodeId(buildNode(add), 'after'); + + const boundaries = getForLoopBodyBoundaries( + [forNode, bodyNode, returnNode, afterNode], + [ + edge('for', 'item', 'body', 'a'), + edge('body', 'value', 'return', 'output'), + edge('for', 'output_collection', 'after', 'a'), + buildLoopLinkageEdge('for', 'return'), + ] + ); + + expect(boundaries[0]?.bodyNodeIds).toEqual(['for', 'body', 'return']); + }); + + it('ignores loop linkage when finding executable paths', () => { + const forNode = setNodeId(buildNode(for_loop), 'for'); + const bodyNode = setNodeId(buildNode(add), 'body'); + const returnNode = setNodeId(buildNode(for_return), 'return'); + + const boundaries = getForLoopBodyBoundaries( + [forNode, bodyNode, returnNode], + [buildLoopLinkageEdge('for', 'return')] + ); + + expect(boundaries[0]).toEqual(expect.objectContaining({ status: 'invalid_linkage' })); + }); + + it('reports an unlinked ForReturn as an orphan boundary', () => { + const returnNode = setNodeId(buildNode(for_return), 'return'); + + expect(getForLoopBodyBoundaries([returnNode], [])).toEqual([ + expect.objectContaining({ + forNodeId: undefined, + returnNodeId: 'return', + bodyNodeIds: ['return'], + status: 'orphan_return', + }), + ]); + }); +}); diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/loopBodyBoundary.ts b/invokeai/frontend/web/src/features/nodes/util/graph/loopBodyBoundary.ts new file mode 100644 index 00000000000..660a59b8ba6 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/util/graph/loopBodyBoundary.ts @@ -0,0 +1,219 @@ +import { CONNECTOR_OUTPUT_HANDLE, resolveLoopLinkagePath } from 'features/nodes/store/util/connectorTopology'; +import { isLoopLinkageEdge } from 'features/nodes/store/util/reactFlowUtil'; +import { LOOP_LINKAGE_FIELD } from 'features/nodes/types/constants'; +import type { AnyEdge, AnyNode } from 'features/nodes/types/invocation'; +import { isConnectorNode, isInvocationNode } from 'features/nodes/types/invocation'; + +const ITERATION_OUTPUT_FIELDS = new Set(['item', 'index', 'total', 'state']); + +export type LoopBodyBoundaryStatus = + | 'complete' + | 'missing_linkage' + | 'invalid_linkage' + | 'duplicate_linkage' + | 'missing_return' + | 'multiple_returns' + | 'orphan_return'; + +type LoopBodyBoundary = { + forNodeId?: string; + returnNodeId?: string; + bodyNodeIds: string[]; + status: LoopBodyBoundaryStatus; +}; + +const getReachableNodeIds = (startIds: string[], outgoing: Map): Set => { + const visited = new Set(); + const pending = [...startIds]; + while (pending.length > 0) { + const nodeId = pending.pop(); + if (nodeId === undefined || visited.has(nodeId)) { + continue; + } + visited.add(nodeId); + pending.push(...(outgoing.get(nodeId) ?? [])); + } + return visited; +}; + +const getBoundaryNodeIds = ( + nodes: AnyNode[], + reachableNodeIds: Set, + returnNodeId: string | undefined, + incoming: Map, + additionalNodeIds: Set = new Set() +): string[] => { + const bodyNodeIds = returnNodeId + ? new Set([...getReachableNodeIds([returnNodeId], incoming)].filter((nodeId) => reachableNodeIds.has(nodeId))) + : new Set(reachableNodeIds); + if (returnNodeId) { + bodyNodeIds.add(returnNodeId); + } + additionalNodeIds.forEach((nodeId) => bodyNodeIds.add(nodeId)); + return nodes.filter((node) => bodyNodeIds.has(node.id)).map((node) => node.id); +}; + +export const getForLoopBodyBoundaries = (nodes: AnyNode[], edges: AnyEdge[]): LoopBodyBoundary[] => { + const nodesById = new Map(nodes.map((node) => [node.id, node])); + const resolvedConnectorLinkages = edges.flatMap((edge) => { + if ( + edge.type !== 'default' || + edge.sourceHandle !== CONNECTOR_OUTPUT_HANDLE || + edge.targetHandle !== LOOP_LINKAGE_FIELD || + !isConnectorNode(nodes.find((node) => node.id === edge.source)) + ) { + return []; + } + const path = resolveLoopLinkagePath(edge, nodes, edges); + return path ? [path] : []; + }); + const resolvedConnectorLinkageEdgeIds = new Set(resolvedConnectorLinkages.flatMap((path) => path.edgeIds)); + const resolvedConnectorNodeIdsByForId = new Map(); + for (const path of resolvedConnectorLinkages) { + resolvedConnectorNodeIdsByForId.set(path.forNodeId, [ + ...(resolvedConnectorNodeIdsByForId.get(path.forNodeId) ?? []), + ...path.connectorNodeIds, + ]); + } + const executableEdges = edges.filter( + (edge) => !isLoopLinkageEdge(edge) && !resolvedConnectorLinkageEdgeIds.has(edge.id) + ); + const linkageEdges = [ + ...edges.filter(isLoopLinkageEdge), + ...resolvedConnectorLinkages.map( + ({ forNodeId, returnNodeId }) => + ({ + id: `resolved-loop-linkage-${forNodeId}-${returnNodeId}`, + type: 'loop_linkage' as const, + source: forNodeId, + sourceHandle: LOOP_LINKAGE_FIELD, + target: returnNodeId, + targetHandle: LOOP_LINKAGE_FIELD, + }) satisfies AnyEdge + ), + ]; + const outgoing = new Map(); + const incoming = new Map(); + + for (const edge of executableEdges) { + if (!nodesById.has(edge.source) || !nodesById.has(edge.target)) { + continue; + } + outgoing.set(edge.source, [...(outgoing.get(edge.source) ?? []), edge.target]); + incoming.set(edge.target, [...(incoming.get(edge.target) ?? []), edge.source]); + } + + const linkedReturnByForId = new Map(); + const linkedForByReturnId = new Map(); + const duplicateForIds = new Set(); + const duplicateReturnIds = new Set(); + const invalidForIds = new Set(); + const invalidReturnIds = new Set(); + + for (const edge of linkageEdges) { + const sourceNode = nodesById.get(edge.source); + const targetNode = nodesById.get(edge.target); + if ( + edge.sourceHandle !== LOOP_LINKAGE_FIELD || + edge.targetHandle !== LOOP_LINKAGE_FIELD || + !isInvocationNode(sourceNode) || + sourceNode.data.type !== 'for' || + !isInvocationNode(targetNode) || + targetNode.data.type !== 'for_return' + ) { + if (sourceNode?.type === 'invocation' && sourceNode.data.type === 'for') { + invalidForIds.add(sourceNode.id); + } + if (targetNode?.type === 'invocation' && targetNode.data.type === 'for_return') { + invalidReturnIds.add(targetNode.id); + } + continue; + } + if (linkedReturnByForId.has(sourceNode.id)) { + duplicateForIds.add(sourceNode.id); + } else { + linkedReturnByForId.set(sourceNode.id, targetNode.id); + } + if (linkedForByReturnId.has(targetNode.id)) { + duplicateReturnIds.add(targetNode.id); + } else { + linkedForByReturnId.set(targetNode.id, sourceNode.id); + } + } + + const reachableReturnIds = new Set(); + const forBoundaries = nodes + .filter((node) => isInvocationNode(node) && node.data.type === 'for') + .map((forNode) => { + const iterationTargets = executableEdges + .filter( + (edge) => + edge.source === forNode.id && + typeof edge.sourceHandle === 'string' && + ITERATION_OUTPUT_FIELDS.has(edge.sourceHandle) + ) + .map((edge) => edge.target); + const reachableNodeIds = getReachableNodeIds(iterationTargets, outgoing); + const reachableReturnNodes = nodes.filter( + (node) => reachableNodeIds.has(node.id) && isInvocationNode(node) && node.data.type === 'for_return' + ); + reachableReturnNodes.forEach((node) => reachableReturnIds.add(node.id)); + + const linkedReturnId = linkedReturnByForId.get(forNode.id); + const returnNodeId = + linkedReturnId ?? (reachableReturnNodes.length === 1 ? reachableReturnNodes[0]?.id : undefined); + let status: LoopBodyBoundaryStatus; + if (duplicateForIds.has(forNode.id) || (linkedReturnId !== undefined && duplicateReturnIds.has(linkedReturnId))) { + status = 'duplicate_linkage'; + } else if (invalidForIds.has(forNode.id)) { + status = 'invalid_linkage'; + } else if (linkedReturnId === undefined) { + status = 'missing_linkage'; + } else if (!reachableNodeIds.has(linkedReturnId)) { + status = 'invalid_linkage'; + } else if (reachableReturnNodes.length === 0) { + status = 'missing_return'; + } else if (reachableReturnNodes.length > 1) { + status = 'multiple_returns'; + } else { + status = 'complete'; + } + + return { + forNodeId: forNode.id, + ...(returnNodeId ? { returnNodeId } : {}), + bodyNodeIds: [ + forNode.id, + ...getBoundaryNodeIds( + nodes, + reachableNodeIds, + returnNodeId, + incoming, + new Set(resolvedConnectorNodeIdsByForId.get(forNode.id)) + ).filter((id) => id !== forNode.id), + ], + status, + }; + }); + + const orphanReturnBoundaries = nodes + .filter( + (node) => + isInvocationNode(node) && + node.data.type === 'for_return' && + !linkedForByReturnId.has(node.id) && + (!reachableReturnIds.has(node.id) || invalidReturnIds.has(node.id)) + ) + .map((returnNode) => ({ + forNodeId: linkedForByReturnId.get(returnNode.id), + returnNodeId: returnNode.id, + bodyNodeIds: [returnNode.id], + status: duplicateReturnIds.has(returnNode.id) + ? ('duplicate_linkage' as const) + : invalidReturnIds.has(returnNode.id) + ? ('invalid_linkage' as const) + : ('orphan_return' as const), + })); + + return [...forBoundaries, ...orphanReturnBoundaries]; +}; diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/validateForLoopGraph.test.ts b/invokeai/frontend/web/src/features/nodes/util/graph/validateForLoopGraph.test.ts new file mode 100644 index 00000000000..a42437da1dd --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/util/graph/validateForLoopGraph.test.ts @@ -0,0 +1,604 @@ +import type { Graph } from 'services/api/types'; +import { describe, expect, it } from 'vitest'; + +import { validateForLoopGraph } from './validateForLoopGraph'; + +type TestNode = { id: string; type: string }; +type TestEdge = { + type?: 'default' | 'loop_linkage'; + source: { node_id: string; field: string }; + destination: { node_id: string; field: string }; +}; + +const buildGraph = (nodes: TestNode[], edges: TestEdge[]): Graph => + ({ + id: 'graph', + nodes: Object.fromEntries(nodes.map((node) => [node.id, node])), + edges, + }) as unknown as Graph; + +const edge = (source: string, sourceField: string, destination: string, destinationField: string): TestEdge => ({ + type: 'default', + source: { node_id: source, field: sourceField }, + destination: { node_id: destination, field: destinationField }, +}); + +const linkage = (source: string, destination: string): TestEdge => ({ + type: 'loop_linkage', + source: { node_id: source, field: 'loop_linkage' }, + destination: { node_id: destination, field: 'loop_linkage' }, +}); + +describe(validateForLoopGraph.name, () => { + it('accepts a valid For body', () => { + const graph = buildGraph( + [ + { id: 'for', type: 'for' }, + { id: 'body', type: 'add' }, + { id: 'return', type: 'for_return' }, + { id: 'after', type: 'collect' }, + ], + [ + edge('for', 'item', 'body', 'a'), + edge('body', 'value', 'return', 'output'), + edge('for', 'output_collection', 'after', 'item'), + linkage('for', 'return'), + ] + ); + + expect(validateForLoopGraph(graph)).toBeNull(); + }); + + it('requires an explicit loop linkage', () => { + const graph = buildGraph( + [ + { id: 'for', type: 'for' }, + { id: 'body', type: 'add' }, + { id: 'return', type: 'for_return' }, + ], + [edge('for', 'item', 'body', 'a'), edge('body', 'value', 'return', 'output')] + ); + + expect(validateForLoopGraph(graph)).toBe('nodes.forLoopLinkageMissing'); + }); + + it('rejects a linkage with the wrong endpoint fields', () => { + const graph = buildGraph( + [ + { id: 'source', type: 'add' }, + { id: 'for', type: 'for' }, + { id: 'body', type: 'add' }, + { id: 'return', type: 'for_return' }, + ], + [ + edge('for', 'item', 'body', 'a'), + edge('body', 'value', 'return', 'output'), + { + type: 'loop_linkage', + source: { node_id: 'for', field: 'item' }, + destination: { node_id: 'return', field: 'loop_linkage' }, + }, + ] + ); + + expect(validateForLoopGraph(graph)).toBe('nodes.forLoopLinkageInvalid'); + }); + + it('rejects an internal Iterate predicate branch without scalar aggregation', () => { + const graph = buildGraph( + [ + { id: 'for', type: 'for' }, + { id: 'iterate', type: 'iterate' }, + { id: 'body', type: 'add' }, + { id: 'condition', type: 'add' }, + { id: 'collect', type: 'collect' }, + { id: 'return', type: 'for_return' }, + ], + [ + edge('for', 'item', 'iterate', 'collection'), + edge('iterate', 'item', 'body', 'a'), + edge('iterate', 'item', 'condition', 'value'), + edge('body', 'value', 'collect', 'item'), + edge('collect', 'collection', 'return', 'output'), + edge('condition', 'value', 'return', 'continue_condition'), + linkage('for', 'return'), + ] + ); + + expect(validateForLoopGraph(graph)).toBe('nodes.forLoopIterateUnsupported'); + }); + + it('accepts one nested For whose final collection closes the outer body', () => { + const graph = buildGraph( + [ + { id: 'outer', type: 'for' }, + { id: 'inner-collection', type: 'add' }, + { id: 'inner', type: 'for' }, + { id: 'inner-body', type: 'add' }, + { id: 'inner-condition', type: 'add' }, + { id: 'inner-return', type: 'for_return' }, + { id: 'outer-return', type: 'for_return' }, + ], + [ + edge('outer', 'item', 'inner-collection', 'value'), + edge('inner-collection', 'value', 'inner', 'collection'), + edge('inner', 'item', 'inner-body', 'value'), + edge('inner', 'item', 'inner-condition', 'value'), + edge('inner-body', 'value', 'inner-return', 'output'), + edge('inner-condition', 'value', 'inner-return', 'continue_condition'), + edge('inner', 'output_collection', 'outer-return', 'output'), + linkage('inner', 'inner-return'), + linkage('outer', 'outer-return'), + ] + ); + + expect(validateForLoopGraph(graph)).toBeNull(); + }); + + it('accepts nested ForReturn state produced by the inner body', () => { + const graph = buildGraph( + [ + { id: 'outer', type: 'for' }, + { id: 'inner-collection', type: 'add' }, + { id: 'inner', type: 'for' }, + { id: 'inner-body', type: 'add' }, + { id: 'inner-state', type: 'add' }, + { id: 'inner-return', type: 'for_return' }, + { id: 'outer-return', type: 'for_return' }, + ], + [ + edge('outer', 'item', 'inner-collection', 'value'), + edge('inner-collection', 'value', 'inner', 'collection'), + edge('inner', 'item', 'inner-body', 'value'), + edge('inner', 'state', 'inner-state', 'state'), + edge('inner', 'item', 'inner-state', 'value'), + edge('inner-body', 'value', 'inner-return', 'output'), + edge('inner-state', 'state', 'inner-return', 'state'), + edge('inner', 'output_collection', 'outer-return', 'output'), + linkage('inner', 'inner-return'), + linkage('outer', 'outer-return'), + ] + ); + + expect(validateForLoopGraph(graph)).toBeNull(); + }); + + it('rejects an outer nested ForReturn condition from an external scope', () => { + const graph = buildGraph( + [ + { id: 'outer', type: 'for' }, + { id: 'inner-collection', type: 'add' }, + { id: 'inner', type: 'for' }, + { id: 'inner-body', type: 'add' }, + { id: 'inner-return', type: 'for_return' }, + { id: 'outer-return', type: 'for_return' }, + { id: 'external-condition', type: 'add' }, + ], + [ + edge('outer', 'item', 'inner-collection', 'value'), + edge('inner-collection', 'value', 'inner', 'collection'), + edge('inner', 'item', 'inner-body', 'value'), + edge('inner-body', 'value', 'inner-return', 'output'), + edge('inner', 'output_collection', 'outer-return', 'output'), + linkage('inner', 'inner-return'), + linkage('outer', 'outer-return'), + edge('external-condition', 'value', 'outer-return', 'continue_condition'), + ] + ); + + expect(validateForLoopGraph(graph)).toBe('nodes.forLoopNestedUnsupported'); + }); + + it('accepts deeper nested For boundaries when each boundary has one child', () => { + const graph = buildGraph( + [ + { id: 'outer', type: 'for' }, + { id: 'outer-collection', type: 'add' }, + { id: 'inner', type: 'for' }, + { id: 'inner-collection', type: 'add' }, + { id: 'leaf', type: 'for' }, + { id: 'leaf-body', type: 'add' }, + { id: 'leaf-return', type: 'for_return' }, + { id: 'inner-return', type: 'for_return' }, + { id: 'outer-return', type: 'for_return' }, + ], + [ + edge('outer', 'item', 'outer-collection', 'value'), + edge('outer-collection', 'value', 'inner', 'collection'), + edge('inner', 'item', 'inner-collection', 'value'), + edge('inner-collection', 'value', 'leaf', 'collection'), + edge('leaf', 'item', 'leaf-body', 'value'), + edge('leaf-body', 'value', 'leaf-return', 'output'), + edge('leaf', 'output_collection', 'inner-return', 'output'), + edge('inner', 'output_collection', 'outer-return', 'output'), + linkage('leaf', 'leaf-return'), + linkage('inner', 'inner-return'), + linkage('outer', 'outer-return'), + ] + ); + + expect(validateForLoopGraph(graph)).toBeNull(); + }); + + it('accepts a nested For with an outer continuation after the inner final output', () => { + const graph = buildGraph( + [ + { id: 'outer', type: 'for' }, + { id: 'inner-collection', type: 'add' }, + { id: 'inner', type: 'for' }, + { id: 'inner-body', type: 'add' }, + { id: 'inner-return', type: 'for_return' }, + { id: 'continuation', type: 'add' }, + { id: 'continuation-tail', type: 'add' }, + { id: 'outer-return', type: 'for_return' }, + ], + [ + edge('outer', 'item', 'inner-collection', 'value'), + edge('inner-collection', 'value', 'inner', 'collection'), + edge('inner', 'item', 'inner-body', 'value'), + edge('inner-body', 'value', 'inner-return', 'output'), + edge('inner', 'output_collection', 'continuation', 'value'), + edge('continuation', 'value', 'continuation-tail', 'value'), + edge('continuation-tail', 'value', 'outer-return', 'output'), + edge('continuation-tail', 'value', 'outer-return', 'continue_condition'), + linkage('inner', 'inner-return'), + linkage('outer', 'outer-return'), + ] + ); + + expect(validateForLoopGraph(graph)).toBeNull(); + }); + + it('rejects a nested continuation branch that does not reach the outer ForReturn', () => { + const graph = buildGraph( + [ + { id: 'outer', type: 'for' }, + { id: 'inner-collection', type: 'add' }, + { id: 'inner', type: 'for' }, + { id: 'inner-body', type: 'add' }, + { id: 'inner-return', type: 'for_return' }, + { id: 'continuation', type: 'add' }, + { id: 'dead-branch', type: 'add' }, + { id: 'outer-return', type: 'for_return' }, + ], + [ + edge('outer', 'item', 'inner-collection', 'value'), + edge('inner-collection', 'value', 'inner', 'collection'), + edge('inner', 'item', 'inner-body', 'value'), + edge('inner-body', 'value', 'inner-return', 'output'), + edge('inner', 'output_collection', 'continuation', 'value'), + edge('continuation', 'value', 'outer-return', 'output'), + edge('continuation', 'value', 'dead-branch', 'value'), + linkage('inner', 'inner-return'), + linkage('outer', 'outer-return'), + ] + ); + + expect(validateForLoopGraph(graph)).toBe('nodes.forLoopNestedUnsupported'); + }); + + it('accepts independent nested For children with an explicit fan-in continuation', () => { + const graph = buildGraph( + [ + { id: 'outer', type: 'for' }, + { id: 'first', type: 'for' }, + { id: 'second', type: 'for' }, + { id: 'first-body', type: 'add' }, + { id: 'second-body', type: 'add' }, + { id: 'first-return', type: 'for_return' }, + { id: 'second-return', type: 'for_return' }, + { id: 'fan-in', type: 'add' }, + { id: 'outer-return', type: 'for_return' }, + ], + [ + edge('outer', 'item', 'first', 'collection'), + edge('outer', 'item', 'second', 'collection'), + edge('first', 'item', 'first-body', 'value'), + edge('first-body', 'value', 'first-return', 'output'), + edge('second', 'item', 'second-body', 'value'), + edge('second-body', 'value', 'second-return', 'output'), + edge('first', 'output_collection', 'fan-in', 'first'), + edge('second', 'output_collection', 'fan-in', 'second'), + edge('fan-in', 'value', 'outer-return', 'output'), + linkage('first', 'first-return'), + linkage('second', 'second-return'), + linkage('outer', 'outer-return'), + ] + ); + + expect(validateForLoopGraph(graph)).toBeNull(); + }); + + it.each([ + { + name: 'missing loop linkage', + nodes: [ + { id: 'for', type: 'for' }, + { id: 'return', type: 'for_return' }, + ], + edges: [edge('for', 'item', 'return', 'output')], + expected: 'nodes.forLoopLinkageMissing', + }, + { + name: 'duplicate loop linkage', + nodes: [ + { id: 'for', type: 'for' }, + { id: 'return', type: 'for_return' }, + ], + edges: [edge('for', 'item', 'return', 'output'), linkage('for', 'return'), linkage('for', 'return')], + expected: 'nodes.forLoopLinkageDuplicate', + }, + { + name: 'duplicate For collection inputs', + nodes: [ + { id: 'for', type: 'for' }, + { id: 'first', type: 'add' }, + { id: 'second', type: 'add' }, + { id: 'return', type: 'for_return' }, + ], + edges: [ + edge('first', 'value', 'for', 'collection'), + edge('second', 'value', 'for', 'collection'), + edge('for', 'item', 'return', 'output'), + linkage('for', 'return'), + ], + expected: 'nodes.forLoopInputCount', + }, + { + name: 'duplicate For state inputs', + nodes: [ + { id: 'for', type: 'for' }, + { id: 'first', type: 'add' }, + { id: 'second', type: 'add' }, + { id: 'return', type: 'for_return' }, + ], + edges: [ + edge('first', 'value', 'for', 'state'), + edge('second', 'value', 'for', 'state'), + edge('for', 'item', 'return', 'output'), + linkage('for', 'return'), + ], + expected: 'nodes.forLoopInputCount', + }, + { + name: 'duplicate ForReturn outputs', + nodes: [ + { id: 'for', type: 'for' }, + { id: 'first', type: 'add' }, + { id: 'second', type: 'add' }, + { id: 'return', type: 'for_return' }, + ], + edges: [ + edge('for', 'item', 'return', 'output'), + edge('first', 'value', 'return', 'output'), + edge('second', 'value', 'return', 'output'), + linkage('for', 'return'), + ], + expected: 'nodes.forReturnInputCount', + }, + { + name: 'duplicate ForReturn state inputs', + nodes: [ + { id: 'for', type: 'for' }, + { id: 'first', type: 'add' }, + { id: 'second', type: 'add' }, + { id: 'return', type: 'for_return' }, + ], + edges: [ + edge('for', 'item', 'return', 'output'), + edge('first', 'value', 'return', 'state'), + edge('second', 'value', 'return', 'state'), + linkage('for', 'return'), + ], + expected: 'nodes.forReturnInputCount', + }, + { + name: 'duplicate ForReturn continue conditions', + nodes: [ + { id: 'for', type: 'for' }, + { id: 'first', type: 'add' }, + { id: 'second', type: 'add' }, + { id: 'return', type: 'for_return' }, + ], + edges: [ + edge('for', 'item', 'return', 'output'), + edge('first', 'value', 'return', 'continue_condition'), + edge('second', 'value', 'return', 'continue_condition'), + linkage('for', 'return'), + ], + expected: 'nodes.forReturnInputCount', + }, + ])('rejects $name', ({ nodes, edges, expected }) => { + expect(validateForLoopGraph(buildGraph(nodes, edges))).toBe(expected); + }); + + it.each([ + { + name: 'missing iteration output', + nodes: [ + { id: 'for', type: 'for' }, + { id: 'return', type: 'for_return' }, + ], + edges: [linkage('for', 'return')], + expected: 'nodes.forLoopMissingIterationOutput', + }, + { + name: 'missing ForReturn', + nodes: [ + { id: 'for', type: 'for' }, + { id: 'body', type: 'add' }, + ], + edges: [edge('for', 'item', 'body', 'a')], + expected: 'nodes.forLoopLinkageMissing', + }, + { + name: 'multiple ForReturn nodes', + nodes: [ + { id: 'for', type: 'for' }, + { id: 'first', type: 'for_return' }, + { id: 'second', type: 'for_return' }, + ], + edges: [edge('for', 'item', 'first', 'output'), edge('for', 'state', 'second', 'state'), linkage('for', 'first')], + expected: 'nodes.forLoopLinkageMissing', + }, + { + name: 'unterminated body branch', + nodes: [ + { id: 'for', type: 'for' }, + { id: 'body', type: 'add' }, + { id: 'return', type: 'for_return' }, + { id: 'escape', type: 'add' }, + ], + edges: [ + edge('for', 'item', 'body', 'a'), + edge('body', 'value', 'return', 'output'), + edge('for', 'state', 'escape', 'a'), + linkage('for', 'return'), + ], + expected: 'nodes.forLoopUnterminatedBody', + }, + { + name: 'nested For', + nodes: [ + { id: 'for', type: 'for' }, + { id: 'nested', type: 'for' }, + { id: 'return', type: 'for_return' }, + ], + edges: [ + edge('for', 'item', 'nested', 'collection'), + edge('nested', 'item', 'return', 'output'), + linkage('nested', 'return'), + ], + expected: 'nodes.forLoopLinkageMissing', + }, + { + name: 'multiple direct nested For children', + nodes: [ + { id: 'outer', type: 'for' }, + { id: 'first', type: 'for' }, + { id: 'second', type: 'for' }, + { id: 'first-return', type: 'for_return' }, + { id: 'second-return', type: 'for_return' }, + { id: 'outer-return', type: 'for_return' }, + ], + edges: [ + edge('outer', 'item', 'first', 'collection'), + edge('outer', 'item', 'second', 'collection'), + edge('first', 'item', 'first-return', 'output'), + edge('second', 'item', 'second-return', 'output'), + edge('first', 'output_collection', 'outer-return', 'output'), + linkage('first', 'first-return'), + linkage('second', 'second-return'), + linkage('outer', 'outer-return'), + ], + expected: 'nodes.forLoopNestedUnsupported', + }, + { + name: 'mixed nested For and Iterate body', + nodes: [ + { id: 'outer', type: 'for' }, + { id: 'inner-collection', type: 'add' }, + { id: 'inner', type: 'for' }, + { id: 'iterate-collection', type: 'add' }, + { id: 'iterate', type: 'iterate' }, + { id: 'body', type: 'add' }, + { id: 'collect', type: 'collect' }, + { id: 'inner-return', type: 'for_return' }, + { id: 'outer-return', type: 'for_return' }, + ], + edges: [ + edge('outer', 'item', 'inner-collection', 'value'), + edge('inner-collection', 'value', 'inner', 'collection'), + edge('inner', 'item', 'iterate-collection', 'value'), + edge('iterate-collection', 'value', 'iterate', 'collection'), + edge('iterate', 'item', 'body', 'value'), + edge('body', 'value', 'collect', 'item'), + edge('collect', 'collection', 'inner-return', 'output'), + edge('inner', 'output_collection', 'outer-return', 'output'), + linkage('inner', 'inner-return'), + linkage('outer', 'outer-return'), + ], + expected: 'nodes.forLoopNestedUnsupported', + }, + { + name: 'body Iterate', + nodes: [ + { id: 'for', type: 'for' }, + { id: 'iterate', type: 'iterate' }, + { id: 'return', type: 'for_return' }, + ], + edges: [ + edge('for', 'item', 'iterate', 'collection'), + edge('iterate', 'item', 'return', 'output'), + linkage('for', 'return'), + ], + expected: 'nodes.forLoopIterateUnsupported', + }, + { + name: 'iterator-derived external body input', + nodes: [ + { id: 'collection', type: 'integer_collection' }, + { id: 'iterate', type: 'iterate' }, + { id: 'external', type: 'add' }, + { id: 'for', type: 'for' }, + { id: 'body', type: 'add' }, + { id: 'return', type: 'for_return' }, + ], + edges: [ + edge('collection', 'collection', 'iterate', 'collection'), + edge('iterate', 'item', 'external', 'a'), + edge('for', 'item', 'body', 'a'), + edge('external', 'value', 'body', 'b'), + edge('body', 'value', 'return', 'output'), + linkage('for', 'return'), + ], + expected: 'nodes.forLoopIteratorInputUnsupported', + }, + { + name: 'final output feeding body', + nodes: [ + { id: 'for', type: 'for' }, + { id: 'return', type: 'for_return' }, + ], + edges: [ + edge('for', 'item', 'return', 'output'), + edge('for', 'final_state', 'return', 'state'), + linkage('for', 'return'), + ], + expected: 'nodes.forLoopFinalOutputInBody', + }, + { + name: 'body output escaping before ForReturn', + nodes: [ + { id: 'for', type: 'for' }, + { id: 'body', type: 'add' }, + { id: 'return', type: 'for_return' }, + { id: 'escape', type: 'add' }, + ], + edges: [ + edge('for', 'item', 'body', 'a'), + edge('body', 'value', 'return', 'output'), + edge('body', 'value', 'escape', 'a'), + linkage('for', 'return'), + ], + expected: 'nodes.forLoopUnterminatedBody', + }, + { + name: 'ForReturn shared by two loops', + nodes: [ + { id: 'first', type: 'for' }, + { id: 'second', type: 'for' }, + { id: 'return', type: 'for_return' }, + ], + edges: [ + edge('first', 'item', 'return', 'output'), + edge('second', 'item', 'return', 'output'), + linkage('first', 'return'), + linkage('second', 'return'), + ], + expected: 'nodes.forLoopLinkageDuplicate', + }, + ])('rejects $name', ({ nodes, edges, expected }) => { + expect(validateForLoopGraph(buildGraph(nodes, edges))).toBe(expected); + }); +}); diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/validateForLoopGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/validateForLoopGraph.ts new file mode 100644 index 00000000000..39c07e65b54 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/util/graph/validateForLoopGraph.ts @@ -0,0 +1,532 @@ +import { LOOP_LINKAGE_FIELD } from 'features/nodes/types/constants'; +import type { Graph } from 'services/api/types'; + +type ForLoopGraphError = + | 'nodes.forLoopMissingIterationOutput' + | 'nodes.forLoopReturnCount' + | 'nodes.forLoopUnterminatedBody' + | 'nodes.forLoopNestedUnsupported' + | 'nodes.forLoopIterateUnsupported' + | 'nodes.forLoopIteratorInputUnsupported' + | 'nodes.forLoopFinalOutputInBody' + | 'nodes.forLoopBodyEscape' + | 'nodes.forLoopInputCount' + | 'nodes.forReturnInputCount' + | 'nodes.forLoopLinkageMissing' + | 'nodes.forLoopLinkageInvalid' + | 'nodes.forLoopLinkageDuplicate' + | 'nodes.forReturnOwnership'; + +const ITERATION_OUTPUT_FIELDS = new Set(['item', 'index', 'total', 'state']); +const FINAL_OUTPUT_FIELDS = new Set(['output_collection', 'final_state']); + +export const validateForLoopGraph = (graph: Graph): ForLoopGraphError | null => { + const nodes = graph.nodes ?? {}; + const allEdges = graph.edges ?? []; + const edges = allEdges.filter((edge) => edge.type !== 'loop_linkage'); + const linkageEdges = allEdges.filter((edge) => edge.type === 'loop_linkage'); + const outgoing = new Map(); + const incoming = new Map(); + + for (const edge of edges) { + const sourceId = edge.source.node_id; + const destinationId = edge.destination.node_id; + outgoing.set(sourceId, [...(outgoing.get(sourceId) ?? []), destinationId]); + incoming.set(destinationId, [...(incoming.get(destinationId) ?? []), sourceId]); + } + + const walk = (startIds: Iterable, adjacency: Map): Set => { + const visited = new Set(); + const pending = [...startIds]; + while (pending.length > 0) { + const nodeId = pending.pop(); + if (nodeId === undefined || visited.has(nodeId)) { + continue; + } + visited.add(nodeId); + pending.push(...(adjacency.get(nodeId) ?? [])); + } + return visited; + }; + + const hasPath = (startId: string, targetId: string): boolean => + startId === targetId || walk([startId], outgoing).has(targetId); + + const linkedReturnByForId = new Map(); + const linkedForByReturnId = new Map(); + for (const edge of linkageEdges) { + const sourceNode = nodes[edge.source.node_id]; + const destinationNode = nodes[edge.destination.node_id]; + if ( + edge.source.field !== LOOP_LINKAGE_FIELD || + edge.destination.field !== LOOP_LINKAGE_FIELD || + sourceNode?.type !== 'for' || + destinationNode?.type !== 'for_return' + ) { + return 'nodes.forLoopLinkageInvalid'; + } + if (linkedReturnByForId.has(edge.source.node_id) || linkedForByReturnId.has(edge.destination.node_id)) { + return 'nodes.forLoopLinkageDuplicate'; + } + linkedReturnByForId.set(edge.source.node_id, edge.destination.node_id); + linkedForByReturnId.set(edge.destination.node_id, edge.source.node_id); + } + + if ( + Object.values(nodes).some((node) => { + if (node.type === 'for') { + return !linkedReturnByForId.has(node.id); + } + if (node.type === 'for_return') { + return !linkedForByReturnId.has(node.id); + } + return false; + }) + ) { + return 'nodes.forLoopLinkageMissing'; + } + + const supportsNestedIterateBody = ( + bodyPathNodeIds: Set, + iterateNodeIds: string[], + collectNodeIds: string[], + returnId: string, + forId: string + ): boolean => { + if (iterateNodeIds.length !== 1 || collectNodeIds.length !== 1) { + return false; + } + + const iterateId = iterateNodeIds[0]; + const collectId = collectNodeIds[0]; + if (iterateId === undefined || collectId === undefined || !hasPath(iterateId, collectId)) { + return false; + } + + const iterateCollectionEdges = edges.filter( + (edge) => edge.destination.node_id === iterateId && edge.destination.field === 'collection' + ); + const iterateCollectionSourceId = iterateCollectionEdges[0]?.source.node_id; + if ( + iterateCollectionEdges.length !== 1 || + iterateCollectionSourceId === undefined || + (iterateCollectionSourceId !== forId && !bodyPathNodeIds.has(iterateCollectionSourceId)) + ) { + return false; + } + + const returnOutputEdges = edges.filter( + (edge) => edge.destination.node_id === returnId && edge.destination.field === 'output' + ); + if ( + returnOutputEdges.length !== 1 || + returnOutputEdges[0]?.source.node_id !== collectId || + returnOutputEdges[0]?.source.field !== 'collection' + ) { + return false; + } + + const unsupportedReturnInput = edges.some( + (edge) => + edge.destination.node_id === returnId && + edge.destination.field !== 'output' && + edge.destination.field !== 'continue_condition' && + (edge.destination.field !== 'state' || edge.source.node_id !== forId || edge.source.field !== 'state') + ); + if (unsupportedReturnInput) { + return false; + } + + const collectCollectionEdges = edges.filter( + (edge) => edge.destination.node_id === collectId && edge.destination.field === 'collection' + ); + const collectItemEdges = edges.filter( + (edge) => edge.destination.node_id === collectId && edge.destination.field === 'item' + ); + if (collectCollectionEdges.length !== 0 || collectItemEdges.length !== 1) { + return false; + } + const collectItemSourceId = collectItemEdges[0]?.source.node_id; + if (collectItemSourceId === undefined || !hasPath(iterateId, collectItemSourceId)) { + return false; + } + + for (const bodyNodeId of bodyPathNodeIds) { + if (bodyNodeId === iterateId || bodyNodeId === collectId || bodyNodeId === returnId) { + continue; + } + if (!hasPath(bodyNodeId, collectId)) { + return false; + } + if (!hasPath(bodyNodeId, iterateId) && !hasPath(iterateId, bodyNodeId)) { + return false; + } + } + + return true; + }; + + const getSupportedNestedForBody = ( + forId: string, + reachableBodyNodeIds: Set, + reachableReturnIds: string[] + ): { bodyPathNodeIds: Set; returnId: string } | null => { + const outerReturnId = linkedReturnByForId.get(forId); + if (outerReturnId === undefined || !reachableReturnIds.includes(outerReturnId)) { + return null; + } + + const innerForIds = [...reachableBodyNodeIds].filter((nodeId) => nodes[nodeId]?.type === 'for'); + const directInnerForIds = innerForIds.filter( + (innerForId) => + !innerForIds.some((otherInnerForId) => otherInnerForId !== innerForId && hasPath(otherInnerForId, innerForId)) + ); + if (directInnerForIds.length === 0) { + return null; + } + const innerBodyPathNodeIds = new Set(); + + for (const innerForId of directInnerForIds) { + if (innerForId === undefined) { + return null; + } + + const innerIterationEdges = edges.filter( + (edge) => edge.source.node_id === innerForId && ITERATION_OUTPUT_FIELDS.has(edge.source.field) + ); + if (innerIterationEdges.length === 0) { + return null; + } + const innerReachableBodyNodeIds = walk( + innerIterationEdges.map((edge) => edge.destination.node_id), + outgoing + ); + const innerReachableReturnIds = [...innerReachableBodyNodeIds].filter( + (nodeId) => nodes[nodeId]?.type === 'for_return' + ); + const innerReturnId = linkedReturnByForId.get(innerForId); + if (innerReturnId === undefined || !innerReachableReturnIds.includes(innerReturnId)) { + return null; + } + + const innerReturnAncestors = walk([innerReturnId], incoming); + const childBodyPathNodeIds = new Set( + [...innerReachableBodyNodeIds].filter((nodeId) => nodeId === innerReturnId || innerReturnAncestors.has(nodeId)) + ); + childBodyPathNodeIds.add(innerReturnId); + const innerNestedForIds = [...childBodyPathNodeIds].filter((nodeId) => nodes[nodeId]?.type === 'for'); + if ([...childBodyPathNodeIds].some((nodeId) => nodes[nodeId]?.type === 'iterate')) { + return null; + } + const innerNestedBody = + innerNestedForIds.length > 0 + ? getSupportedNestedForBody(innerForId, innerReachableBodyNodeIds, innerReachableReturnIds) + : null; + if (innerNestedForIds.length > 0 && innerNestedBody === null) { + return null; + } + if (innerNestedBody !== null) { + for (const bodyNodeId of innerNestedBody.bodyPathNodeIds) { + childBodyPathNodeIds.add(bodyNodeId); + } + } + + const innerCollectionEdges = edges.filter( + (edge) => edge.destination.node_id === innerForId && edge.destination.field === 'collection' + ); + const innerCollectionSourceId = innerCollectionEdges[0]?.source.node_id; + if ( + innerCollectionEdges.length !== 1 || + innerCollectionSourceId === undefined || + (innerCollectionSourceId !== forId && !reachableBodyNodeIds.has(innerCollectionSourceId)) + ) { + return null; + } + + const unsupportedInnerReturnInput = edges.some( + (edge) => + edge.destination.node_id === innerReturnId && + edge.destination.field === 'state' && + edge.source.node_id !== innerForId && + !childBodyPathNodeIds.has(edge.source.node_id) + ); + if (unsupportedInnerReturnInput) { + return null; + } + + for (const bodyNodeId of childBodyPathNodeIds) { + innerBodyPathNodeIds.add(bodyNodeId); + } + } + if ( + new Set(reachableReturnIds.filter((returnId) => !innerBodyPathNodeIds.has(returnId))).size !== 1 || + !reachableReturnIds.includes(outerReturnId) + ) { + return null; + } + + const outerReturnOutputEdges = edges.filter( + (edge) => edge.destination.node_id === outerReturnId && edge.destination.field === 'output' + ); + if (outerReturnOutputEdges.length !== 1) { + return null; + } + const unsupportedOuterReturnInput = edges.some( + (edge) => + edge.destination.node_id === outerReturnId && + edge.destination.field !== 'output' && + edge.destination.field !== 'continue_condition' && + (edge.destination.field !== 'state' || edge.source.node_id !== forId || edge.source.field !== 'state') + ); + if (unsupportedOuterReturnInput) { + return null; + } + + const outerPreparationNodeIds = new Set(); + for (const innerForId of directInnerForIds) { + for (const bodyNodeId of reachableBodyNodeIds) { + if (walk([innerForId], incoming).has(bodyNodeId)) { + outerPreparationNodeIds.add(bodyNodeId); + } + } + outerPreparationNodeIds.add(innerForId); + } + const innerFinalDescendantNodeIds = new Set(); + for (const innerForId of directInnerForIds) { + for (const destinationId of edges + .filter((edge) => edge.source.node_id === innerForId && edge.source.field === 'output_collection') + .map((edge) => edge.destination.node_id)) { + for (const descendantId of walk([destinationId], outgoing)) { + innerFinalDescendantNodeIds.add(descendantId); + } + } + } + const continuationNodeIds = new Set( + [...reachableBodyNodeIds].filter( + (nodeId) => + !outerPreparationNodeIds.has(nodeId) && !innerBodyPathNodeIds.has(nodeId) && nodeId !== outerReturnId + ) + ); + if ( + edges.some( + (edge) => + edge.destination.node_id === outerReturnId && + edge.destination.field === 'continue_condition' && + edge.source.node_id !== forId && + !continuationNodeIds.has(edge.source.node_id) && + !(directInnerForIds.includes(edge.source.node_id) && FINAL_OUTPUT_FIELDS.has(edge.source.field)) + ) + ) { + return null; + } + if ([...continuationNodeIds].some((nodeId) => !innerFinalDescendantNodeIds.has(nodeId))) { + return null; + } + if ([...continuationNodeIds].some((nodeId) => !hasPath(nodeId, outerReturnId))) { + return null; + } + if ( + [...continuationNodeIds].some( + (nodeId) => + nodes[nodeId]?.type === 'for' || nodes[nodeId]?.type === 'iterate' || nodes[nodeId]?.type === 'for_return' + ) + ) { + return null; + } + if ( + [...continuationNodeIds].some((nodeId) => + edges.some( + (edge) => + edge.destination.node_id === nodeId && + (innerBodyPathNodeIds.has(edge.source.node_id) || + (directInnerForIds.includes(edge.source.node_id) && edge.source.field !== 'output_collection')) + ) + ) + ) { + return null; + } + const outerReturnOutputSource = outerReturnOutputEdges[0]?.source; + if (outerReturnOutputSource !== undefined && directInnerForIds.includes(outerReturnOutputSource.node_id)) { + if ( + directInnerForIds.length !== 1 || + outerReturnOutputSource.field !== 'output_collection' || + continuationNodeIds.size > 0 + ) { + return null; + } + } else if (outerReturnOutputSource === undefined || !continuationNodeIds.has(outerReturnOutputSource.node_id)) { + return null; + } + + if ( + directInnerForIds.some( + (innerForId) => + !edges + .filter((edge) => edge.source.node_id === innerForId && FINAL_OUTPUT_FIELDS.has(edge.source.field)) + .some( + (edge) => continuationNodeIds.has(edge.destination.node_id) || edge.destination.node_id === outerReturnId + ) + ) + ) { + return null; + } + + const bodyPathNodeIds = new Set([ + ...outerPreparationNodeIds, + ...innerBodyPathNodeIds, + ...continuationNodeIds, + outerReturnId, + ]); + if ([...reachableBodyNodeIds].some((nodeId) => !bodyPathNodeIds.has(nodeId))) { + return null; + } + if ( + [...outerPreparationNodeIds].some( + (nodeId) => + !directInnerForIds.includes(nodeId) && (nodes[nodeId]?.type === 'for' || nodes[nodeId]?.type === 'iterate') + ) + ) { + return null; + } + for (const bodyNodeId of outerPreparationNodeIds) { + if (directInnerForIds.includes(bodyNodeId) || innerBodyPathNodeIds.has(bodyNodeId)) { + continue; + } + if (!directInnerForIds.some((innerForId) => hasPath(bodyNodeId, innerForId))) { + return null; + } + } + + return { bodyPathNodeIds, returnId: outerReturnId }; + }; + + const matchingForIdsByReturnId = new Map(); + + for (const node of Object.values(nodes)) { + if (node.type !== 'for') { + continue; + } + + const iterationEdges = edges.filter( + (edge) => edge.source.node_id === node.id && ITERATION_OUTPUT_FIELDS.has(edge.source.field) + ); + if ( + edges.filter((edge) => edge.destination.node_id === node.id && edge.destination.field === 'collection').length > + 1 || + edges.filter((edge) => edge.destination.node_id === node.id && edge.destination.field === 'state').length > 1 + ) { + return 'nodes.forLoopInputCount'; + } + if (iterationEdges.length === 0) { + return 'nodes.forLoopMissingIterationOutput'; + } + + const reachableBodyNodeIds = walk( + iterationEdges.map((edge) => edge.destination.node_id), + outgoing + ); + const reachableReturnIds = [...reachableBodyNodeIds].filter((nodeId) => nodes[nodeId]?.type === 'for_return'); + const nestedForNodeIds = [...reachableBodyNodeIds].filter( + (nodeId) => + nodeId !== node.id && + nodes[nodeId]?.type === 'for' && + ![...reachableBodyNodeIds].some( + (otherNodeId) => otherNodeId !== nodeId && nodes[otherNodeId]?.type === 'for' && hasPath(otherNodeId, nodeId) + ) + ); + const nestedBody = + nestedForNodeIds.length > 0 ? getSupportedNestedForBody(node.id, reachableBodyNodeIds, reachableReturnIds) : null; + if (nestedForNodeIds.length > 0 && nestedBody === null) { + return 'nodes.forLoopNestedUnsupported'; + } + + const linkedReturnId = linkedReturnByForId.get(node.id); + if (nestedBody === null && (linkedReturnId === undefined || !reachableReturnIds.includes(linkedReturnId))) { + return 'nodes.forLoopReturnCount'; + } + + const returnId = nestedBody?.returnId ?? linkedReturnId; + if (returnId === undefined) { + return 'nodes.forLoopReturnCount'; + } + matchingForIdsByReturnId.set(returnId, [...(matchingForIdsByReturnId.get(returnId) ?? []), node.id]); + + const bodyPathNodeIds = + nestedBody?.bodyPathNodeIds ?? + (() => { + const returnAncestorIds = walk(incoming.get(returnId) ?? [], incoming); + const path = new Set( + [...reachableBodyNodeIds].filter((nodeId) => nodeId === returnId || returnAncestorIds.has(nodeId)) + ); + path.add(returnId); + return path; + })(); + + if ([...reachableBodyNodeIds].some((nodeId) => !bodyPathNodeIds.has(nodeId))) { + return 'nodes.forLoopUnterminatedBody'; + } + + const iterateNodeIds = [...bodyPathNodeIds].filter((nodeId) => nodes[nodeId]?.type === 'iterate'); + if ( + iterateNodeIds.length > 0 && + !supportsNestedIterateBody( + bodyPathNodeIds, + iterateNodeIds, + [...bodyPathNodeIds].filter((nodeId) => nodes[nodeId]?.type === 'collect'), + returnId, + node.id + ) + ) { + return 'nodes.forLoopIterateUnsupported'; + } + + for (const bodyNodeId of bodyPathNodeIds) { + for (const sourceId of incoming.get(bodyNodeId) ?? []) { + if (sourceId === node.id || bodyPathNodeIds.has(sourceId)) { + continue; + } + const activeSourceIds = walk([sourceId], incoming); + if ([...activeSourceIds].some((sourceNodeId) => nodes[sourceNodeId]?.type === 'iterate')) { + return 'nodes.forLoopIteratorInputUnsupported'; + } + } + } + + if ( + edges.some( + (edge) => + edge.source.node_id === node.id && + FINAL_OUTPUT_FIELDS.has(edge.source.field) && + bodyPathNodeIds.has(edge.destination.node_id) + ) + ) { + return 'nodes.forLoopFinalOutputInBody'; + } + + for (const bodyNodeId of bodyPathNodeIds) { + if (bodyNodeId === returnId) { + continue; + } + if ((outgoing.get(bodyNodeId) ?? []).some((destinationId) => !bodyPathNodeIds.has(destinationId))) { + return 'nodes.forLoopBodyEscape'; + } + } + } + + for (const node of Object.values(nodes)) { + if (node.type === 'for_return' && matchingForIdsByReturnId.get(node.id)?.length !== 1) { + return 'nodes.forReturnOwnership'; + } + if ( + node.type === 'for_return' && + (edges.filter((edge) => edge.destination.node_id === node.id && edge.destination.field === 'output').length > 1 || + edges.filter((edge) => edge.destination.node_id === node.id && edge.destination.field === 'state').length > 1 || + edges.filter((edge) => edge.destination.node_id === node.id && edge.destination.field === 'continue_condition') + .length > 1) + ) { + return 'nodes.forReturnInputCount'; + } + } + + return null; +}; diff --git a/invokeai/frontend/web/src/features/nodes/util/node/getOutputFieldNamesByScope.test.ts b/invokeai/frontend/web/src/features/nodes/util/node/getOutputFieldNamesByScope.test.ts new file mode 100644 index 00000000000..4cea157b14c --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/util/node/getOutputFieldNamesByScope.test.ts @@ -0,0 +1,49 @@ +import { for_return } from 'features/nodes/store/util/testUtils'; +import type { FieldOutputTemplate } from 'features/nodes/types/field'; +import { getOutputFieldNamesByScope } from 'features/nodes/util/node/getOutputFieldNamesByScope'; +import { describe, expect, it } from 'vitest'; + +const buildOutput = ( + name: string, + output_scope: FieldOutputTemplate['output_scope'], + ui_order: number, + ui_hidden = false +): FieldOutputTemplate => ({ + fieldKind: 'output', + name, + title: name, + description: name, + type: { name: 'AnyField', cardinality: 'SINGLE', batch: false }, + ui_hidden, + ui_order, + output_scope, +}); + +describe(getOutputFieldNamesByScope.name, () => { + it('sorts visible output fields and partitions them by scope', () => { + const fields = [ + buildOutput('output_collection', 'final', 3), + buildOutput('hidden_iteration_value', 'iteration', 0, true), + buildOutput('value', null, 2), + buildOutput('item', 'iteration', 1), + ]; + + expect(getOutputFieldNamesByScope(fields)).toEqual({ + all: ['item', 'value', 'output_collection'], + unscoped: ['value'], + iteration: ['item'], + final: ['output_collection'], + }); + }); + + it('hides ForReturn scheduler outputs from the node UI', () => { + expect(getOutputFieldNamesByScope(Object.values(for_return.outputs))).toEqual({ + all: [], + unscoped: [], + iteration: [], + final: [], + }); + expect(for_return.inputs.output?.ui_hidden).toBe(false); + expect(for_return.inputs.state?.ui_hidden).toBe(false); + }); +}); diff --git a/invokeai/frontend/web/src/features/nodes/util/node/getOutputFieldNamesByScope.ts b/invokeai/frontend/web/src/features/nodes/util/node/getOutputFieldNamesByScope.ts new file mode 100644 index 00000000000..7ac39707381 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/util/node/getOutputFieldNamesByScope.ts @@ -0,0 +1,21 @@ +import type { FieldOutputTemplate } from 'features/nodes/types/field'; +import { getSortedFilteredFieldNames } from 'features/nodes/util/node/getSortedFilteredFieldNames'; + +export type OutputFieldNamesByScope = { + all: string[]; + unscoped: string[]; + iteration: string[]; + final: string[]; +}; + +export const getOutputFieldNamesByScope = (fields: FieldOutputTemplate[]): OutputFieldNamesByScope => { + const all = getSortedFilteredFieldNames(fields); + const fieldsByName = new Map(fields.map((field) => [field.name, field])); + + return { + all, + unscoped: all.filter((name) => !fieldsByName.get(name)?.output_scope), + iteration: all.filter((name) => fieldsByName.get(name)?.output_scope === 'iteration'), + final: all.filter((name) => fieldsByName.get(name)?.output_scope === 'final'), + }; +}; diff --git a/invokeai/frontend/web/src/features/nodes/util/node/getOutputFieldRows.test.ts b/invokeai/frontend/web/src/features/nodes/util/node/getOutputFieldRows.test.ts new file mode 100644 index 00000000000..619f590f770 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/util/node/getOutputFieldRows.test.ts @@ -0,0 +1,37 @@ +import { getOutputFieldRows } from 'features/nodes/util/node/getOutputFieldRows'; +import { describe, expect, it } from 'vitest'; + +describe(getOutputFieldRows.name, () => { + it('returns ordinary outputs without section headers', () => { + expect( + getOutputFieldRows({ + all: ['value', 'metadata'], + unscoped: ['value', 'metadata'], + iteration: [], + final: [], + }) + ).toEqual([ + { type: 'field', fieldName: 'value' }, + { type: 'field', fieldName: 'metadata' }, + ]); + }); + + it('groups scoped outputs under iteration and final section headers', () => { + expect( + getOutputFieldRows({ + all: ['value', 'item', 'state', 'output_collection', 'final_state'], + unscoped: ['value'], + iteration: ['item', 'state'], + final: ['output_collection', 'final_state'], + }) + ).toEqual([ + { type: 'field', fieldName: 'value' }, + { type: 'header', scope: 'iteration' }, + { type: 'field', fieldName: 'item' }, + { type: 'field', fieldName: 'state' }, + { type: 'header', scope: 'final' }, + { type: 'field', fieldName: 'output_collection' }, + { type: 'field', fieldName: 'final_state' }, + ]); + }); +}); diff --git a/invokeai/frontend/web/src/features/nodes/util/node/getOutputFieldRows.ts b/invokeai/frontend/web/src/features/nodes/util/node/getOutputFieldRows.ts new file mode 100644 index 00000000000..c39d8e44ed4 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/util/node/getOutputFieldRows.ts @@ -0,0 +1,21 @@ +import type { OutputFieldNamesByScope } from 'features/nodes/util/node/getOutputFieldNamesByScope'; + +type OutputFieldRow = { type: 'field'; fieldName: string } | { type: 'header'; scope: 'iteration' | 'final' }; + +const getFieldRows = (fieldNames: string[]): OutputFieldRow[] => + fieldNames.map((fieldName) => ({ type: 'field', fieldName })); + +export const getOutputFieldRows = (fieldNames: OutputFieldNamesByScope): OutputFieldRow[] => { + if (fieldNames.iteration.length === 0 && fieldNames.final.length === 0) { + return getFieldRows(fieldNames.all); + } + + const rows = getFieldRows(fieldNames.unscoped); + if (fieldNames.iteration.length > 0) { + rows.push({ type: 'header', scope: 'iteration' }, ...getFieldRows(fieldNames.iteration)); + } + if (fieldNames.final.length > 0) { + rows.push({ type: 'header', scope: 'final' }, ...getFieldRows(fieldNames.final)); + } + return rows; +}; diff --git a/invokeai/frontend/web/src/features/nodes/util/node/nodeUpdate.ts b/invokeai/frontend/web/src/features/nodes/util/node/nodeUpdate.ts index ca1b397a91d..e648b86f397 100644 --- a/invokeai/frontend/web/src/features/nodes/util/node/nodeUpdate.ts +++ b/invokeai/frontend/web/src/features/nodes/util/node/nodeUpdate.ts @@ -18,7 +18,7 @@ type UpdateNodeOptions = { export const getConnectedInputNames = (nodeId: string, edges: ConnectedInputEdge[]): Set => new Set( edges.flatMap((edge) => - edge.type === 'default' && edge.target === nodeId && edge.targetHandle ? [edge.targetHandle] : [] + edge.type !== 'loop_linkage' && edge.target === nodeId && edge.targetHandle ? [edge.targetHandle] : [] ) ); diff --git a/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputTemplate.ts b/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputTemplate.ts index 4c0e4130e76..c5d660779ce 100644 --- a/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputTemplate.ts +++ b/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputTemplate.ts @@ -46,13 +46,17 @@ import { isStatefulFieldType, isStringCollectionFieldType, } from 'features/nodes/types/field'; -import type { InvocationFieldSchema } from 'features/nodes/types/openapi'; +import type { InvocationInputFieldSchema } from 'features/nodes/types/openapi'; import { isSchemaObject } from 'features/nodes/types/openapi'; import { t } from 'i18next'; // eslint-disable-next-line @typescript-eslint/no-explicit-any type FieldInputTemplateBuilder = // valid `any`! - (arg: { schemaObject: InvocationFieldSchema; baseField: Omit; fieldType: T['type'] }) => T; + (arg: { + schemaObject: InvocationInputFieldSchema; + baseField: Omit; + fieldType: T['type']; + }) => T; const buildIntegerFieldInputTemplate: FieldInputTemplateBuilder = ({ schemaObject, @@ -625,7 +629,7 @@ const TEMPLATE_BUILDER_MAP: Record { diff --git a/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldOutputTemplate.test.ts b/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldOutputTemplate.test.ts new file mode 100644 index 00000000000..d3b8ea05cd9 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldOutputTemplate.test.ts @@ -0,0 +1,26 @@ +import type { FieldType } from 'features/nodes/types/field'; +import type { InvocationOutputFieldSchema } from 'features/nodes/types/openapi'; +import { buildFieldOutputTemplate } from 'features/nodes/util/schema/buildFieldOutputTemplate'; +import { describe, expect, it } from 'vitest'; + +const fieldType: FieldType = { + name: 'StringField', + cardinality: 'SINGLE', + batch: false, +}; + +describe('buildFieldOutputTemplate', () => { + it('preserves output scope metadata', () => { + const fieldSchema = { + field_kind: 'output', + title: 'Item', + description: 'The current item', + ui_hidden: false, + output_scope: 'iteration', + } as InvocationOutputFieldSchema; + + const template = buildFieldOutputTemplate(fieldSchema, 'item', fieldType); + + expect(template.output_scope).toBe('iteration'); + }); +}); diff --git a/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldOutputTemplate.ts b/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldOutputTemplate.ts index 960af9395b2..50ee9f161af 100644 --- a/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldOutputTemplate.ts +++ b/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldOutputTemplate.ts @@ -1,13 +1,13 @@ import { startCase } from 'es-toolkit/compat'; import type { FieldOutputTemplate, FieldType } from 'features/nodes/types/field'; -import type { InvocationFieldSchema } from 'features/nodes/types/openapi'; +import type { InvocationOutputFieldSchema } from 'features/nodes/types/openapi'; export const buildFieldOutputTemplate = ( - fieldSchema: InvocationFieldSchema, + fieldSchema: InvocationOutputFieldSchema, fieldName: string, fieldType: FieldType ): FieldOutputTemplate => { - const { title, description, ui_hidden, ui_type, ui_order } = fieldSchema; + const { title, description, ui_hidden, ui_type, ui_order, output_scope } = fieldSchema; const template: FieldOutputTemplate = { fieldKind: 'output', @@ -18,6 +18,7 @@ export const buildFieldOutputTemplate = ( ui_hidden, ui_type, ui_order, + output_scope, }; return template; diff --git a/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.test.ts b/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.test.ts index 1317f973ddf..eb31e5d0ac7 100644 --- a/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.test.ts +++ b/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.test.ts @@ -1,9 +1,26 @@ import { omit, pick } from 'es-toolkit/compat'; -import { call_saved_workflow, schema, templates, workflow_return } from 'features/nodes/store/util/testUtils'; +import { + call_saved_workflow, + for_loop, + for_return, + schema, + templates, + workflow_return, +} from 'features/nodes/store/util/testUtils'; +import type { InvocationTemplate } from 'features/nodes/types/invocation'; import { parseSchema } from 'features/nodes/util/schema/parseSchema'; +import type { OpenAPIV3_1 } from 'openapi-types'; import { describe, expect, it } from 'vitest'; +import generatedSchemaJSON from '../../../../../openapi.json?raw'; + const stripUndefinedDeep = (value: T): T => JSON.parse(JSON.stringify(value)) as T; +const normalizeInputUiHidden = (template: InvocationTemplate): InvocationTemplate => ({ + ...template, + inputs: Object.fromEntries( + Object.entries(template.inputs).map(([name, input]) => [name, { ...input, ui_hidden: input.ui_hidden ?? false }]) + ), +}); describe('parseSchema', () => { it('should parse the schema', () => { @@ -46,4 +63,67 @@ describe('parseSchema', () => { expect(collectionInput.type.name).toBe('CollectionField'); expect(collectionInput.ui_type).toBe('CollectionField'); }); + it('should keep the loop test templates aligned with the generated schema', () => { + const generatedSchema = JSON.parse(generatedSchemaJSON) as OpenAPIV3_1.Document; + const parsed = parseSchema(generatedSchema, ['for', 'for_return']); + + expect( + stripUndefinedDeep( + Object.fromEntries(Object.entries(parsed).map(([type, template]) => [type, normalizeInputUiHidden(template)])) + ) + ).toEqual( + stripUndefinedDeep({ + for: normalizeInputUiHidden(for_loop), + for_return: normalizeInputUiHidden(for_return), + }) + ); + + // Keep this explicit check so the generated schema and hand-maintained loop fixture cannot drift together. + expect(parsed.for_return?.version).toBe('1.3.2'); + expect(parsed.for_return?.inputs.continue_condition).toMatchObject({ + input: 'any', + required: false, + default: true, + type: { name: 'BooleanField' }, + }); + }); + it('should expose state_set.value as an AnyField connection input', () => { + const generatedSchema = JSON.parse(generatedSchemaJSON) as OpenAPIV3_1.Document; + const parsed = parseSchema(generatedSchema, ['state_set']); + const valueInput = parsed.state_set?.inputs.value; + + expect(valueInput).toMatchObject({ + input: 'connection', + ui_type: 'AnyField', + type: { name: 'AnyField', cardinality: 'SINGLE', batch: false }, + }); + expect(valueInput?.default).toBeUndefined(); + }); + it('should expose state_get.default and value as AnyField connections', () => { + const generatedSchema = JSON.parse(generatedSchemaJSON) as OpenAPIV3_1.Document; + const parsed = parseSchema(generatedSchema, ['state_get']); + const template = parsed.state_get; + + expect(template?.inputs.default).toMatchObject({ + input: 'connection', + ui_type: 'AnyField', + type: { name: 'AnyField', cardinality: 'SINGLE', batch: false }, + }); + expect(template?.inputs.default?.default).toBeUndefined(); + expect(template?.outputs.value).toMatchObject({ + ui_type: 'AnyField', + type: { name: 'AnyField', cardinality: 'SINGLE', batch: false }, + }); + }); + it('should expose state_merge.values as an AnyField connection input', () => { + const generatedSchema = JSON.parse(generatedSchemaJSON) as OpenAPIV3_1.Document; + const parsed = parseSchema(generatedSchema, ['state_merge']); + const valuesInput = parsed.state_merge?.inputs.values; + + expect(valuesInput).toMatchObject({ + input: 'connection', + ui_type: 'AnyField', + type: { name: 'AnyField', cardinality: 'SINGLE', batch: false }, + }); + }); }); diff --git a/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.ts b/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.ts index 47be2c62ec7..659ad823892 100644 --- a/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.ts +++ b/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.ts @@ -14,7 +14,8 @@ import { import type { InvocationTemplate } from 'features/nodes/types/invocation'; import type { InvocationFieldSchema, InvocationSchemaObject } from 'features/nodes/types/openapi'; import { - isInvocationFieldSchema, + isInvocationInputFieldSchema, + isInvocationOutputFieldSchema, isInvocationOutputSchemaObject, isInvocationSchemaObject, } from 'features/nodes/types/openapi'; @@ -42,6 +43,9 @@ const isReservedInputField = (nodeType: string, fieldName: string) => { if (nodeType === 'iterate' && fieldName === 'index') { return true; } + if (nodeType === 'for' && fieldName === 'index') { + return true; + } return false; }; @@ -126,7 +130,7 @@ export const parseSchema = ( return inputsAccumulator; } - if (!isInvocationFieldSchema(property)) { + if (!isInvocationInputFieldSchema(property)) { log.warn({ node: type, field: propertyName, schema: parseify(property) }, 'Unhandled input property'); return inputsAccumulator; } @@ -203,7 +207,7 @@ export const parseSchema = ( return outputsAccumulator; } - if (!isInvocationFieldSchema(property)) { + if (!isInvocationOutputFieldSchema(property)) { log.warn({ node: type, field: propertyName, schema: parseify(property) }, 'Unhandled output property'); return outputsAccumulator; } diff --git a/invokeai/frontend/web/src/features/nodes/util/workflow/buildWorkflow.test.ts b/invokeai/frontend/web/src/features/nodes/util/workflow/buildWorkflow.test.ts index c7bdd80acea..42c8a329110 100644 --- a/invokeai/frontend/web/src/features/nodes/util/workflow/buildWorkflow.test.ts +++ b/invokeai/frontend/web/src/features/nodes/util/workflow/buildWorkflow.test.ts @@ -1,8 +1,40 @@ import { getInitialWorkflow } from 'features/nodes/store/nodesSlice'; -import { buildNode, call_saved_workflow } from 'features/nodes/store/util/testUtils'; +import { buildEdge, buildNode, call_saved_workflow, for_loop, for_return } from 'features/nodes/store/util/testUtils'; import { describe, expect, it } from 'vitest'; describe('buildWorkflowFast', () => { + it('serializes loop linkage handles as loop_linkage edges even when the edge type is stale', async () => { + Object.assign(globalThis, { + window: { + location: { + origin: 'http://localhost', + }, + }, + }); + + const { buildWorkflowFast } = await import('features/nodes/util/workflow/buildWorkflow'); + const forNode = buildNode(for_loop); + const returnNode = buildNode(for_return); + + const workflow = buildWorkflowFast({ + _version: 1, + formFieldInitialValues: {}, + ...getInitialWorkflow(), + nodes: [forNode, returnNode], + edges: [buildEdge(forNode.id, 'loop_linkage', returnNode.id, 'loop_linkage')], + }); + + expect(workflow.edges).toEqual([ + expect.objectContaining({ + type: 'loop_linkage', + source: forNode.id, + sourceHandle: 'loop_linkage', + target: returnNode.id, + targetHandle: 'loop_linkage', + }), + ]); + }); + it('persists the selected workflow id for call_saved_workflow nodes', async () => { Object.assign(globalThis, { window: { diff --git a/invokeai/frontend/web/src/features/nodes/util/workflow/buildWorkflow.ts b/invokeai/frontend/web/src/features/nodes/util/workflow/buildWorkflow.ts index 6d35fdf3f47..2ed98d4475c 100644 --- a/invokeai/frontend/web/src/features/nodes/util/workflow/buildWorkflow.ts +++ b/invokeai/frontend/web/src/features/nodes/util/workflow/buildWorkflow.ts @@ -5,6 +5,7 @@ import { parseify } from 'common/util/serialize'; import { pick } from 'es-toolkit/compat'; import { selectNodesSlice } from 'features/nodes/store/selectors'; import type { NodesState } from 'features/nodes/store/types'; +import { getEdgeTypeFromHandles } from 'features/nodes/store/util/reactFlowUtil'; import { isConnectorNode, isInvocationNode, isNotesNode } from 'features/nodes/types/invocation'; import type { WorkflowV3 } from 'features/nodes/types/workflow'; import { zWorkflowV3 } from 'features/nodes/types/workflow'; @@ -52,9 +53,14 @@ export const buildWorkflowFast = (nodesState: NodesState): WorkflowV3 => { } for (const edge of edges) { - if (edge.type === 'default' && edge.sourceHandle && edge.targetHandle) { - const { id, type, source, target, sourceHandle, targetHandle, hidden } = edge; - newWorkflow.edges.push({ id, type, source, target, sourceHandle, targetHandle, hidden }); + if ((edge.type === 'default' || edge.type === 'loop_linkage') && edge.sourceHandle && edge.targetHandle) { + const { id, source, target, sourceHandle, targetHandle, hidden } = edge; + const type = edge.type === 'loop_linkage' ? 'loop_linkage' : getEdgeTypeFromHandles(sourceHandle, targetHandle); + if (type === 'loop_linkage') { + newWorkflow.edges.push({ id, type, source, target, sourceHandle, targetHandle }); + } else { + newWorkflow.edges.push({ id, type, source, target, sourceHandle, targetHandle, hidden }); + } } else if (edge.type === 'collapsed') { const { id, type, source, target } = edge; newWorkflow.edges.push({ id, type, source, target }); diff --git a/invokeai/frontend/web/src/features/nodes/util/workflow/graphToWorkflow.test.ts b/invokeai/frontend/web/src/features/nodes/util/workflow/graphToWorkflow.test.ts index 18c30d486b2..8837a1df0aa 100644 --- a/invokeai/frontend/web/src/features/nodes/util/workflow/graphToWorkflow.test.ts +++ b/invokeai/frontend/web/src/features/nodes/util/workflow/graphToWorkflow.test.ts @@ -1,11 +1,14 @@ import { $templates } from 'features/nodes/store/nodesSlice'; import type { Templates } from 'features/nodes/store/types'; +import { for_loop, for_return } from 'features/nodes/store/util/testUtils'; import type { InvocationTemplate } from 'features/nodes/types/invocation'; import { isWorkflowInvocationNode } from 'features/nodes/types/workflow'; +import { buildNodesGraph } from 'features/nodes/util/graph/buildNodesGraph'; +import { getOutputFieldNamesByScope } from 'features/nodes/util/node/getOutputFieldNamesByScope'; +import { graphToWorkflow } from 'features/nodes/util/workflow/graphToWorkflow'; import type { NonNullableGraph } from 'services/api/types'; import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; -import { graphToWorkflow } from './graphToWorkflow'; import { parseAndMigrateWorkflow } from './migrations'; // Minimal templates needed to render the user's graph. We use the same shape @@ -233,9 +236,10 @@ const imageCollectionTemplate = { describe('graphToWorkflow', () => { const originalTemplates = $templates.get(); + const loopTemplates: Templates = { for: for_loop, for_return }; beforeEach(() => { - $templates.set({ image_collection: imageCollectionTemplate }); + $templates.set({ image_collection: imageCollectionTemplate, ...loopTemplates }); }); afterEach(() => { @@ -298,4 +302,134 @@ describe('graphToWorkflow', () => { expect(node.data.inputs.images?.value).toBeUndefined(); expect(node.data.inputs.collection?.value).toEqual(images); }); + + it('round-trips For and ForReturn nodes and resolves scoped outputs from their templates', () => { + const graph = { + id: 'graph', + nodes: { + for: { + id: 'for', + type: 'for', + collection: ['alpha', 'beta'], + state: null, + index: -1, + }, + return: { + id: 'return', + type: 'for_return', + output: null, + state: null, + continue_condition: null, + }, + }, + edges: [ + { + type: 'default', + source: { node_id: 'for', field: 'item' }, + destination: { node_id: 'return', field: 'output' }, + }, + { + type: 'loop_linkage', + source: { node_id: 'for', field: 'loop_linkage' }, + destination: { node_id: 'return', field: 'loop_linkage' }, + }, + ], + } satisfies NonNullableGraph; + + const workflow = graphToWorkflow(graph, false); + const forNode = workflow.nodes.find((node) => node.id === 'for'); + const returnNode = workflow.nodes.find((node) => node.id === 'return'); + if (!isWorkflowInvocationNode(forNode) || !isWorkflowInvocationNode(returnNode)) { + throw new Error('Expected For and ForReturn invocation nodes'); + } + + expect(forNode.data.inputs.collection?.value).toEqual(['alpha', 'beta']); + expect(forNode.data.inputs.state?.value).toBeNull(); + expect(forNode.data.inputs.index).toBeUndefined(); + expect(returnNode.data.inputs.continue_condition?.value).toBeNull(); + expect(returnNode.data.inputs.state?.value).toBeNull(); + expect(workflow.edges).toHaveLength(2); + expect(workflow.edges[0]).toMatchObject({ + type: 'default', + source: 'for', + sourceHandle: 'item', + target: 'return', + targetHandle: 'output', + }); + expect(workflow.edges[1]).toMatchObject({ + type: 'loop_linkage', + source: 'for', + sourceHandle: 'loop_linkage', + target: 'return', + targetHandle: 'loop_linkage', + }); + const resolvedForTemplate = loopTemplates[forNode.data.type]; + if (!resolvedForTemplate) { + throw new Error('Expected the round-tripped For node type to resolve its template'); + } + expect(getOutputFieldNamesByScope(Object.values(resolvedForTemplate.outputs))).toEqual({ + all: ['loop_linkage', 'item', 'index', 'total', 'state', 'output_collection', 'final_state'], + unscoped: ['loop_linkage'], + iteration: ['item', 'index', 'total', 'state'], + final: ['output_collection', 'final_state'], + }); + + const rootState = { + nodes: { + past: [], + future: [], + present: { + _version: 1, + formFieldInitialValues: {}, + ...workflow, + }, + }, + gallery: { + autoAddBoardId: 'none', + }, + } as never; + const rebuiltGraph = buildNodesGraph(rootState, loopTemplates); + + expect(rebuiltGraph.nodes.for).toMatchObject({ + type: 'for', + collection: ['alpha', 'beta'], + state: null, + }); + expect(rebuiltGraph.nodes.return).toMatchObject({ + type: 'for_return', + state: null, + continue_condition: null, + }); + expect(rebuiltGraph.edges).toEqual(graph.edges); + }); + + it('normalizes a default graph edge between loop linkage fields', () => { + const workflow = graphToWorkflow( + { + id: 'graph', + nodes: { + for: { id: 'for', type: 'for', collection: [], state: null }, + return: { id: 'return', type: 'for_return', output: null, state: null, continue_condition: true }, + }, + edges: [ + { + type: 'default', + source: { node_id: 'for', field: 'loop_linkage' }, + destination: { node_id: 'return', field: 'loop_linkage' }, + }, + ], + } satisfies NonNullableGraph, + false + ); + + expect(workflow.edges).toEqual([ + expect.objectContaining({ + type: 'loop_linkage', + source: 'for', + sourceHandle: 'loop_linkage', + target: 'return', + targetHandle: 'loop_linkage', + }), + ]); + }); }); diff --git a/invokeai/frontend/web/src/features/nodes/util/workflow/graphToWorkflow.ts b/invokeai/frontend/web/src/features/nodes/util/workflow/graphToWorkflow.ts index a55e13e0942..037f464631c 100644 --- a/invokeai/frontend/web/src/features/nodes/util/workflow/graphToWorkflow.ts +++ b/invokeai/frontend/web/src/features/nodes/util/workflow/graphToWorkflow.ts @@ -2,6 +2,7 @@ import * as dagre from '@dagrejs/dagre'; import { logger } from 'app/logging/logger'; import { forEach } from 'es-toolkit/compat'; import { $templates } from 'features/nodes/store/nodesSlice'; +import { getEdgeTypeFromHandles } from 'features/nodes/store/util/reactFlowUtil'; import { NODE_WIDTH } from 'features/nodes/types/constants'; import { nodeAcceptsExtraInputs } from 'features/nodes/types/extraInputs'; import type { FieldInputInstance, FieldInputTemplate } from 'features/nodes/types/field'; @@ -122,7 +123,10 @@ export const graphToWorkflow = (graph: NonNullableGraph, autoLayout = true): Wor forEach(graph.edges, (edge) => { workflow.edges.push({ id: uuidv4(), // we don't have edge IDs in the graph - type: 'default', + type: + edge.type === 'loop_linkage' + ? 'loop_linkage' + : getEdgeTypeFromHandles(edge.source.field, edge.destination.field), source: edge.source.node_id, sourceHandle: edge.source.field, target: edge.destination.node_id, @@ -167,9 +171,11 @@ export const graphToWorkflow = (graph: NonNullableGraph, autoLayout = true): Wor dagreGraph.setNode(node.id, { width, height }); }); - graph.edges.forEach((edge) => { - dagreGraph.setEdge(edge.source.node_id, edge.destination.node_id); - }); + graph.edges + .filter((edge) => edge.type !== 'loop_linkage') + .forEach((edge) => { + dagreGraph.setEdge(edge.source.node_id, edge.destination.node_id); + }); // This does the magic dagre.layout(dagreGraph); diff --git a/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.test.ts b/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.test.ts index 7d5abf34e5e..0316f9cd76a 100644 --- a/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.test.ts +++ b/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.test.ts @@ -4,6 +4,8 @@ import { CONNECTOR_INPUT_HANDLE, CONNECTOR_OUTPUT_HANDLE } from 'features/nodes/ import { add, call_saved_workflow, + for_loop, + for_return, img_resize, main_model_loader, workflow_return, @@ -699,4 +701,236 @@ describe('validateWorkflow', () => { expect(validationResult.warnings.length).toBe(1); }); + + it('should remove the internal For index from a loaded workflow', async () => { + const forNode = buildInvocationNode({ x: 0, y: 0 }, for_loop); + forNode.data.inputs.index = { + name: 'index', + label: '', + description: '', + value: -1, + }; + const workflow: WorkflowV3 = { + name: '', + author: '', + description: '', + version: '', + contact: '', + tags: '', + notes: '', + exposedFields: [], + form: getDefaultForm(), + meta: { version: '4.0.0', category: 'user' }, + nodes: [forNode], + edges: [], + }; + + const validationResult = await validateWorkflow({ + workflow, + templates: { for: for_loop }, + checkImageAccess: resolveTrue, + checkVideoAccess: resolveTrue, + checkBoardAccess: resolveTrue, + checkModelAccess: resolveTrue, + }); + + expect(validationResult.warnings).toEqual([]); + expect(validationResult.workflow.nodes[0]?.type).toBe('invocation'); + if (validationResult.workflow.nodes[0]?.type !== 'invocation') { + throw new Error('expected an invocation node'); + } + expect(validationResult.workflow.nodes[0].data.inputs.index).toBeUndefined(); + }); + + it('should remove malformed loop linkage edges instead of treating them as data edges', async () => { + const forNode = buildInvocationNode({ x: 0, y: 0 }, for_loop); + const returnNode = buildInvocationNode({ x: 0, y: 0 }, for_return); + const workflow: WorkflowV3 = { + name: '', + author: '', + description: '', + version: '', + contact: '', + tags: '', + notes: '', + exposedFields: [], + form: getDefaultForm(), + meta: { version: '4.0.0', category: 'user' }, + nodes: [forNode, returnNode], + edges: [ + { + id: 'malformed-loop-linkage', + type: 'loop_linkage', + source: forNode.id, + sourceHandle: 'item', + target: returnNode.id, + targetHandle: 'output', + }, + ], + }; + + const validationResult = await validateWorkflow({ + workflow, + templates: { for: for_loop, for_return }, + checkImageAccess: resolveTrue, + checkVideoAccess: resolveTrue, + checkBoardAccess: resolveTrue, + checkModelAccess: resolveTrue, + }); + + expect(validationResult.workflow.edges).toEqual([]); + expect(validationResult.warnings).toHaveLength(1); + }); + + it('should normalize a stale default edge between loop linkage handles', async () => { + const forNode = buildInvocationNode({ x: 0, y: 0 }, for_loop); + const returnNode = buildInvocationNode({ x: 0, y: 0 }, for_return); + const workflow: WorkflowV3 = { + name: '', + author: '', + description: '', + version: '', + contact: '', + tags: '', + notes: '', + exposedFields: [], + form: getDefaultForm(), + meta: { version: '4.0.0', category: 'user' }, + nodes: [forNode, returnNode], + edges: [ + { + id: 'stale-loop-linkage', + type: 'default', + source: forNode.id, + sourceHandle: 'loop_linkage', + target: returnNode.id, + targetHandle: 'loop_linkage', + }, + ], + }; + + const validationResult = await validateWorkflow({ + workflow, + templates: { for: for_loop, for_return }, + checkImageAccess: resolveTrue, + checkVideoAccess: resolveTrue, + checkBoardAccess: resolveTrue, + checkModelAccess: resolveTrue, + }); + + expect(validationResult.warnings).toEqual([]); + expect(validationResult.workflow.edges).toEqual([ + expect.objectContaining({ + type: 'loop_linkage', + sourceHandle: 'loop_linkage', + targetHandle: 'loop_linkage', + }), + ]); + }); + + it('should preserve a connector loop linkage alias regardless of edge order', async () => { + const forNode = buildInvocationNode({ x: 0, y: 0 }, for_loop); + const connectorNode = buildConnectorNode('connector-1'); + const returnNode = buildInvocationNode({ x: 0, y: 0 }, for_return); + const workflow: WorkflowV3 = { + name: '', + author: '', + description: '', + version: '', + contact: '', + tags: '', + notes: '', + exposedFields: [], + form: getDefaultForm(), + meta: { version: '4.0.0', category: 'user' }, + nodes: [forNode, connectorNode, returnNode], + edges: [ + { + id: 'linkage-output', + type: 'default', + source: connectorNode.id, + sourceHandle: CONNECTOR_OUTPUT_HANDLE, + target: returnNode.id, + targetHandle: 'loop_linkage', + }, + { + id: 'linkage-input', + type: 'default', + source: forNode.id, + sourceHandle: 'loop_linkage', + target: connectorNode.id, + targetHandle: CONNECTOR_INPUT_HANDLE, + }, + ], + }; + + const validationResult = await validateWorkflow({ + workflow, + templates: { for: for_loop, for_return }, + checkImageAccess: resolveTrue, + checkVideoAccess: resolveTrue, + checkBoardAccess: resolveTrue, + checkModelAccess: resolveTrue, + }); + + expect(validationResult.warnings).toEqual([]); + expect(validationResult.workflow.edges).toEqual(workflow.edges); + }); + + it('should remove a connector alias that duplicates a direct linkage regardless of edge order', async () => { + const forNode = buildInvocationNode({ x: 0, y: 0 }, for_loop); + const connectorNode = buildConnectorNode('connector-1'); + const returnNode = buildInvocationNode({ x: 0, y: 0 }, for_return); + const directLinkage = { + id: 'direct-linkage', + type: 'loop_linkage' as const, + source: forNode.id, + sourceHandle: 'loop_linkage', + target: returnNode.id, + targetHandle: 'loop_linkage', + }; + const workflow: WorkflowV3 = { + name: '', + author: '', + description: '', + version: '', + contact: '', + tags: '', + notes: '', + exposedFields: [], + form: getDefaultForm(), + meta: { version: '4.0.0', category: 'user' }, + nodes: [forNode, connectorNode, returnNode], + edges: [ + { + id: 'linkage-output', + type: 'default', + source: connectorNode.id, + sourceHandle: CONNECTOR_OUTPUT_HANDLE, + target: returnNode.id, + targetHandle: 'loop_linkage', + }, + directLinkage, + { + id: 'linkage-input', + type: 'default', + source: forNode.id, + sourceHandle: 'loop_linkage', + target: connectorNode.id, + targetHandle: CONNECTOR_INPUT_HANDLE, + }, + ], + }; + + const validationResult = await validateWorkflow({ + workflow, + templates: { for: for_loop, for_return }, + checkImageAccess: resolveTrue, + checkVideoAccess: resolveTrue, + checkBoardAccess: resolveTrue, + checkModelAccess: resolveTrue, + }); + + expect(validationResult.workflow.edges).toEqual([directLinkage]); + }); }); diff --git a/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.ts b/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.ts index 4775b2cfc4d..53fc025d43b 100644 --- a/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.ts +++ b/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.ts @@ -3,7 +3,14 @@ import { getSavedWorkflowDynamicFields } from 'features/nodes/components/flow/no import { addElement, getIsFormEmpty } from 'features/nodes/components/sidePanel/builder/form-manipulation'; import { CALL_SAVED_WORKFLOW_DYNAMIC_FIELD_PREFIX } from 'features/nodes/store/nodesSlice'; import type { Templates } from 'features/nodes/store/types'; +import { + CONNECTOR_OUTPUT_HANDLE, + resolveConnectorSource, + resolveLoopLinkagePath, +} from 'features/nodes/store/util/connectorTopology'; +import { getEdgeTypeFromHandles, isLoopLinkageEdge } from 'features/nodes/store/util/reactFlowUtil'; import { validateConnection } from 'features/nodes/store/util/validateConnection'; +import { LOOP_LINKAGE_FIELD } from 'features/nodes/types/constants'; import { nodeAcceptsExtraInputs } from 'features/nodes/types/extraInputs'; import { isBoardFieldInputInstance, @@ -13,7 +20,7 @@ import { isModelIdentifierFieldInputInstance, isVideoFieldInputInstance, } from 'features/nodes/types/field'; -import { getInvocationNodeInputTemplate } from 'features/nodes/types/invocation'; +import { getInvocationNodeInputTemplate, isConnectorNode } from 'features/nodes/types/invocation'; import type { WorkflowV3 } from 'features/nodes/types/workflow'; import { buildNodeFieldElement, @@ -150,7 +157,12 @@ export const validateWorkflow = async (args: ValidateWorkflowArgs): Promise id === edge.source); const targetNode = nodes.find(({ id }) => id === edge.target); @@ -206,6 +218,19 @@ export const validateWorkflow = async (args: ValidateWorkflowArgs): Promise(); + const linkedForIds = new Set(validEdges.filter(isLoopLinkageEdge).map((edge) => edge.source)); + const linkedReturnIds = new Set(validEdges.filter(isLoopLinkageEdge).map((edge) => edge.target)); + for (const edge of edges) { + if ( + edge.type !== 'default' || + edge.sourceHandle !== CONNECTOR_OUTPUT_HANDLE || + edge.targetHandle !== LOOP_LINKAGE_FIELD || + !isConnectorNode(nodes.find((node) => node.id === edge.source)) + ) { + continue; + } + + const resolvedSource = resolveConnectorSource(edge.source, nodes, edges); + if (!resolvedSource) { + continue; + } + + const path = resolveLoopLinkagePath(edge, nodes, edges); + if (!path) { + invalidConnectorLinkageEdgeIds.add(edge.id); + warnings.push({ + message: t('nodes.deletedInvalidEdge', { + source: `${edge.source}.${edge.sourceHandle}`, + target: `${edge.target}.${edge.targetHandle}`, + }), + issues: [t('nodes.forLoopLinkageInvalid')], + data: edge, + }); + continue; + } + if (linkedForIds.has(path.forNodeId) || linkedReturnIds.has(path.returnNodeId)) { + path.edgeIds.forEach((edgeId) => invalidConnectorLinkageEdgeIds.add(edgeId)); + warnings.push({ + message: t('nodes.deletedInvalidEdge', { + source: `${edge.source}.${edge.sourceHandle}`, + target: `${edge.target}.${edge.targetHandle}`, + }), + issues: [t('nodes.forLoopLinkageDuplicate')], + data: edge, + }); + continue; + } + linkedForIds.add(path.forNodeId); + linkedReturnIds.add(path.returnNodeId); + } + + _workflow.edges = validEdges.filter((edge) => !invalidConnectorLinkageEdgeIds.has(edge.id)); for (const node of nodes) { if (!isWorkflowInvocationNode(node)) { @@ -283,6 +355,12 @@ export const validateWorkflow = async (args: ValidateWorkflowArgs): Promise ({ default: { t: (key: string) => key, }, + t: (key: string) => key, })); +import type { AppStore } from 'app/store/store'; import type { ParamsState, RefImagesState } from 'features/controlLayers/store/types'; import type { DynamicPromptsState } from 'features/dynamicPrompts/store/dynamicPromptsSlice'; +import type { NodesState } from 'features/nodes/store/types'; +import { add, buildEdge, buildNode, for_loop, templates } from 'features/nodes/store/util/testUtils'; +import type { WorkflowSettingsState } from 'features/nodes/store/workflowSettingsSlice'; import type { AnyModelConfig, MainModelConfig } from 'services/api/types'; -import { getReasonsWhyCannotEnqueueCanvasTab, getReasonsWhyCannotEnqueueGenerateTab } from './readiness'; +import { + getReasonsWhyCannotEnqueueCanvasTab, + getReasonsWhyCannotEnqueueGenerateTab, + getReasonsWhyCannotEnqueueWorkflowsTab, +} from './readiness'; // --- Fixtures --- @@ -244,6 +253,58 @@ describe('FLUX.2 Klein readiness checks – generate tab', () => { }); }); +describe('workflow readiness checks', () => { + it('blocks Invoke when the workflow graph has an invalid For topology', async () => { + const forNode = buildNode(for_loop); + const bodyNode = buildNode(add); + const nodesState = { + _version: 1, + nodes: [forNode, bodyNode], + edges: [buildEdge(forNode.id, 'item', bodyNode.id, 'a')], + formFieldInitialValues: {}, + id: undefined, + name: '', + author: '', + description: '', + version: '', + contact: '', + tags: '', + notes: '', + exposedFields: [], + meta: { version: '4.0.0', category: 'user' }, + form: { + rootElementId: 'root', + elements: { + root: { + id: 'root', + type: 'container', + data: { layout: 'column', children: [] }, + }, + }, + }, + } as unknown as NodesState; + const rootState = { + nodes: { present: nodesState }, + gallery: { autoAddBoardId: 'none', selection: [] }, + }; + const store = { + dispatch: vi.fn(), + getState: () => rootState, + } as unknown as AppStore; + + const reasons = await getReasonsWhyCannotEnqueueWorkflowsTab({ + dispatch: store.dispatch, + nodesState, + workflowSettingsState: { shouldValidateGraph: true } as WorkflowSettingsState, + isConnected: true, + templates: { ...templates, for: for_loop }, + store, + }); + + expect(reasons).toContainEqual({ content: 'nodes.forLoopLinkageMissing' }); + }); +}); + describe('FLUX.2 Klein SDNQ pipeline readiness checks', () => { it('generate: no errors for a full SDNQ pipeline (self-contained) with no component sources', () => { const reasons = getReasonsWhyCannotEnqueueGenerateTab(buildGenerateTabArg({ model: flux2SdnqPipelineModel })); diff --git a/invokeai/frontend/web/src/features/queue/store/readiness.ts b/invokeai/frontend/web/src/features/queue/store/readiness.ts index 6de630b7471..d8a9226baf5 100644 --- a/invokeai/frontend/web/src/features/queue/store/readiness.ts +++ b/invokeai/frontend/web/src/features/queue/store/readiness.ts @@ -40,6 +40,7 @@ import { getInvocationNodeErrors } from 'features/nodes/store/util/fieldValidato import type { WorkflowSettingsState } from 'features/nodes/store/workflowSettingsSlice'; import { selectWorkflowSettingsSlice } from 'features/nodes/store/workflowSettingsSlice'; import { isBatchNode, isExecutableNode, isInvocationNode } from 'features/nodes/types/invocation'; +import { buildNodesGraph } from 'features/nodes/util/graph/buildNodesGraph'; import { resolveBatchValue } from 'features/nodes/util/node/resolveBatchValue'; import type { UpscaleState } from 'features/parameters/store/upscaleSlice'; import { selectUpscaleSlice } from 'features/parameters/store/upscaleSlice'; @@ -197,6 +198,7 @@ const debouncedUpdateReasons = debounce(async (arg: UpdateReasonsArg) => { workflowSettingsState: workflowSettings, isConnected, templates, + store, }); $reasonsWhyCannotEnqueue.set(reasons); } else if (tab === 'upscaling') { @@ -614,20 +616,30 @@ export const getReasonsWhyCannotEnqueueGenerateTab = (arg: { return reasons; }; -const getReasonsWhyCannotEnqueueWorkflowsTab = async (arg: { +export const getReasonsWhyCannotEnqueueWorkflowsTab = async (arg: { dispatch: AppDispatch; nodesState: NodesState; workflowSettingsState: WorkflowSettingsState; isConnected: boolean; templates: Templates; + store: AppStore; }): Promise => { - const { dispatch, nodesState, workflowSettingsState, isConnected, templates } = arg; + const { dispatch, nodesState, workflowSettingsState, isConnected, templates, store } = arg; const reasons: Reason[] = []; if (!isConnected) { reasons.push(disconnectedReason(i18n.t)); } + // Queue submission always builds and validates this graph, regardless of the optional field + // validation setting. Run the same validation here so an invalid loop cannot leave Invoke + // enabled only to fail inside the click handler. + try { + buildNodesGraph(store.getState(), templates); + } catch (error) { + reasons.push({ content: error instanceof Error ? error.message : String(error) }); + } + if (workflowSettingsState.shouldValidateGraph) { const { nodes, edges } = nodesState; const invocationNodes = nodes.filter(isInvocationNode); diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 12d3d46e6f1..2b0bff58886 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -7425,6 +7425,171 @@ export type components = { */ type: "collect_output"; }; + /** + * Cartesian Product of Collections + * @description Emits every pair formed by one item from each collection, up to 100,000 pairs. + */ + CollectionCartesianInvocation: { + /** + * Id + * @description The id of this instance of an invocation. Must be unique among all instances of invocations. + */ + id: string; + /** + * Is Intermediate + * @description Whether or not this is an intermediate invocation. + * @default false + */ + is_intermediate?: boolean; + /** + * Use Cache + * @description Whether or not to use the cache + * @default true + */ + use_cache?: boolean; + /** + * First + * @description The first collection + * @default [] + */ + first?: unknown[]; + /** + * Second + * @description The second collection + * @default [] + */ + second?: unknown[]; + /** + * type + * @default collection_cartesian + * @constant + */ + type: "collection_cartesian"; + }; + /** CollectionCartesianInvocationOutput */ + CollectionCartesianInvocationOutput: { + /** + * Collection + * @description The Cartesian product pairs + */ + collection: unknown[]; + /** + * type + * @default collection_cartesian_output + * @constant + */ + type: "collection_cartesian_output"; + }; + /** + * Concatenate Collections + * @description Concatenates two collections in left-to-right order. + */ + CollectionConcatInvocation: { + /** + * Id + * @description The id of this instance of an invocation. Must be unique among all instances of invocations. + */ + id: string; + /** + * Is Intermediate + * @description Whether or not this is an intermediate invocation. + * @default false + */ + is_intermediate?: boolean; + /** + * Use Cache + * @description Whether or not to use the cache + * @default true + */ + use_cache?: boolean; + /** + * First + * @description The first collection + * @default [] + */ + first?: unknown[]; + /** + * Second + * @description The second collection + * @default [] + */ + second?: unknown[]; + /** + * type + * @default collection_concat + * @constant + */ + type: "collection_concat"; + }; + /** CollectionConcatInvocationOutput */ + CollectionConcatInvocationOutput: { + /** + * Collection + * @description The concatenated collection + */ + collection: unknown[]; + /** + * type + * @default collection_concat_output + * @constant + */ + type: "collection_concat_output"; + }; + /** + * Zip Collections + * @description Pairs items at matching positions from two equally sized collections. + */ + CollectionZipInvocation: { + /** + * Id + * @description The id of this instance of an invocation. Must be unique among all instances of invocations. + */ + id: string; + /** + * Is Intermediate + * @description Whether or not this is an intermediate invocation. + * @default false + */ + is_intermediate?: boolean; + /** + * Use Cache + * @description Whether or not to use the cache + * @default true + */ + use_cache?: boolean; + /** + * First + * @description The first collection + * @default [] + */ + first?: unknown[]; + /** + * Second + * @description The second collection + * @default [] + */ + second?: unknown[]; + /** + * type + * @default collection_zip + * @constant + */ + type: "collection_zip"; + }; + /** CollectionZipInvocationOutput */ + CollectionZipInvocationOutput: { + /** + * Collection + * @description The positional pairs + */ + collection: unknown[]; + /** + * type + * @default collection_zip_output + * @constant + */ + type: "collection_zip_output"; + }; /** * ColorCollectionOutput * @description Base class for nodes that output a collection of colors @@ -10603,6 +10768,13 @@ export type components = { }; /** Edge */ Edge: { + /** + * Type + * @description The kind of relationship represented by this edge + * @default default + * @enum {string} + */ + type?: "default" | "loop_linkage"; /** @description The connection for the edge's from node and field */ source: components["schemas"]["EdgeConnection"]; /** @description The connection for the edge's to node and field */ @@ -14546,6 +14718,164 @@ export type components = { * @enum {string} */ FluxVariantType: "schnell" | "dev" | "dev_fill"; + /** ForInvocation */ + ForInvocation: { + /** + * Id + * @description The id of this instance of an invocation. Must be unique among all instances of invocations. + */ + id: string; + /** + * Is Intermediate + * @description Whether or not this is an intermediate invocation. + * @default false + */ + is_intermediate?: boolean; + /** + * Use Cache + * @description Whether or not to use the cache + * @default true + */ + use_cache?: boolean; + /** + * Collection + * @description The list of items to iterate over + * @default [] + */ + collection?: unknown[]; + /** + * @description Optional initial loop state + * @default null + */ + state?: components["schemas"]["LoopState"] | null; + /** + * Index + * @description The internal iteration index for a prepared For execution node + * @default -1 + */ + index?: number; + /** + * type + * @default for + * @constant + */ + type: "for"; + }; + /** ForInvocationOutput */ + ForInvocationOutput: { + /** + * Loop Linkage + * @description The loop linkage to the matching ForReturn + */ + loop_linkage: unknown; + /** + * Collection Item + * @description The item for the current loop iteration, or None when the collection is empty + * @default null + */ + item: unknown | null; + /** + * Index + * @description The index for the current loop iteration + */ + index: number; + /** + * Total + * @description The total number of items in the loop collection + */ + total: number; + /** + * State + * @description The state for the current loop iteration + */ + state: components["schemas"]["LoopState"]; + /** + * Output Collection + * @description The collected loop body outputs + */ + output_collection: unknown[]; + /** + * Final State + * @description The final loop state + */ + final_state: components["schemas"]["LoopState"]; + /** + * type + * @default for_output + * @constant + */ + type: "for_output"; + }; + /** ForReturnInvocation */ + ForReturnInvocation: { + /** + * Id + * @description The id of this instance of an invocation. Must be unique among all instances of invocations. + */ + id: string; + /** + * Is Intermediate + * @description Whether or not this is an intermediate invocation. + * @default false + */ + is_intermediate?: boolean; + /** + * Use Cache + * @description Whether or not to use the cache + * @default true + */ + use_cache?: boolean; + /** + * Loop Linkage + * @description The loop linkage from the matching For + * @default null + */ + loop_linkage?: unknown | null; + /** + * Output + * @description The output item to append to the loop output collection + * @default null + */ + output?: unknown | null; + /** + * @description The state to pass to the next loop iteration + * @default null + */ + state?: components["schemas"]["LoopState"] | null; + /** + * Continue Condition + * @description Whether to schedule the next loop iteration; false finalizes the loop + * @default true + */ + continue_condition?: boolean | null; + /** + * type + * @default for_return + * @constant + */ + type: "for_return"; + }; + /** ForReturnInvocationOutput */ + ForReturnInvocationOutput: { + /** + * Output + * @description The output item to append to the loop output collection + * @default null + */ + output: unknown | null; + /** + * State + * @description The state to pass to the next loop iteration + * @default null + */ + state: components["schemas"]["LoopState"] | null; + /** + * type + * @default for_return_output + * @constant + */ + type: "for_return_output"; + }; /** FoundModel */ FoundModel: { /** @@ -15253,7 +15583,7 @@ export type components = { * @description The nodes in this graph */ nodes?: { - [key: string]: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; + [key: string]: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CollectionCartesianInvocation"] | components["schemas"]["CollectionConcatInvocation"] | components["schemas"]["CollectionZipInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["ForInvocation"] | components["schemas"]["ForReturnInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StateEmptyInvocation"] | components["schemas"]["StateGetInvocation"] | components["schemas"]["StateMergeInvocation"] | components["schemas"]["StateSetInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; }; /** * Edges @@ -15290,7 +15620,7 @@ export type components = { * @description The results of node executions */ results: { - [key: string]: components["schemas"]["AnimaConditioningOutput"] | components["schemas"]["AnimaLLLiteOutput"] | components["schemas"]["AnimaLoRALoaderOutput"] | components["schemas"]["AnimaModelLoaderOutput"] | components["schemas"]["BooleanCollectionOutput"] | components["schemas"]["BooleanOutput"] | components["schemas"]["BoundingBoxCollectionOutput"] | components["schemas"]["BoundingBoxOutput"] | components["schemas"]["CLIPOutput"] | components["schemas"]["CLIPSkipInvocationOutput"] | components["schemas"]["CalculateImageTilesOutput"] | components["schemas"]["CogView4ConditioningOutput"] | components["schemas"]["CogView4ModelLoaderOutput"] | components["schemas"]["CollectInvocationOutput"] | components["schemas"]["ColorCollectionOutput"] | components["schemas"]["ColorOutput"] | components["schemas"]["ConditioningCollectionOutput"] | components["schemas"]["ConditioningOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["DenoiseMaskOutput"] | components["schemas"]["ErnieImageConditioningOutput"] | components["schemas"]["ErnieImageModelLoaderOutput"] | components["schemas"]["ExtractVideoRangeOutput"] | components["schemas"]["FaceMaskOutput"] | components["schemas"]["FaceOffOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["FloatGeneratorOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["Flux2DevLoRALoaderOutput"] | components["schemas"]["Flux2DevModelLoaderOutput"] | components["schemas"]["Flux2KleinLoRALoaderOutput"] | components["schemas"]["Flux2KleinModelLoaderOutput"] | components["schemas"]["FluxConditioningCollectionOutput"] | components["schemas"]["FluxConditioningOutput"] | components["schemas"]["FluxControlLoRALoaderOutput"] | components["schemas"]["FluxControlNetOutput"] | components["schemas"]["FluxFillOutput"] | components["schemas"]["FluxKontextOutput"] | components["schemas"]["FluxLoRALoaderOutput"] | components["schemas"]["FluxModelLoaderOutput"] | components["schemas"]["FluxReduxOutput"] | components["schemas"]["Gemma2EncoderOutput"] | components["schemas"]["GradientMaskOutput"] | components["schemas"]["IPAdapterOutput"] | components["schemas"]["IdealSizeOutput"] | components["schemas"]["Ideogram4ConditioningOutput"] | components["schemas"]["Ideogram4ModelLoaderOutput"] | components["schemas"]["IfInvocationOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ImageGeneratorOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["ImagePanelCoordinateOutput"] | components["schemas"]["IntegerCollectionOutput"] | components["schemas"]["IntegerGeneratorOutput"] | components["schemas"]["IntegerOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["Krea2ConditioningOutput"] | components["schemas"]["Krea2LoRALoaderOutput"] | components["schemas"]["Krea2ModelLoaderOutput"] | components["schemas"]["LatentsCollectionOutput"] | components["schemas"]["LatentsMetaOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["LoRALoaderOutput"] | components["schemas"]["LoRASelectorOutput"] | components["schemas"]["MDControlListOutput"] | components["schemas"]["MDIPAdapterListOutput"] | components["schemas"]["MDT2IAdapterListOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["MetadataItemOutput"] | components["schemas"]["MetadataOutput"] | components["schemas"]["MetadataToLorasCollectionOutput"] | components["schemas"]["MetadataToModelOutput"] | components["schemas"]["MetadataToSDXLModelOutput"] | components["schemas"]["MiniMaxH3ConditioningOutput"] | components["schemas"]["MiniMaxH3DenoiseOutput"] | components["schemas"]["MiniMaxH3FrameConditioningOutput"] | components["schemas"]["MiniMaxH3LoRACollectionLoaderOutput"] | components["schemas"]["MiniMaxH3LoRALoaderOutput"] | components["schemas"]["MiniMaxH3ModelLoaderOutput"] | components["schemas"]["ModelIdentifierOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PBRMapsOutput"] | components["schemas"]["PairTileImageOutput"] | components["schemas"]["PiDDecoderOutput"] | components["schemas"]["PromptTemplateOutput"] | components["schemas"]["QwenImageConditioningOutput"] | components["schemas"]["QwenImageLoRALoaderOutput"] | components["schemas"]["QwenImageModelLoaderOutput"] | components["schemas"]["SD3ConditioningOutput"] | components["schemas"]["SDXLLoRALoaderOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["SchedulerOutput"] | components["schemas"]["Sd3ModelLoaderOutput"] | components["schemas"]["SeamlessModeOutput"] | components["schemas"]["String2Output"] | components["schemas"]["StringCollectionOutput"] | components["schemas"]["StringGeneratorOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["StringPosNegOutput"] | components["schemas"]["T2IAdapterOutput"] | components["schemas"]["TileToPropertiesOutput"] | components["schemas"]["UNetOutput"] | components["schemas"]["VAEOutput"] | components["schemas"]["VideoOutput"] | components["schemas"]["WanConditioningOutput"] | components["schemas"]["WanLoRALoaderOutput"] | components["schemas"]["WanModelLoaderOutput"] | components["schemas"]["WanRefImageOutput"] | components["schemas"]["WorkflowReturnGetOutput"] | components["schemas"]["WorkflowReturnOutput"] | components["schemas"]["WorkflowReturnValueOutput"] | components["schemas"]["ZImageConditioningOutput"] | components["schemas"]["ZImageControlOutput"] | components["schemas"]["ZImageLoRALoaderOutput"] | components["schemas"]["ZImageModelLoaderOutput"]; + [key: string]: components["schemas"]["AnimaConditioningOutput"] | components["schemas"]["AnimaLLLiteOutput"] | components["schemas"]["AnimaLoRALoaderOutput"] | components["schemas"]["AnimaModelLoaderOutput"] | components["schemas"]["BooleanCollectionOutput"] | components["schemas"]["BooleanOutput"] | components["schemas"]["BoundingBoxCollectionOutput"] | components["schemas"]["BoundingBoxOutput"] | components["schemas"]["CLIPOutput"] | components["schemas"]["CLIPSkipInvocationOutput"] | components["schemas"]["CalculateImageTilesOutput"] | components["schemas"]["CogView4ConditioningOutput"] | components["schemas"]["CogView4ModelLoaderOutput"] | components["schemas"]["CollectInvocationOutput"] | components["schemas"]["CollectionCartesianInvocationOutput"] | components["schemas"]["CollectionConcatInvocationOutput"] | components["schemas"]["CollectionZipInvocationOutput"] | components["schemas"]["ColorCollectionOutput"] | components["schemas"]["ColorOutput"] | components["schemas"]["ConditioningCollectionOutput"] | components["schemas"]["ConditioningOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["DenoiseMaskOutput"] | components["schemas"]["ErnieImageConditioningOutput"] | components["schemas"]["ErnieImageModelLoaderOutput"] | components["schemas"]["ExtractVideoRangeOutput"] | components["schemas"]["FaceMaskOutput"] | components["schemas"]["FaceOffOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["FloatGeneratorOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["Flux2DevLoRALoaderOutput"] | components["schemas"]["Flux2DevModelLoaderOutput"] | components["schemas"]["Flux2KleinLoRALoaderOutput"] | components["schemas"]["Flux2KleinModelLoaderOutput"] | components["schemas"]["FluxConditioningCollectionOutput"] | components["schemas"]["FluxConditioningOutput"] | components["schemas"]["FluxControlLoRALoaderOutput"] | components["schemas"]["FluxControlNetOutput"] | components["schemas"]["FluxFillOutput"] | components["schemas"]["FluxKontextOutput"] | components["schemas"]["FluxLoRALoaderOutput"] | components["schemas"]["FluxModelLoaderOutput"] | components["schemas"]["FluxReduxOutput"] | components["schemas"]["ForInvocationOutput"] | components["schemas"]["ForReturnInvocationOutput"] | components["schemas"]["Gemma2EncoderOutput"] | components["schemas"]["GradientMaskOutput"] | components["schemas"]["IPAdapterOutput"] | components["schemas"]["IdealSizeOutput"] | components["schemas"]["Ideogram4ConditioningOutput"] | components["schemas"]["Ideogram4ModelLoaderOutput"] | components["schemas"]["IfInvocationOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ImageGeneratorOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["ImagePanelCoordinateOutput"] | components["schemas"]["IntegerCollectionOutput"] | components["schemas"]["IntegerGeneratorOutput"] | components["schemas"]["IntegerOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["Krea2ConditioningOutput"] | components["schemas"]["Krea2LoRALoaderOutput"] | components["schemas"]["Krea2ModelLoaderOutput"] | components["schemas"]["LatentsCollectionOutput"] | components["schemas"]["LatentsMetaOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["LoRALoaderOutput"] | components["schemas"]["LoRASelectorOutput"] | components["schemas"]["LoopStateOutput"] | components["schemas"]["LoopStateValueOutput"] | components["schemas"]["MDControlListOutput"] | components["schemas"]["MDIPAdapterListOutput"] | components["schemas"]["MDT2IAdapterListOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["MetadataItemOutput"] | components["schemas"]["MetadataOutput"] | components["schemas"]["MetadataToLorasCollectionOutput"] | components["schemas"]["MetadataToModelOutput"] | components["schemas"]["MetadataToSDXLModelOutput"] | components["schemas"]["MiniMaxH3ConditioningOutput"] | components["schemas"]["MiniMaxH3DenoiseOutput"] | components["schemas"]["MiniMaxH3FrameConditioningOutput"] | components["schemas"]["MiniMaxH3LoRACollectionLoaderOutput"] | components["schemas"]["MiniMaxH3LoRALoaderOutput"] | components["schemas"]["MiniMaxH3ModelLoaderOutput"] | components["schemas"]["ModelIdentifierOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PBRMapsOutput"] | components["schemas"]["PairTileImageOutput"] | components["schemas"]["PiDDecoderOutput"] | components["schemas"]["PromptTemplateOutput"] | components["schemas"]["QwenImageConditioningOutput"] | components["schemas"]["QwenImageLoRALoaderOutput"] | components["schemas"]["QwenImageModelLoaderOutput"] | components["schemas"]["SD3ConditioningOutput"] | components["schemas"]["SDXLLoRALoaderOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["SchedulerOutput"] | components["schemas"]["Sd3ModelLoaderOutput"] | components["schemas"]["SeamlessModeOutput"] | components["schemas"]["String2Output"] | components["schemas"]["StringCollectionOutput"] | components["schemas"]["StringGeneratorOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["StringPosNegOutput"] | components["schemas"]["T2IAdapterOutput"] | components["schemas"]["TileToPropertiesOutput"] | components["schemas"]["UNetOutput"] | components["schemas"]["VAEOutput"] | components["schemas"]["VideoOutput"] | components["schemas"]["WanConditioningOutput"] | components["schemas"]["WanLoRALoaderOutput"] | components["schemas"]["WanModelLoaderOutput"] | components["schemas"]["WanRefImageOutput"] | components["schemas"]["WorkflowReturnGetOutput"] | components["schemas"]["WorkflowReturnOutput"] | components["schemas"]["WorkflowReturnValueOutput"] | components["schemas"]["ZImageConditioningOutput"] | components["schemas"]["ZImageControlOutput"] | components["schemas"]["ZImageLoRALoaderOutput"] | components["schemas"]["ZImageModelLoaderOutput"]; }; /** * Errors @@ -15337,6 +15667,19 @@ export type components = { source_prepared_mapping: { [key: string]: string[]; }; + /** + * Finalized Loop Nodes + * @description Legacy set of top-level loop source nodes whose final outputs have been materialized + */ + finalized_loop_nodes: string[]; + /** + * Finalized Loop Contexts + * @description The finalized loop source and parent iteration contexts + */ + finalized_loop_contexts?: [ + string, + number[] + ][]; /** * Prepared Iteration Paths * @description The iteration coordinates of each prepared execution node @@ -19441,7 +19784,7 @@ export type components = { * Invocation * @description The ID of the invocation */ - invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; + invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CollectionCartesianInvocation"] | components["schemas"]["CollectionConcatInvocation"] | components["schemas"]["CollectionZipInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["ForInvocation"] | components["schemas"]["ForReturnInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StateEmptyInvocation"] | components["schemas"]["StateGetInvocation"] | components["schemas"]["StateMergeInvocation"] | components["schemas"]["StateSetInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; /** * Invocation Source Id * @description The ID of the prepared invocation's source node @@ -19451,7 +19794,7 @@ export type components = { * Result * @description The result of the invocation */ - result: components["schemas"]["AnimaConditioningOutput"] | components["schemas"]["AnimaLLLiteOutput"] | components["schemas"]["AnimaLoRALoaderOutput"] | components["schemas"]["AnimaModelLoaderOutput"] | components["schemas"]["BooleanCollectionOutput"] | components["schemas"]["BooleanOutput"] | components["schemas"]["BoundingBoxCollectionOutput"] | components["schemas"]["BoundingBoxOutput"] | components["schemas"]["CLIPOutput"] | components["schemas"]["CLIPSkipInvocationOutput"] | components["schemas"]["CalculateImageTilesOutput"] | components["schemas"]["CogView4ConditioningOutput"] | components["schemas"]["CogView4ModelLoaderOutput"] | components["schemas"]["CollectInvocationOutput"] | components["schemas"]["ColorCollectionOutput"] | components["schemas"]["ColorOutput"] | components["schemas"]["ConditioningCollectionOutput"] | components["schemas"]["ConditioningOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["DenoiseMaskOutput"] | components["schemas"]["ErnieImageConditioningOutput"] | components["schemas"]["ErnieImageModelLoaderOutput"] | components["schemas"]["ExtractVideoRangeOutput"] | components["schemas"]["FaceMaskOutput"] | components["schemas"]["FaceOffOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["FloatGeneratorOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["Flux2DevLoRALoaderOutput"] | components["schemas"]["Flux2DevModelLoaderOutput"] | components["schemas"]["Flux2KleinLoRALoaderOutput"] | components["schemas"]["Flux2KleinModelLoaderOutput"] | components["schemas"]["FluxConditioningCollectionOutput"] | components["schemas"]["FluxConditioningOutput"] | components["schemas"]["FluxControlLoRALoaderOutput"] | components["schemas"]["FluxControlNetOutput"] | components["schemas"]["FluxFillOutput"] | components["schemas"]["FluxKontextOutput"] | components["schemas"]["FluxLoRALoaderOutput"] | components["schemas"]["FluxModelLoaderOutput"] | components["schemas"]["FluxReduxOutput"] | components["schemas"]["Gemma2EncoderOutput"] | components["schemas"]["GradientMaskOutput"] | components["schemas"]["IPAdapterOutput"] | components["schemas"]["IdealSizeOutput"] | components["schemas"]["Ideogram4ConditioningOutput"] | components["schemas"]["Ideogram4ModelLoaderOutput"] | components["schemas"]["IfInvocationOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ImageGeneratorOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["ImagePanelCoordinateOutput"] | components["schemas"]["IntegerCollectionOutput"] | components["schemas"]["IntegerGeneratorOutput"] | components["schemas"]["IntegerOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["Krea2ConditioningOutput"] | components["schemas"]["Krea2LoRALoaderOutput"] | components["schemas"]["Krea2ModelLoaderOutput"] | components["schemas"]["LatentsCollectionOutput"] | components["schemas"]["LatentsMetaOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["LoRALoaderOutput"] | components["schemas"]["LoRASelectorOutput"] | components["schemas"]["MDControlListOutput"] | components["schemas"]["MDIPAdapterListOutput"] | components["schemas"]["MDT2IAdapterListOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["MetadataItemOutput"] | components["schemas"]["MetadataOutput"] | components["schemas"]["MetadataToLorasCollectionOutput"] | components["schemas"]["MetadataToModelOutput"] | components["schemas"]["MetadataToSDXLModelOutput"] | components["schemas"]["MiniMaxH3ConditioningOutput"] | components["schemas"]["MiniMaxH3DenoiseOutput"] | components["schemas"]["MiniMaxH3FrameConditioningOutput"] | components["schemas"]["MiniMaxH3LoRACollectionLoaderOutput"] | components["schemas"]["MiniMaxH3LoRALoaderOutput"] | components["schemas"]["MiniMaxH3ModelLoaderOutput"] | components["schemas"]["ModelIdentifierOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PBRMapsOutput"] | components["schemas"]["PairTileImageOutput"] | components["schemas"]["PiDDecoderOutput"] | components["schemas"]["PromptTemplateOutput"] | components["schemas"]["QwenImageConditioningOutput"] | components["schemas"]["QwenImageLoRALoaderOutput"] | components["schemas"]["QwenImageModelLoaderOutput"] | components["schemas"]["SD3ConditioningOutput"] | components["schemas"]["SDXLLoRALoaderOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["SchedulerOutput"] | components["schemas"]["Sd3ModelLoaderOutput"] | components["schemas"]["SeamlessModeOutput"] | components["schemas"]["String2Output"] | components["schemas"]["StringCollectionOutput"] | components["schemas"]["StringGeneratorOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["StringPosNegOutput"] | components["schemas"]["T2IAdapterOutput"] | components["schemas"]["TileToPropertiesOutput"] | components["schemas"]["UNetOutput"] | components["schemas"]["VAEOutput"] | components["schemas"]["VideoOutput"] | components["schemas"]["WanConditioningOutput"] | components["schemas"]["WanLoRALoaderOutput"] | components["schemas"]["WanModelLoaderOutput"] | components["schemas"]["WanRefImageOutput"] | components["schemas"]["WorkflowReturnGetOutput"] | components["schemas"]["WorkflowReturnOutput"] | components["schemas"]["WorkflowReturnValueOutput"] | components["schemas"]["ZImageConditioningOutput"] | components["schemas"]["ZImageControlOutput"] | components["schemas"]["ZImageLoRALoaderOutput"] | components["schemas"]["ZImageModelLoaderOutput"]; + result: components["schemas"]["AnimaConditioningOutput"] | components["schemas"]["AnimaLLLiteOutput"] | components["schemas"]["AnimaLoRALoaderOutput"] | components["schemas"]["AnimaModelLoaderOutput"] | components["schemas"]["BooleanCollectionOutput"] | components["schemas"]["BooleanOutput"] | components["schemas"]["BoundingBoxCollectionOutput"] | components["schemas"]["BoundingBoxOutput"] | components["schemas"]["CLIPOutput"] | components["schemas"]["CLIPSkipInvocationOutput"] | components["schemas"]["CalculateImageTilesOutput"] | components["schemas"]["CogView4ConditioningOutput"] | components["schemas"]["CogView4ModelLoaderOutput"] | components["schemas"]["CollectInvocationOutput"] | components["schemas"]["CollectionCartesianInvocationOutput"] | components["schemas"]["CollectionConcatInvocationOutput"] | components["schemas"]["CollectionZipInvocationOutput"] | components["schemas"]["ColorCollectionOutput"] | components["schemas"]["ColorOutput"] | components["schemas"]["ConditioningCollectionOutput"] | components["schemas"]["ConditioningOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["DenoiseMaskOutput"] | components["schemas"]["ErnieImageConditioningOutput"] | components["schemas"]["ErnieImageModelLoaderOutput"] | components["schemas"]["ExtractVideoRangeOutput"] | components["schemas"]["FaceMaskOutput"] | components["schemas"]["FaceOffOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["FloatGeneratorOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["Flux2DevLoRALoaderOutput"] | components["schemas"]["Flux2DevModelLoaderOutput"] | components["schemas"]["Flux2KleinLoRALoaderOutput"] | components["schemas"]["Flux2KleinModelLoaderOutput"] | components["schemas"]["FluxConditioningCollectionOutput"] | components["schemas"]["FluxConditioningOutput"] | components["schemas"]["FluxControlLoRALoaderOutput"] | components["schemas"]["FluxControlNetOutput"] | components["schemas"]["FluxFillOutput"] | components["schemas"]["FluxKontextOutput"] | components["schemas"]["FluxLoRALoaderOutput"] | components["schemas"]["FluxModelLoaderOutput"] | components["schemas"]["FluxReduxOutput"] | components["schemas"]["ForInvocationOutput"] | components["schemas"]["ForReturnInvocationOutput"] | components["schemas"]["Gemma2EncoderOutput"] | components["schemas"]["GradientMaskOutput"] | components["schemas"]["IPAdapterOutput"] | components["schemas"]["IdealSizeOutput"] | components["schemas"]["Ideogram4ConditioningOutput"] | components["schemas"]["Ideogram4ModelLoaderOutput"] | components["schemas"]["IfInvocationOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ImageGeneratorOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["ImagePanelCoordinateOutput"] | components["schemas"]["IntegerCollectionOutput"] | components["schemas"]["IntegerGeneratorOutput"] | components["schemas"]["IntegerOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["Krea2ConditioningOutput"] | components["schemas"]["Krea2LoRALoaderOutput"] | components["schemas"]["Krea2ModelLoaderOutput"] | components["schemas"]["LatentsCollectionOutput"] | components["schemas"]["LatentsMetaOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["LoRALoaderOutput"] | components["schemas"]["LoRASelectorOutput"] | components["schemas"]["LoopStateOutput"] | components["schemas"]["LoopStateValueOutput"] | components["schemas"]["MDControlListOutput"] | components["schemas"]["MDIPAdapterListOutput"] | components["schemas"]["MDT2IAdapterListOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["MetadataItemOutput"] | components["schemas"]["MetadataOutput"] | components["schemas"]["MetadataToLorasCollectionOutput"] | components["schemas"]["MetadataToModelOutput"] | components["schemas"]["MetadataToSDXLModelOutput"] | components["schemas"]["MiniMaxH3ConditioningOutput"] | components["schemas"]["MiniMaxH3DenoiseOutput"] | components["schemas"]["MiniMaxH3FrameConditioningOutput"] | components["schemas"]["MiniMaxH3LoRACollectionLoaderOutput"] | components["schemas"]["MiniMaxH3LoRALoaderOutput"] | components["schemas"]["MiniMaxH3ModelLoaderOutput"] | components["schemas"]["ModelIdentifierOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PBRMapsOutput"] | components["schemas"]["PairTileImageOutput"] | components["schemas"]["PiDDecoderOutput"] | components["schemas"]["PromptTemplateOutput"] | components["schemas"]["QwenImageConditioningOutput"] | components["schemas"]["QwenImageLoRALoaderOutput"] | components["schemas"]["QwenImageModelLoaderOutput"] | components["schemas"]["SD3ConditioningOutput"] | components["schemas"]["SDXLLoRALoaderOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["SchedulerOutput"] | components["schemas"]["Sd3ModelLoaderOutput"] | components["schemas"]["SeamlessModeOutput"] | components["schemas"]["String2Output"] | components["schemas"]["StringCollectionOutput"] | components["schemas"]["StringGeneratorOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["StringPosNegOutput"] | components["schemas"]["T2IAdapterOutput"] | components["schemas"]["TileToPropertiesOutput"] | components["schemas"]["UNetOutput"] | components["schemas"]["VAEOutput"] | components["schemas"]["VideoOutput"] | components["schemas"]["WanConditioningOutput"] | components["schemas"]["WanLoRALoaderOutput"] | components["schemas"]["WanModelLoaderOutput"] | components["schemas"]["WanRefImageOutput"] | components["schemas"]["WorkflowReturnGetOutput"] | components["schemas"]["WorkflowReturnOutput"] | components["schemas"]["WorkflowReturnValueOutput"] | components["schemas"]["ZImageConditioningOutput"] | components["schemas"]["ZImageControlOutput"] | components["schemas"]["ZImageLoRALoaderOutput"] | components["schemas"]["ZImageModelLoaderOutput"]; }; /** * InvocationErrorEvent @@ -19505,7 +19848,7 @@ export type components = { * Invocation * @description The ID of the invocation */ - invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; + invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CollectionCartesianInvocation"] | components["schemas"]["CollectionConcatInvocation"] | components["schemas"]["CollectionZipInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["ForInvocation"] | components["schemas"]["ForReturnInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StateEmptyInvocation"] | components["schemas"]["StateGetInvocation"] | components["schemas"]["StateMergeInvocation"] | components["schemas"]["StateSetInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; /** * Invocation Source Id * @description The ID of the prepared invocation's source node @@ -19561,6 +19904,9 @@ export type components = { cogview4_model_loader: components["schemas"]["CogView4ModelLoaderOutput"]; cogview4_text_encoder: components["schemas"]["CogView4ConditioningOutput"]; collect: components["schemas"]["CollectInvocationOutput"]; + collection_cartesian: components["schemas"]["CollectionCartesianInvocationOutput"]; + collection_concat: components["schemas"]["CollectionConcatInvocationOutput"]; + collection_zip: components["schemas"]["CollectionZipInvocationOutput"]; color: components["schemas"]["ColorOutput"]; color_correct: components["schemas"]["ImageOutput"]; color_map: components["schemas"]["ImageOutput"]; @@ -19628,6 +19974,8 @@ export type components = { flux_text_encoder: components["schemas"]["FluxConditioningOutput"]; flux_vae_decode: components["schemas"]["ImageOutput"]; flux_vae_encode: components["schemas"]["LatentsOutput"]; + for: components["schemas"]["ForInvocationOutput"]; + for_return: components["schemas"]["ForReturnInvocationOutput"]; freeu: components["schemas"]["UNetOutput"]; gemini_image_generation: components["schemas"]["ImageCollectionOutput"]; gemma2_encoder_loader: components["schemas"]["Gemma2EncoderOutput"]; @@ -19796,6 +20144,10 @@ export type components = { show_image: components["schemas"]["ImageOutput"]; spandrel_image_to_image: components["schemas"]["ImageOutput"]; spandrel_image_to_image_autoscale: components["schemas"]["ImageOutput"]; + state_empty: components["schemas"]["LoopStateOutput"]; + state_get: components["schemas"]["LoopStateValueOutput"]; + state_merge: components["schemas"]["LoopStateOutput"]; + state_set: components["schemas"]["LoopStateOutput"]; string: components["schemas"]["StringOutput"]; string_batch: components["schemas"]["StringOutput"]; string_collection: components["schemas"]["StringCollectionOutput"]; @@ -19898,7 +20250,7 @@ export type components = { * Invocation * @description The ID of the invocation */ - invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; + invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CollectionCartesianInvocation"] | components["schemas"]["CollectionConcatInvocation"] | components["schemas"]["CollectionZipInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["ForInvocation"] | components["schemas"]["ForReturnInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StateEmptyInvocation"] | components["schemas"]["StateGetInvocation"] | components["schemas"]["StateMergeInvocation"] | components["schemas"]["StateSetInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; /** * Invocation Source Id * @description The ID of the prepared invocation's source node @@ -19979,7 +20331,7 @@ export type components = { * Invocation * @description The ID of the invocation */ - invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; + invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CollectionCartesianInvocation"] | components["schemas"]["CollectionConcatInvocation"] | components["schemas"]["CollectionZipInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["ForInvocation"] | components["schemas"]["ForReturnInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StateEmptyInvocation"] | components["schemas"]["StateGetInvocation"] | components["schemas"]["StateMergeInvocation"] | components["schemas"]["StateSetInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; /** * Invocation Source Id * @description The ID of the prepared invocation's source node @@ -24179,6 +24531,39 @@ export type components = { */ success: boolean; }; + /** LoopState */ + LoopState: { + /** Values */ + values?: { + [key: string]: unknown; + }; + }; + /** LoopStateOutput */ + LoopStateOutput: { + /** @description The loop state */ + state: components["schemas"]["LoopState"]; + /** + * type + * @default loop_state_output + * @constant + */ + type: "loop_state_output"; + }; + /** LoopStateValueOutput */ + LoopStateValueOutput: { + /** + * Value + * @description The value read from the loop state, or None when the key is missing + * @default null + */ + value: unknown | null; + /** + * type + * @default loop_state_value_output + * @constant + */ + type: "loop_state_value_output"; + }; /** LoraModelDefaultSettings */ LoraModelDefaultSettings: { /** @@ -31990,7 +32375,17 @@ export type components = { ui_order: number | null; /** @default null */ ui_type: components["schemas"]["UIType"] | null; + /** @default null */ + output_scope: components["schemas"]["OutputScope"] | null; }; + /** + * OutputScope + * @description The execution scope for an output field. + * - `Iteration`: The field emits values for a loop body's current iteration. + * - `Final`: The field emits values after a loop boundary completes. + * @enum {string} + */ + OutputScope: "iteration" | "final"; /** * PBR Maps * @description Generate Normal, Displacement and Roughness Map from a given image @@ -37952,6 +38347,169 @@ export type components = { */ previous_names?: string[]; }; + /** + * Empty Loop State + * @description Creates an empty loop state. + */ + StateEmptyInvocation: { + /** + * Id + * @description The id of this instance of an invocation. Must be unique among all instances of invocations. + */ + id: string; + /** + * Is Intermediate + * @description Whether or not this is an intermediate invocation. + * @default false + */ + is_intermediate?: boolean; + /** + * Use Cache + * @description Whether or not to use the cache + * @default true + */ + use_cache?: boolean; + /** + * type + * @default state_empty + * @constant + */ + type: "state_empty"; + }; + /** + * Get Loop State Value + * @description Reads a value from loop state. + */ + StateGetInvocation: { + /** + * Id + * @description The id of this instance of an invocation. Must be unique among all instances of invocations. + */ + id: string; + /** + * Is Intermediate + * @description Whether or not this is an intermediate invocation. + * @default false + */ + is_intermediate?: boolean; + /** + * Use Cache + * @description Whether or not to use the cache + * @default true + */ + use_cache?: boolean; + /** + * @description The loop state to read + * @default null + */ + state?: components["schemas"]["LoopState"] | null; + /** + * Key + * @description The state key to read + * @default + */ + key?: string; + /** + * Default + * @description The value to return when the key is missing + * @default null + */ + default?: unknown | null; + /** + * type + * @default state_get + * @constant + */ + type: "state_get"; + }; + /** + * Merge Loop State Values + * @description Returns loop state with multiple values merged. + */ + StateMergeInvocation: { + /** + * Id + * @description The id of this instance of an invocation. Must be unique among all instances of invocations. + */ + id: string; + /** + * Is Intermediate + * @description Whether or not this is an intermediate invocation. + * @default false + */ + is_intermediate?: boolean; + /** + * Use Cache + * @description Whether or not to use the cache + * @default true + */ + use_cache?: boolean; + /** + * @description The loop state to update + * @default null + */ + state?: components["schemas"]["LoopState"] | null; + /** + * Values + * @description The values to merge into the loop state. Connect an output to this input. + * @default {} + */ + values?: { + [key: string]: unknown; + }; + /** + * type + * @default state_merge + * @constant + */ + type: "state_merge"; + }; + /** + * Set Loop State Value + * @description Returns loop state with one value set. + */ + StateSetInvocation: { + /** + * Id + * @description The id of this instance of an invocation. Must be unique among all instances of invocations. + */ + id: string; + /** + * Is Intermediate + * @description Whether or not this is an intermediate invocation. + * @default false + */ + is_intermediate?: boolean; + /** + * Use Cache + * @description Whether or not to use the cache + * @default true + */ + use_cache?: boolean; + /** + * @description The loop state to update + * @default null + */ + state?: components["schemas"]["LoopState"] | null; + /** + * Key + * @description The state key to set + * @default + */ + key?: string; + /** + * Value + * @description The value to set. Connect an output to this input. + * @default null + */ + value?: unknown | null; + /** + * type + * @default state_set + * @constant + */ + type: "state_set"; + }; /** * String2Output * @description Base class for invocations that output two strings @@ -40215,7 +40773,7 @@ export type components = { * * - Any Field * We cannot infer the usage of `typing.Any` via schema parsing, so you *must* use `ui_type=UIType.Any` to - * indicate that the field accepts any type. Use with caution. This cannot be used on outputs. + * indicate that the field accepts any type. Use with caution. On inputs, this renders as a connection-only field. * * - Scheduler Field * Special handling in the UI is needed for this field, which otherwise would be parsed as a plain enum field. diff --git a/tests/app/invocations/test_collections.py b/tests/app/invocations/test_collections.py new file mode 100644 index 00000000000..84bc10e8ef3 --- /dev/null +++ b/tests/app/invocations/test_collections.py @@ -0,0 +1,128 @@ +from unittest.mock import Mock + +import pytest + +from invokeai.app.invocations.collections import ( + MAX_CARTESIAN_PRODUCT_SIZE, + CollectionCartesianInvocation, + CollectionConcatInvocation, + CollectionZipInvocation, +) + + +def test_collection_concat_preserves_left_then_right_order() -> None: + invocation = CollectionConcatInvocation(id="concat", first=[1, 2], second=[3, 4]) + + output = invocation.invoke(Mock()) + + assert output.collection == [1, 2, 3, 4] + + +def test_collection_concat_handles_empty_inputs() -> None: + invocation = CollectionConcatInvocation(id="concat", first=[], second=["value"]) + + output = invocation.invoke(Mock()) + + assert output.collection == ["value"] + + +def test_collection_concat_does_not_mutate_input_collections() -> None: + first = ["left"] + second = ["right"] + invocation = CollectionConcatInvocation(id="concat", first=first, second=second) + + output = invocation.invoke(Mock()) + output.collection.append("changed") + + assert first == ["left"] + assert second == ["right"] + + +def test_collection_zip_preserves_positional_order_as_pairs() -> None: + invocation = CollectionZipInvocation(id="zip", first=[1, 2], second=["a", "b"]) + + output = invocation.invoke(Mock()) + + assert output.collection == [[1, "a"], [2, "b"]] + + +def test_collection_zip_rejects_unequal_input_lengths() -> None: + invocation = CollectionZipInvocation(id="zip", first=[1], second=["a", "b"]) + + with pytest.raises(ValueError, match="same length"): + invocation.invoke(Mock()) + + +def test_collection_zip_handles_empty_inputs() -> None: + invocation = CollectionZipInvocation(id="zip", first=[], second=[]) + + output = invocation.invoke(Mock()) + + assert output.collection == [] + + +def test_collection_zip_does_not_mutate_input_collections() -> None: + first = ["left"] + second = ["right"] + invocation = CollectionZipInvocation(id="zip", first=first, second=second) + + output = invocation.invoke(Mock()) + output.collection.append(["changed", "value"]) + + assert first == ["left"] + assert second == ["right"] + + +def test_collection_cartesian_preserves_left_major_right_minor_order() -> None: + invocation = CollectionCartesianInvocation(id="cartesian", first=[1, 2], second=["a", "b"]) + + output = invocation.invoke(Mock()) + + assert output.collection == [[1, "a"], [1, "b"], [2, "a"], [2, "b"]] + + +def test_collection_cartesian_accepts_unequal_input_lengths() -> None: + invocation = CollectionCartesianInvocation(id="cartesian", first=[1], second=["a", "b", "c"]) + + output = invocation.invoke(Mock()) + + assert output.collection == [[1, "a"], [1, "b"], [1, "c"]] + + +def test_collection_cartesian_handles_empty_inputs() -> None: + invocation = CollectionCartesianInvocation(id="cartesian", first=[], second=["value"]) + + output = invocation.invoke(Mock()) + + assert output.collection == [] + + +def test_collection_cartesian_does_not_mutate_input_collections() -> None: + first = ["left"] + second = ["right"] + invocation = CollectionCartesianInvocation(id="cartesian", first=first, second=second) + + output = invocation.invoke(Mock()) + output.collection.append(["changed", "value"]) + + assert first == ["left"] + assert second == ["right"] + + +def test_collection_cartesian_rejects_products_above_the_bound() -> None: + invocation = CollectionCartesianInvocation( + id="cartesian", first=list(range(MAX_CARTESIAN_PRODUCT_SIZE + 1)), second=["value"] + ) + + with pytest.raises(ValueError, match=str(MAX_CARTESIAN_PRODUCT_SIZE)): + invocation.invoke(Mock()) + + +def test_collection_cartesian_accepts_a_product_at_the_bound() -> None: + invocation = CollectionCartesianInvocation( + id="cartesian", first=list(range(MAX_CARTESIAN_PRODUCT_SIZE)), second=["value"] + ) + + output = invocation.invoke(Mock()) + + assert len(output.collection) == MAX_CARTESIAN_PRODUCT_SIZE diff --git a/tests/app/invocations/test_loop_nodes.py b/tests/app/invocations/test_loop_nodes.py new file mode 100644 index 00000000000..7ba0d27cf34 --- /dev/null +++ b/tests/app/invocations/test_loop_nodes.py @@ -0,0 +1,186 @@ +import pytest + +from invokeai.app.invocations.fields import OutputScope +from invokeai.app.invocations.loops import ( + ForInvocation, + ForReturnInvocation, + ForReturnInvocationOutput, + LoopState, + LoopStateValueOutput, + StateEmptyInvocation, + StateGetInvocation, + StateMergeInvocation, + StateSetInvocation, +) +from invokeai.app.services.invocation_cache.invocation_cache_memory import MemoryInvocationCache +from invokeai.app.services.shared.graph import get_output_field_scope + + +def test_loop_state_defaults_to_empty_values() -> None: + assert LoopState().values == {} + + +def test_for_invocation_outputs_have_iteration_and_final_scopes() -> None: + node = ForInvocation(id="for") + + assert get_output_field_scope(node, "item") == OutputScope.Iteration + assert get_output_field_scope(node, "index") == OutputScope.Iteration + assert get_output_field_scope(node, "total") == OutputScope.Iteration + assert get_output_field_scope(node, "state") == OutputScope.Iteration + assert get_output_field_scope(node, "output_collection") == OutputScope.Final + assert get_output_field_scope(node, "final_state") == OutputScope.Final + + +def test_for_invocation_is_not_directly_executable() -> None: + node = ForInvocation(id="for") + + with pytest.raises(NotImplementedError, match="scheduler-special"): + node.invoke(None) # type: ignore[arg-type] + + +def test_for_return_loop_linkage_is_the_first_input() -> None: + input_names = [ + name for name in ForReturnInvocation.model_fields if name not in {"id", "is_intermediate", "use_cache", "type"} + ] + assert input_names == ["loop_linkage", "output", "state", "continue_condition"] + + +def test_for_return_scheduler_outputs_are_hidden_from_ui_schema() -> None: + schema = ForReturnInvocationOutput.model_json_schema() + invocation_schema = ForReturnInvocation.model_json_schema() + + assert schema["properties"]["output"]["ui_hidden"] is True + assert schema["properties"]["state"]["ui_hidden"] is True + assert invocation_schema["properties"]["output"].get("ui_hidden", False) is False + assert invocation_schema["properties"]["state"].get("ui_hidden", False) is False + + +def test_state_set_invocation_schema_exposes_any_value_input() -> None: + schema = StateSetInvocation.model_json_schema() + + assert schema["properties"]["value"]["ui_type"] == "AnyField" + + +def test_state_get_invocation_schema_exposes_any_default_input() -> None: + schema = StateGetInvocation.model_json_schema() + + assert schema["properties"]["default"]["ui_type"] == "AnyField" + + +def test_state_get_output_schema_exposes_any_value_output() -> None: + schema = LoopStateValueOutput.model_json_schema() + + assert schema["properties"]["value"]["ui_type"] == "AnyField" + + +def test_state_merge_invocation_schema_exposes_any_values_input() -> None: + schema = StateMergeInvocation.model_json_schema() + + assert schema["properties"]["values"]["ui_type"] == "AnyField" + + +def test_for_return_invocation_returns_body_output_and_state() -> None: + state = LoopState(values={"count": 1}) + node = ForReturnInvocation(id="return", output="value", state=state) + + output = node.invoke(None) # type: ignore[arg-type] + + assert output.output == "value" + assert output.state == state + + +def test_state_empty_invocation_returns_empty_loop_state() -> None: + node = StateEmptyInvocation(id="state_empty") + + output = node.invoke(None) # type: ignore[arg-type] + + assert output.state == LoopState() + + +def test_state_get_invocation_returns_value_for_key() -> None: + state = LoopState(values={"count": 2}) + node = StateGetInvocation(id="state_get", state=state, key="count") + + output = node.invoke(None) # type: ignore[arg-type] + + assert output.value == 2 + + +def test_state_get_invocation_returns_default_for_missing_key() -> None: + state = LoopState(values={"count": 2}) + node = StateGetInvocation(id="state_get", state=state, key="missing", default="fallback") + + output = node.invoke(None) # type: ignore[arg-type] + + assert output.value == "fallback" + + +def test_state_get_invocation_deep_copies_model_values() -> None: + nested_state = LoopState(values={"items": ["alpha"]}) + state = LoopState(values={"nested": nested_state}) + node = StateGetInvocation(id="state_get", state=state, key="nested") + + output = node.invoke(None) # type: ignore[arg-type] + assert isinstance(output.value, LoopState) + output.value.values["items"].append("beta") + + assert nested_state == LoopState(values={"items": ["alpha"]}) + + +def test_state_set_invocation_returns_new_state_with_value() -> None: + state = LoopState(values={"count": 2}) + node = StateSetInvocation(id="state_set", state=state, key="count", value=3) + + output = node.invoke(None) # type: ignore[arg-type] + + assert output.state == LoopState(values={"count": 3}) + assert state == LoopState(values={"count": 2}) + + +def test_state_set_invocation_defaults_to_empty_input_state() -> None: + node = StateSetInvocation(id="state_set", key="count", value=1) + + output = node.invoke(None) # type: ignore[arg-type] + + assert output.state == LoopState(values={"count": 1}) + + +def test_state_merge_invocation_returns_new_state_with_updates() -> None: + state = LoopState(values={"count": 2, "name": "old"}) + node = StateMergeInvocation(id="state_merge", state=state, values={"name": "new", "done": True}) + + output = node.invoke(None) # type: ignore[arg-type] + + assert output.state == LoopState(values={"count": 2, "name": "new", "done": True}) + assert state == LoopState(values={"count": 2, "name": "old"}) + + +def test_state_merge_invocation_default_values_are_not_shared() -> None: + first = StateMergeInvocation(id="first") + second = StateMergeInvocation(id="second") + first.values["count"] = 1 + + assert second.values == {} + + +def test_loop_body_cache_key_ignores_rematerialized_node_id_when_inputs_match() -> None: + first = StateGetInvocation(id="state_get_0", state=LoopState(values={"count": 1}), key="count") + second = StateGetInvocation(id="state_get_1", state=LoopState(values={"count": 1}), key="count") + + assert MemoryInvocationCache.create_key(first) == MemoryInvocationCache.create_key(second) + + +def test_loop_body_cache_key_includes_loop_state_input() -> None: + first = StateGetInvocation(id="state_get_0", state=LoopState(), key="last_item", default=None) + second = StateGetInvocation( + id="state_get_1", state=LoopState(values={"last_item": "alpha"}), key="last_item", default=None + ) + + assert MemoryInvocationCache.create_key(first) != MemoryInvocationCache.create_key(second) + + +def test_loop_body_cache_key_includes_loop_item_input() -> None: + first = StateSetInvocation(id="state_set_0", state=LoopState(), key="last_item", value="alpha") + second = StateSetInvocation(id="state_set_1", state=LoopState(), key="last_item", value="beta") + + assert MemoryInvocationCache.create_key(first) != MemoryInvocationCache.create_key(second) diff --git a/tests/app/invocations/test_output_field_scope.py b/tests/app/invocations/test_output_field_scope.py new file mode 100644 index 00000000000..c90ae5e1494 --- /dev/null +++ b/tests/app/invocations/test_output_field_scope.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel + +from invokeai.app.invocations.fields import OutputField, OutputScope + + +class ScopedOutputModel(BaseModel): + iteration_value: str = OutputField(output_scope=OutputScope.Iteration) + final_value: str = OutputField(output_scope=OutputScope.Final) + ordinary_value: str = OutputField() + + +def test_output_field_scope_is_included_in_json_schema() -> None: + schema = ScopedOutputModel.model_json_schema() + + assert schema["properties"]["iteration_value"]["output_scope"] == "iteration" + assert schema["properties"]["final_value"]["output_scope"] == "final" + assert "output_scope" not in schema["properties"]["ordinary_value"] diff --git a/tests/app/services/session_processor/test_for_loop_processor.py b/tests/app/services/session_processor/test_for_loop_processor.py new file mode 100644 index 00000000000..178caeb983d --- /dev/null +++ b/tests/app/services/session_processor/test_for_loop_processor.py @@ -0,0 +1,357 @@ +import asyncio +from contextlib import contextmanager +from threading import Event, Lock +from types import SimpleNamespace +from typing import Any, Callable + +import pytest + +from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput, invocation, invocation_output +from invokeai.app.invocations.fields import InputField, OutputField +from invokeai.app.invocations.loops import ForInvocation, ForReturnInvocation +from invokeai.app.services.events.events_common import QueueItemStatusChangedEvent +from invokeai.app.services.session_processor.session_processor_default import ( + DefaultSessionProcessor, + DefaultSessionRunner, +) +from invokeai.app.services.session_queue.session_queue_common import ( + BatchStatus, + SessionQueueItem, + SessionQueueItemNotFoundError, + SessionQueueStatus, +) +from invokeai.app.services.shared.graph import Graph, GraphExecutionState +from invokeai.app.services.shared.invocation_context import InvocationContext +from tests.test_nodes import create_edge, create_loop_linkage + + +@invocation_output("test_for_processor_body_output") +class ForProcessorBodyOutput(BaseInvocationOutput): + value: int = OutputField(description="The loop body value") + + +@invocation("test_for_processor_body", version="1.0.0") +class ForProcessorBodyInvocation(BaseInvocation): + value: int = InputField(default=0, description="The current loop item") + fail_on: int | None = InputField(default=None, description="The value that raises an exception") + + def invoke(self, context: InvocationContext) -> ForProcessorBodyOutput: + if self.value == self.fail_on: + raise ValueError(f"Refusing loop value {self.value}") + return ForProcessorBodyOutput(value=self.value) + + +@invocation_output("test_for_processor_after_output") +class ForProcessorAfterOutput(BaseInvocationOutput): + values: list[Any] = OutputField(description="The completed loop values") + + +@invocation("test_for_processor_after", version="1.0.0") +class ForProcessorAfterInvocation(BaseInvocation): + values: list[Any] = InputField(default_factory=list, description="The completed loop values") + + def invoke(self, context: InvocationContext) -> ForProcessorAfterOutput: + return ForProcessorAfterOutput(values=self.values) + + +class _Logger: + def debug(self, message: str) -> None: + pass + + def error(self, message: str) -> None: + pass + + def info(self, message: str) -> None: + pass + + def warning(self, message: str) -> None: + pass + + +class _Stats: + @contextmanager + def collect_stats(self, invocation: BaseInvocation, graph_execution_state_id: str): + yield + + def log_stats(self, graph_execution_state_id: str) -> None: + pass + + def reset_stats(self, graph_execution_state_id: str) -> None: + pass + + +class _Events: + def __init__(self) -> None: + self.started: list[str] = [] + self.completed: list[str] = [] + self.errors: list[tuple[str, str, str]] = [] + + def emit_invocation_started(self, queue_item, invocation) -> None: + self.started.append(invocation.id) + + def emit_invocation_complete(self, invocation, queue_item, output) -> None: + self.completed.append(invocation.id) + + def emit_invocation_error(self, queue_item, invocation, error_type, error_message, error_traceback) -> None: + self.errors.append((invocation.id, error_type, error_message)) + + +class _ProcessorQueue: + """Small synchronized queue that preserves the queue mutation/event boundary needed here.""" + + def __init__(self, item: SessionQueueItem) -> None: + self._item = item + self._lock = Lock() + self._dequeued = False + self.terminal = Event() + self.status_events: list[QueueItemStatusChangedEvent] = [] + self.on_status_changed: Callable[[QueueItemStatusChangedEvent], None] | None = None + self.session_updates: list[GraphExecutionState] = [] + self.completed_item_ids: list[int] = [] + self.canceled_item_ids: list[int] = [] + self.failed_item_ids: list[int] = [] + + def dequeue(self, device: str | None = None) -> SessionQueueItem | None: + with self._lock: + if self._dequeued: + return None + self._dequeued = True + self._item.status = "in_progress" + self._item.device = device + return self._item + + def get_queue_item(self, item_id: int) -> SessionQueueItem: + if item_id != self._item.item_id: + raise SessionQueueItemNotFoundError(f"No queue item with id {item_id}") + return self._item + + def set_queue_item_session(self, item_id: int, session: GraphExecutionState) -> SessionQueueItem: + item = self.get_queue_item(item_id) + item.session = session + self.session_updates.append(session) + return item + + def _status_event(self) -> QueueItemStatusChangedEvent: + return QueueItemStatusChangedEvent.build( + self._item, + BatchStatus( + queue_id=self._item.queue_id, + batch_id=self._item.batch_id, + origin=self._item.origin, + destination=self._item.destination, + pending=0, + in_progress=1 if self._item.status == "in_progress" else 0, + waiting=0, + completed=1 if self._item.status == "completed" else 0, + failed=1 if self._item.status == "failed" else 0, + canceled=1 if self._item.status == "canceled" else 0, + total=1, + ), + SessionQueueStatus( + queue_id=self._item.queue_id, + item_id=self._item.item_id, + batch_id=self._item.batch_id, + session_id=self._item.session_id, + pending=0, + in_progress=1 if self._item.status == "in_progress" else 0, + waiting=0, + completed=1 if self._item.status == "completed" else 0, + failed=1 if self._item.status == "failed" else 0, + canceled=1 if self._item.status == "canceled" else 0, + total=1, + ), + ) + + def _emit_status_changed(self) -> None: + event = self._status_event() + self.status_events.append(event) + if self.on_status_changed is not None: + self.on_status_changed(event) + + def complete_queue_item(self, item_id: int, queue_item: SessionQueueItem | None = None) -> SessionQueueItem: + item = self.get_queue_item(item_id) + item.status = "completed" + self.completed_item_ids.append(item_id) + self.terminal.set() + return item + + def cancel_queue_item(self, item_id: int) -> SessionQueueItem: + item = self.get_queue_item(item_id) + with self._lock: + if item.status == "canceled": + return item + item.status = "canceled" + self.canceled_item_ids.append(item_id) + self._emit_status_changed() + self.terminal.set() + return item + + def fail_queue_item( + self, item_id: int, error_type: str, error_message: str, error_traceback: str + ) -> SessionQueueItem: + item = self.get_queue_item(item_id) + item.status = "failed" + item.error_type = error_type + item.error_message = error_message + item.error_traceback = error_traceback + self.failed_item_ids.append(item_id) + self.terminal.set() + return item + + +def _build_graph(*, fail_on: int | None = None) -> Graph: + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=[1, 2, 3])) + graph.add_node(ForProcessorBodyInvocation(id="body", fail_on=fail_on)) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(ForProcessorAfterInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "body", "value")) + graph.add_edge(create_edge("body", "value", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "values")) + graph.add_edge(create_loop_linkage("for", "return")) + return graph + + +def _build_item(*, fail_on: int | None = None) -> SessionQueueItem: + session = GraphExecutionState(graph=_build_graph(fail_on=fail_on)) + return SessionQueueItem( + item_id=1, + batch_id="batch-1", + session_id=session.id, + created_at="2026-01-01T00:00:00", + updated_at="2026-01-01T00:00:00", + started_at=None, + completed_at=None, + queue_id="default", + session=session, + ) + + +def _build_processor( + monkeypatch: pytest.MonkeyPatch, + queue: _ProcessorQueue, + events: _Events, + on_after_run_node: Callable | None = None, +) -> DefaultSessionProcessor: + monkeypatch.setattr( + "invokeai.app.services.session_processor.session_processor_default.build_invocation_context", + lambda data, services, is_canceled: None, + ) + config = SimpleNamespace( + generation_devices=[], + profile_graphs=False, + multiuser=False, + node_cache_size=0, + offload_text_encoders_to_idle_gpus=False, + ) + services = SimpleNamespace( + configuration=config, + events=events, + logger=_Logger(), + performance_statistics=_Stats(), + session_queue=queue, + image_moves=None, + ) + runner = DefaultSessionRunner( + on_after_run_node_callbacks=[on_after_run_node] if on_after_run_node is not None else [] + ) + processor = DefaultSessionProcessor(session_runner=runner, polling_interval=0) + if on_after_run_node is not None: + callback_target: dict[str, DefaultSessionProcessor] = {} + queue.on_status_changed = lambda event: asyncio.run( + callback_target["processor"]._on_queue_item_status_changed((event.__event_name__, event)) + ) + callback_target["processor"] = processor + processor.start(SimpleNamespace(services=services)) + return processor + + +def _stop_processor(processor: DefaultSessionProcessor) -> None: + processor.stop() + for worker in processor._workers: + assert worker.thread is not None + worker.thread.join(timeout=5) + assert not worker.thread.is_alive() + + +def _completed_source_ids(item: SessionQueueItem, events: _Events) -> list[str]: + return [item.session.prepared_source_mapping[exec_id] for exec_id in events.completed] + + +def test_processor_completes_for_loop_in_worker_thread(monkeypatch: pytest.MonkeyPatch) -> None: + item = _build_item() + queue = _ProcessorQueue(item) + events = _Events() + processor = _build_processor(monkeypatch, queue, events) + try: + assert queue.terminal.wait(timeout=5) + finally: + _stop_processor(processor) + + assert item.status == "completed" + assert queue.completed_item_ids == [item.item_id] + assert not queue.canceled_item_ids + assert not queue.failed_item_ids + assert item.session.is_complete() + completed_source_ids = _completed_source_ids(item, events) + assert completed_source_ids.count("for") == 3 + assert completed_source_ids.count("body") == 3 + assert completed_source_ids.count("return") == 3 + assert completed_source_ids[-1] == "after" + + +def test_processor_cancellation_event_stops_for_loop_without_final_output(monkeypatch: pytest.MonkeyPatch) -> None: + item = _build_item() + queue = _ProcessorQueue(item) + events = _Events() + + def cancel_after_first_return(invocation, queue_item, output) -> None: + source_id = queue_item.session.prepared_source_mapping[invocation.id] + if source_id == "return" and _completed_source_ids(queue_item, events).count("return") == 1: + queue.cancel_queue_item(queue_item.item_id) + + processor = _build_processor(monkeypatch, queue, events, on_after_run_node=cancel_after_first_return) + try: + assert queue.terminal.wait(timeout=5) + finally: + _stop_processor(processor) + + assert queue.status_events and queue.status_events[-1].status == "canceled" + assert item.status == "canceled" + assert queue.canceled_item_ids == [item.item_id] + assert queue.completed_item_ids == [] + assert queue.failed_item_ids == [] + assert not item.session.is_complete() + completed_source_ids = _completed_source_ids(item, events) + assert completed_source_ids.count("for") == 1 + assert completed_source_ids.count("body") == 1 + assert completed_source_ids.count("return") == 1 + assert "after" not in completed_source_ids + assert "after" not in item.session.source_prepared_mapping + assert not item.session.finalized_loop_nodes + + +def test_processor_body_exception_fails_for_item_without_after_loop_execution(monkeypatch: pytest.MonkeyPatch) -> None: + item = _build_item(fail_on=2) + queue = _ProcessorQueue(item) + events = _Events() + processor = _build_processor(monkeypatch, queue, events) + try: + assert queue.terminal.wait(timeout=5) + finally: + _stop_processor(processor) + + assert item.status == "failed" + assert queue.failed_item_ids == [item.item_id] + assert queue.completed_item_ids == [] + assert queue.canceled_item_ids == [] + assert item.error_type == "ValueError" + assert item.error_message == "Refusing loop value 2" + assert len(item.session.errors) == 1 + completed_source_ids = _completed_source_ids(item, events) + assert completed_source_ids.count("for") == 2 + assert completed_source_ids.count("body") == 1 + assert completed_source_ids.count("return") == 1 + assert "after" not in completed_source_ids + assert "after" not in item.session.source_prepared_mapping + assert not item.session.finalized_loop_nodes diff --git a/tests/app/services/session_processor/test_for_loop_processor_sqlite.py b/tests/app/services/session_processor/test_for_loop_processor_sqlite.py new file mode 100644 index 00000000000..47ae6096c13 --- /dev/null +++ b/tests/app/services/session_processor/test_for_loop_processor_sqlite.py @@ -0,0 +1,376 @@ +import asyncio +import uuid +from contextlib import contextmanager +from threading import Condition, Event +from typing import Any, Iterator + +import pytest +from fastapi_events.handlers.local import local_handler + +from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput, invocation, invocation_output +from invokeai.app.invocations.fields import InputField, OutputField +from invokeai.app.invocations.loops import ForInvocation, ForReturnInvocation +from invokeai.app.services.events.events_base import EventServiceBase +from invokeai.app.services.events.events_common import EventBase, QueueItemStatusChangedEvent +from invokeai.app.services.invoker import Invoker +from invokeai.app.services.session_processor.session_processor_default import ( + DefaultSessionProcessor, + DefaultSessionRunner, +) +from invokeai.app.services.session_queue.session_queue_sqlite import SqliteSessionQueue +from invokeai.app.services.shared.graph import CollectInvocation, Graph, GraphExecutionState, IterateInvocation +from invokeai.app.services.shared.invocation_context import InvocationContext +from tests.test_nodes import create_edge, create_loop_linkage + + +@invocation_output("test_for_sqlite_body_output") +class ForSqliteBodyOutput(BaseInvocationOutput): + value: int = OutputField(description="The loop body value") + + +@invocation("test_for_sqlite_body", version="1.0.0") +class ForSqliteBodyInvocation(BaseInvocation): + value: int = InputField(default=0, description="The current loop item") + fail_on: int | None = InputField(default=None, description="The value that raises an exception") + + def invoke(self, context: InvocationContext) -> ForSqliteBodyOutput: + if self.value == self.fail_on: + raise ValueError(f"Refusing loop value {self.value}") + return ForSqliteBodyOutput(value=self.value) + + +@invocation_output("test_for_sqlite_collection_adapter_output") +class ForSqliteCollectionAdapterOutput(BaseInvocationOutput): + collection: list[Any] = OutputField(description="The inner loop collection") + + +@invocation("test_for_sqlite_collection_adapter", version="1.0.0") +class ForSqliteCollectionAdapterInvocation(BaseInvocation): + value: Any = InputField(default=None, description="The outer loop item") + + def invoke(self, context: InvocationContext) -> ForSqliteCollectionAdapterOutput: + return ForSqliteCollectionAdapterOutput(collection=self.value) + + +@invocation_output("test_for_sqlite_after_output") +class ForSqliteAfterOutput(BaseInvocationOutput): + collection: list[Any] = OutputField(description="The completed loop collection") + + +@invocation("test_for_sqlite_after", version="1.0.0") +class ForSqliteAfterInvocation(BaseInvocation): + collection: list[Any] = InputField(default_factory=list, description="The completed loop collection") + + def invoke(self, context: InvocationContext) -> ForSqliteAfterOutput: + return ForSqliteAfterOutput(collection=self.collection) + + +def _build_nested_graph(*, fail_on: int | None = None) -> Graph: + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=[[1, 2], [3, 4]])) + graph.add_node(ForSqliteCollectionAdapterInvocation(id="adapter")) + graph.add_node(IterateInvocation(id="iterate")) + graph.add_node(ForSqliteBodyInvocation(id="body", fail_on=fail_on)) + graph.add_node(CollectInvocation(id="collect")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(ForSqliteAfterInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "adapter", "value")) + graph.add_edge(create_edge("adapter", "collection", "iterate", "collection")) + graph.add_edge(create_edge("iterate", "item", "body", "value")) + graph.add_edge(create_edge("body", "value", "collect", "item")) + graph.add_edge(create_edge("collect", "collection", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "collection")) + graph.add_edge(create_loop_linkage("for", "return")) + return graph + + +def _build_nested_for_graph(*, fail_on: int | None = None) -> Graph: + graph = Graph() + graph.add_node(ForInvocation(id="outer_for", collection=[[1, 2], [3, 4]])) + graph.add_node(ForSqliteCollectionAdapterInvocation(id="inner_collection")) + graph.add_node(ForInvocation(id="inner_for")) + graph.add_node(ForSqliteBodyInvocation(id="inner_body", fail_on=fail_on)) + graph.add_node(ForReturnInvocation(id="inner_return")) + graph.add_node(ForReturnInvocation(id="outer_return")) + graph.add_node(ForSqliteAfterInvocation(id="after")) + graph.add_edge(create_edge("outer_for", "item", "inner_collection", "value")) + graph.add_edge(create_edge("inner_collection", "collection", "inner_for", "collection")) + graph.add_edge(create_edge("inner_for", "item", "inner_body", "value")) + graph.add_edge(create_edge("inner_body", "value", "inner_return", "output")) + graph.add_edge(create_edge("inner_for", "output_collection", "outer_return", "output")) + graph.add_edge(create_edge("outer_for", "output_collection", "after", "collection")) + graph.add_edge(create_loop_linkage("outer_for", "outer_return")) + graph.add_edge(create_loop_linkage("inner_for", "inner_return")) + return graph + + +class _RecordingRegisteredEventService(EventServiceBase): + def __init__(self) -> None: + self._events: list[EventBase] = [] + self._events_condition = Condition() + + def dispatch(self, event: EventBase) -> None: + with self._events_condition: + self._events.append(event) + self._events_condition.notify_all() + asyncio.run(local_handler.handle((event.__event_name__, event))) + + def wait_for_status(self, item_id: int, status: str, timeout: float = 5) -> bool: + def has_status() -> bool: + return any( + isinstance(event, QueueItemStatusChangedEvent) and event.item_id == item_id and event.status == status + for event in self._events + ) + + with self._events_condition: + if has_status(): + return True + return self._events_condition.wait_for(has_status, timeout=timeout) + + +@pytest.fixture +def registered_event_bus() -> Iterator[_RecordingRegisteredEventService]: + yield _RecordingRegisteredEventService() + + +def _stop_processor(processor: DefaultSessionProcessor) -> None: + processor.stop() + for worker in processor._workers: + assert worker.thread is not None + worker.thread.join(timeout=5) + assert not worker.thread.is_alive() + + +def _insert_session(queue: SqliteSessionQueue, graph: Graph) -> int: + session = GraphExecutionState(graph=graph) + with queue._db.transaction() as cursor: + cursor.execute( + """--sql + INSERT INTO session_queue ( + queue_id, session, session_id, batch_id, field_values, priority, + workflow, origin, destination, retried_from_item_id, user_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + "default", + session.model_dump_json(warnings=False, exclude_none=True), + session.id, + str(uuid.uuid4()), + None, + 0, + None, + None, + None, + None, + "system", + ), + ) + return cursor.lastrowid # type: ignore[return-value] + + +class _Stats: + @contextmanager + def collect_stats(self, invocation, graph_execution_state_id): + yield + + def log_stats(self, graph_execution_state_id) -> None: + pass + + def reset_stats(self, graph_execution_state_id) -> None: + pass + + +@pytest.mark.parametrize("outcome", ["success", "canceled", "failure"]) +def test_processor_sqlite_queue_nested_iterate_for_cleanup( + monkeypatch: pytest.MonkeyPatch, + mock_invoker: Invoker, + registered_event_bus: _RecordingRegisteredEventService, + outcome: str, +) -> None: + monkeypatch.setattr( + "invokeai.app.services.session_processor.session_processor_default.build_invocation_context", + lambda data, services, is_canceled: None, + ) + + queue = SqliteSessionQueue(db=mock_invoker.services.board_records._db) + mock_invoker.services.events = registered_event_bus + mock_invoker.services.session_queue = queue + mock_invoker.services.performance_statistics = _Stats() + queue.start(mock_invoker) + + returns_seen = 0 + + def cancel_after_first_return(invocation, queue_item, output) -> None: + nonlocal returns_seen + if queue_item.session.prepared_source_mapping[invocation.id] != "return": + return + returns_seen += 1 + if outcome == "canceled" and returns_seen == 1: + queue.cancel_queue_item(queue_item.item_id) + + processor = DefaultSessionProcessor( + session_runner=DefaultSessionRunner(on_after_run_node_callbacks=[cancel_after_first_return]), + polling_interval=0, + ) + graph = _build_nested_graph(fail_on=2 if outcome == "failure" else None) + item_id = _insert_session(queue, graph) + status_handler_called = Event() + original_status_handler = processor._on_queue_item_status_changed + + async def recording_status_handler(event) -> None: + if event[1].item_id == item_id and event[1].status == "canceled": + status_handler_called.set() + await original_status_handler(event) + + processor._on_queue_item_status_changed = recording_status_handler # type: ignore[method-assign] + try: + processor.start(mock_invoker) + + expected_status = { + "success": "completed", + "canceled": "canceled", + "failure": "failed", + }[outcome] + assert registered_event_bus.wait_for_status(item_id, expected_status) + + queue_item = queue.get_queue_item(item_id) + assert queue_item.status == expected_status + assert queue.get_current("default") is None + assert any( + isinstance(event, QueueItemStatusChangedEvent) + and event.item_id == item_id + and event.status == expected_status + for event in registered_event_bus._events + ) + + if outcome == "success": + assert queue_item.session.is_complete() + [after_exec_id] = queue_item.session.source_prepared_mapping["after"] + assert queue_item.session.results[after_exec_id].collection == [[1, 2], [3, 4]] + assert returns_seen == 2 + else: + assert "after" not in queue_item.session.source_prepared_mapping + assert not queue_item.session.finalized_loop_nodes + if outcome == "canceled": + assert returns_seen == 1 + assert status_handler_called.wait(timeout=5) + assert not queue_item.session.is_complete() + else: + assert queue_item.session.has_error() + assert queue_item.error_type == "ValueError" + assert queue_item.error_message == "Refusing loop value 2" + assert returns_seen == 0 + finally: + _stop_processor(processor) + + +@pytest.mark.parametrize("outcome", ["success", "canceled", "failure"]) +def test_processor_sqlite_queue_nested_for_cleanup( + monkeypatch: pytest.MonkeyPatch, + mock_invoker: Invoker, + registered_event_bus: _RecordingRegisteredEventService, + outcome: str, +) -> None: + monkeypatch.setattr( + "invokeai.app.services.session_processor.session_processor_default.build_invocation_context", + lambda data, services, is_canceled: None, + ) + + queue = SqliteSessionQueue(db=mock_invoker.services.board_records._db) + mock_invoker.services.events = registered_event_bus + mock_invoker.services.session_queue = queue + mock_invoker.services.performance_statistics = _Stats() + queue.start(mock_invoker) + + inner_returns_seen = 0 + + def cancel_after_first_inner_return(invocation, queue_item, output) -> None: + nonlocal inner_returns_seen + if queue_item.session.prepared_source_mapping[invocation.id] != "inner_return": + return + inner_returns_seen += 1 + if outcome == "canceled" and inner_returns_seen == 1: + queue.cancel_queue_item(queue_item.item_id) + + processor = DefaultSessionProcessor( + session_runner=DefaultSessionRunner(on_after_run_node_callbacks=[cancel_after_first_inner_return]), + polling_interval=0, + ) + graph = _build_nested_for_graph(fail_on=3 if outcome == "failure" else None) + item_id = _insert_session(queue, graph) + status_handler_called = Event() + original_status_handler = processor._on_queue_item_status_changed + + async def recording_status_handler(event) -> None: + if event[1].item_id == item_id and event[1].status == "canceled": + status_handler_called.set() + await original_status_handler(event) + + processor._on_queue_item_status_changed = recording_status_handler # type: ignore[method-assign] + try: + processor.start(mock_invoker) + + expected_status = { + "success": "completed", + "canceled": "canceled", + "failure": "failed", + }[outcome] + assert registered_event_bus.wait_for_status(item_id, expected_status) + + queue_item = queue.get_queue_item(item_id) + assert queue_item.status == expected_status + assert queue.get_current("default") is None + assert any( + isinstance(event, QueueItemStatusChangedEvent) + and event.item_id == item_id + and event.status == expected_status + for event in registered_event_bus._events + ) + + if outcome == "success": + assert queue_item.session.is_complete() + [after_exec_id] = queue_item.session.source_prepared_mapping["after"] + assert queue_item.session.results[after_exec_id].collection == [[1, 2], [3, 4]] + assert ( + len( + [ + exec_id + for exec_id in queue_item.session.source_prepared_mapping["outer_return"] + if exec_id in queue_item.session.results + ] + ) + == 2 + ) + assert inner_returns_seen == 4 + else: + assert "after" not in queue_item.session.source_prepared_mapping + assert not queue_item.session.finalized_loop_nodes + assert not any( + getattr(queue_item.session.results.get(exec_id), "output_collection", []) + for exec_id in queue_item.session.source_prepared_mapping.get("outer_for", []) + ) + if outcome == "canceled": + assert inner_returns_seen == 1 + assert not any( + exec_id in queue_item.session.results + for exec_id in queue_item.session.source_prepared_mapping.get("outer_return", []) + ) + assert status_handler_called.wait(timeout=5) + assert not queue_item.session.is_complete() + else: + assert queue_item.session.has_error() + assert queue_item.error_type == "ValueError" + assert queue_item.error_message == "Refusing loop value 3" + assert ( + len( + [ + exec_id + for exec_id in queue_item.session.source_prepared_mapping["outer_return"] + if exec_id in queue_item.session.results + ] + ) + == 1 + ) + assert inner_returns_seen == 2 + finally: + _stop_processor(processor) diff --git a/tests/app/services/session_queue/test_for_loop_session_queue.py b/tests/app/services/session_queue/test_for_loop_session_queue.py new file mode 100644 index 00000000000..7606c5e9e69 --- /dev/null +++ b/tests/app/services/session_queue/test_for_loop_session_queue.py @@ -0,0 +1,267 @@ +import uuid +from unittest.mock import Mock + +import pytest +from pydantic import TypeAdapter + +from invokeai.app.invocations.baseinvocation import InvocationContext +from invokeai.app.invocations.loops import ( + ForInvocation, + ForReturnInvocation, + StateGetInvocation, + StateSetInvocation, +) +from invokeai.app.services.invoker import Invoker +from invokeai.app.services.session_queue.session_queue_sqlite import SqliteSessionQueue +from invokeai.app.services.shared.graph import Graph, GraphExecutionState +from tests.test_nodes import AnyTypeTestInvocation, create_edge, create_loop_linkage + + +@pytest.fixture +def session_queue(mock_invoker: Invoker) -> SqliteSessionQueue: + queue = SqliteSessionQueue(db=mock_invoker.services.board_records._db) + queue.start(mock_invoker) + return queue + + +def _execute_next(state: GraphExecutionState) -> str | None: + node = state.next() + if node is None: + return None + output = node.invoke(Mock(InvocationContext)) + state.complete(node.id, output) + return state.prepared_source_mapping[node.id] + + +def _stateful_for_graph() -> Graph: + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta", "charlie"])) + graph.add_node(StateSetInvocation(id="state_set", key="last_item")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after_collection")) + graph.add_node(StateGetInvocation(id="after_state", key="last_item")) + graph.add_edge(create_edge("for", "state", "state_set", "state")) + graph.add_edge(create_edge("for", "item", "state_set", "value")) + graph.add_edge(create_edge("state_set", "state", "return", "state")) + graph.add_edge(create_edge("for", "output_collection", "after_collection", "value")) + graph.add_edge(create_edge("for", "final_state", "after_state", "state")) + graph.add_edge(create_edge("for", "item", "return", "output")) + graph.add_edge(create_loop_linkage("for", "return")) + return graph + + +def _nested_for_graph() -> Graph: + graph = Graph() + graph.add_node(ForInvocation(id="outer_for", collection=[["a", "b"], ["c", "d"]])) + graph.add_node(AnyTypeTestInvocation(id="inner_collection")) + graph.add_node(ForInvocation(id="inner_for")) + graph.add_node(AnyTypeTestInvocation(id="inner_body")) + graph.add_node(ForReturnInvocation(id="inner_return")) + graph.add_node(ForReturnInvocation(id="outer_return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("outer_for", "item", "inner_collection", "value")) + graph.add_edge(create_edge("inner_collection", "value", "inner_for", "collection")) + graph.add_edge(create_edge("inner_for", "item", "inner_body", "value")) + graph.add_edge(create_edge("inner_body", "value", "inner_return", "output")) + graph.add_edge(create_edge("inner_for", "output_collection", "outer_return", "output")) + graph.add_edge(create_edge("outer_for", "output_collection", "after", "value")) + graph.add_edge(create_loop_linkage("outer_for", "outer_return")) + graph.add_edge(create_loop_linkage("inner_for", "inner_return")) + return graph + + +def _empty_for_graph() -> Graph: + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=[])) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "value")) + graph.add_edge(create_loop_linkage("for", "return")) + return graph + + +def _empty_for_missing_state_graph() -> Graph: + graph = _empty_for_graph() + graph.add_node(StateGetInvocation(id="after_state", key="missing")) + graph.add_edge(create_edge("for", "final_state", "after_state", "state")) + return graph + + +def _insert_session(queue: SqliteSessionQueue, state: GraphExecutionState) -> int: + session_id = str(uuid.uuid4()) + batch_id = str(uuid.uuid4()) + session_json = state.model_dump_json(warnings=False, exclude_none=True) + with queue._db.transaction() as cursor: + cursor.execute( + """--sql + INSERT INTO session_queue ( + queue_id, session, session_id, batch_id, field_values, priority, + workflow, origin, destination, retried_from_item_id, user_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ("default", session_json, session_id, batch_id, None, 0, None, None, None, None, "system"), + ) + return cursor.lastrowid # type: ignore[return-value] + + +def test_sqlite_queue_resumes_partial_stateful_for_loop(session_queue: SqliteSessionQueue) -> None: + item_id = _insert_session(session_queue, GraphExecutionState(graph=_stateful_for_graph())) + + queue_item = session_queue.dequeue() + assert queue_item is not None + assert queue_item.item_id == item_id + state = queue_item.session + + # Finish the first iteration, then persist the in-progress state through SQLite. + assert [_execute_next(state) for _ in range(3)] == ["for", "state_set", "return"] + prepared_mapping = state.prepared_source_mapping.copy() + # Use the GraphExecutionState JSON contract as the oracle for private prepared metadata. + direct_round_trip = TypeAdapter(GraphExecutionState).validate_json( + state.model_dump_json(warnings=False, exclude_none=True), strict=False + ) + direct_iteration_paths = { + exec_id: direct_round_trip._prepared_registry().get_iteration_path(exec_id) for exec_id in prepared_mapping + } + session_queue.save_queue_item_session(queue_item.item_id, state) + + reloaded_item = session_queue.get_queue_item(queue_item.item_id) + resumed = reloaded_item.session + assert resumed.prepared_source_mapping == prepared_mapping + assert { + exec_id: resumed._prepared_registry().get_iteration_path(exec_id) for exec_id in prepared_mapping + } == direct_iteration_paths + assert not resumed.is_complete() + assert "after_collection" not in resumed.prepared_source_mapping.values() + + remaining_sources: list[str] = [] + while (source_id := _execute_next(resumed)) is not None: + remaining_sources.append(source_id) + + assert remaining_sources == [ + "for", + "state_set", + "return", + "for", + "state_set", + "return", + "after_collection", + "after_state", + ] + assert resumed.is_complete() + + after_collection_id = next( + exec_id for exec_id, source_id in resumed.prepared_source_mapping.items() if source_id == "after_collection" + ) + after_state_id = next( + exec_id for exec_id, source_id in resumed.prepared_source_mapping.items() if source_id == "after_state" + ) + assert resumed.results[after_collection_id].value == ["alpha", "beta", "charlie"] + assert resumed.results[after_state_id].value == "charlie" + + session_queue.set_queue_item_session(queue_item.item_id, resumed) + final_item = session_queue.complete_queue_item(queue_item.item_id) + assert final_item.status == "completed" + assert final_item.session.is_complete() + assert final_item.session.results[after_collection_id].value == ["alpha", "beta", "charlie"] + assert final_item.session.results[after_state_id].value == "charlie" + + +def test_sqlite_queue_round_trips_empty_for_final_output(session_queue: SqliteSessionQueue) -> None: + item_id = _insert_session(session_queue, GraphExecutionState(graph=_empty_for_graph())) + + queue_item = session_queue.dequeue() + assert queue_item is not None + state = queue_item.session + after_node = state.next() + assert isinstance(after_node, AnyTypeTestInvocation) + + session_queue.save_queue_item_session(queue_item.item_id, state) + resumed = session_queue.get_queue_item(item_id).session + + for_exec_id = next(exec_id for exec_id, source_id in resumed.prepared_source_mapping.items() if source_id == "for") + for_output = resumed.results[for_exec_id] + assert for_output.type == "for_output" + assert for_output.item is None + assert for_output.index == -1 + assert for_output.total == 0 + + +def test_sqlite_queue_round_trips_missing_loop_state_value_after_empty_for( + session_queue: SqliteSessionQueue, +) -> None: + item_id = _insert_session(session_queue, GraphExecutionState(graph=_empty_for_missing_state_graph())) + + queue_item = session_queue.dequeue() + assert queue_item is not None + state = queue_item.session + after_state = state.next() + while not isinstance(after_state, StateGetInvocation): + assert isinstance(after_state, AnyTypeTestInvocation) + state.complete(after_state.id, after_state.invoke(Mock(InvocationContext))) + after_state = state.next() + assert isinstance(after_state, StateGetInvocation) + state.complete(after_state.id, after_state.invoke(Mock(InvocationContext))) + + session_queue.save_queue_item_session(queue_item.item_id, state) + resumed = session_queue.get_queue_item(item_id).session + + assert resumed.results[after_state.id].value is None + + +def test_sqlite_queue_resumes_nested_for_after_first_outer_iteration( + session_queue: SqliteSessionQueue, +) -> None: + item_id = _insert_session(session_queue, GraphExecutionState(graph=_nested_for_graph())) + + queue_item = session_queue.dequeue() + assert queue_item is not None + state = queue_item.session + completed_sources = [_execute_next(state) for _ in range(9)] + assert completed_sources == [ + "outer_for", + "inner_collection", + "inner_for", + "inner_body", + "inner_return", + "inner_for", + "inner_body", + "inner_return", + "outer_return", + ] + assert state.finalized_loop_contexts == {("inner_for", (0,))} + prepared_mapping = state.prepared_source_mapping.copy() + prepared_paths = {exec_id: state._get_iteration_path(exec_id) for exec_id in prepared_mapping} + + session_queue.save_queue_item_session(queue_item.item_id, state) + resumed = session_queue.get_queue_item(item_id).session + + assert resumed.finalized_loop_contexts == {("inner_for", (0,))} + assert resumed.prepared_source_mapping == prepared_mapping + assert {exec_id: resumed._get_iteration_path(exec_id) for exec_id in prepared_mapping} == prepared_paths + remaining_sources: list[str] = [] + while (source_id := _execute_next(resumed)) is not None: + remaining_sources.append(source_id) + + assert remaining_sources == [ + "outer_for", + "inner_collection", + "inner_for", + "inner_body", + "inner_return", + "inner_for", + "inner_body", + "inner_return", + "outer_return", + "after", + ] + after_exec_id = next( + exec_id for exec_id, source_id in resumed.prepared_source_mapping.items() if source_id == "after" + ) + assert resumed.results[after_exec_id].value == [["a", "b"], ["c", "d"]] + assert resumed.is_complete() + session_queue.set_queue_item_session(item_id, resumed) + final_item = session_queue.complete_queue_item(item_id) + assert final_item.status == "completed" + assert final_item.session.is_complete() + assert final_item.session.results[after_exec_id].value == [["a", "b"], ["c", "d"]] diff --git a/tests/app/services/test_for_loop_session_runner.py b/tests/app/services/test_for_loop_session_runner.py new file mode 100644 index 00000000000..4ec8ba32f89 --- /dev/null +++ b/tests/app/services/test_for_loop_session_runner.py @@ -0,0 +1,492 @@ +from threading import Event +from types import SimpleNamespace +from typing import Any + +import pytest + +from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput, invocation, invocation_output +from invokeai.app.invocations.fields import InputField, OutputField +from invokeai.app.invocations.loops import ForInvocation, ForInvocationOutput, ForReturnInvocation, LoopState +from invokeai.app.invocations.primitives import BooleanOutput +from invokeai.app.services.session_processor.session_processor_default import DefaultSessionRunner +from invokeai.app.services.shared.graph import CollectInvocation, Graph, GraphExecutionState, IterateInvocation +from invokeai.app.services.shared.invocation_context import InvocationContext +from tests.app.services.workflow_call_test_utils import ( + _DummyConfig, + _DummyEvents, + _DummyLogger, + _DummySessionQueue, + _DummyStats, +) +from tests.test_nodes import create_edge, create_loop_linkage + + +@invocation_output("test_for_runner_value_output") +class ForRunnerValueOutput(BaseInvocationOutput): + value: int = OutputField(description="The loop body value") + + +@invocation("test_for_runner_body", version="1.0.0") +class ForRunnerBodyInvocation(BaseInvocation): + value: int = InputField(default=0, description="The current loop item") + fail_on: int | None = InputField(default=None, description="The value that raises an exception") + + def invoke(self, context: InvocationContext) -> ForRunnerValueOutput: + if self.value == self.fail_on: + raise ValueError(f"Refusing loop value {self.value}") + return ForRunnerValueOutput(value=self.value) + + +@invocation_output("test_for_runner_collection_adapter_output") +class ForRunnerCollectionAdapterOutput(BaseInvocationOutput): + collection: list[Any] = OutputField(description="The inner loop collection") + + +@invocation("test_for_runner_collection_adapter", version="1.0.0") +class ForRunnerCollectionAdapterInvocation(BaseInvocation): + value: Any = InputField(default=None, description="The outer loop item") + + def invoke(self, context: InvocationContext) -> ForRunnerCollectionAdapterOutput: + return ForRunnerCollectionAdapterOutput(collection=self.value) + + +@invocation_output("test_for_runner_collection_output") +class ForRunnerCollectionOutput(BaseInvocationOutput): + collection: list[Any] = OutputField(description="The completed loop collection") + + +@invocation("test_for_runner_collection", version="1.0.0") +class ForRunnerCollectionInvocation(BaseInvocation): + collection: list[Any] = InputField(default_factory=list, description="The completed loop collection") + + def invoke(self, context: InvocationContext) -> ForRunnerCollectionOutput: + return ForRunnerCollectionOutput(collection=self.collection) + + +@invocation("test_for_runner_condition", version="1.0.0") +class ForRunnerConditionInvocation(BaseInvocation): + value: Any = InputField(default=None) + continue_condition: bool = InputField(default=True) + + def invoke(self, context: InvocationContext) -> BooleanOutput: + return BooleanOutput(value=self.continue_condition) + + +def _build_graph(*, fail_on: int | None = None) -> Graph: + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=[1, 2, 3])) + graph.add_node(ForRunnerBodyInvocation(id="body", fail_on=fail_on)) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(ForRunnerCollectionInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "body", "value")) + graph.add_edge(create_edge("body", "value", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "collection")) + graph.add_edge(create_loop_linkage("for", "return")) + return graph + + +def _build_nested_graph(*, fail_on: int | None = None) -> Graph: + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=[[1, 2], [3, 4]])) + graph.add_node(ForRunnerCollectionAdapterInvocation(id="adapter")) + graph.add_node(IterateInvocation(id="iterate")) + graph.add_node(ForRunnerBodyInvocation(id="body", fail_on=fail_on)) + graph.add_node(CollectInvocation(id="collect")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(ForRunnerCollectionInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "adapter", "value")) + graph.add_edge(create_edge("adapter", "collection", "iterate", "collection")) + graph.add_edge(create_edge("iterate", "item", "body", "value")) + graph.add_edge(create_edge("body", "value", "collect", "item")) + graph.add_edge(create_edge("collect", "collection", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "collection")) + graph.add_edge(create_loop_linkage("for", "return")) + return graph + + +def _build_nested_for_graph( + *, + fail_on: int | None = None, + collection: list[list[int]] | None = None, + break_inner_after_first: bool = False, + break_outer_after_first: bool = False, +) -> Graph: + graph = Graph() + graph.add_node( + ForInvocation( + id="outer_for", + collection=[[1, 2], [3, 4]] if collection is None else collection, + state=LoopState(values={"outer": True}), + ) + ) + graph.add_node(ForRunnerCollectionAdapterInvocation(id="inner_collection")) + graph.add_node(ForInvocation(id="inner_for")) + graph.add_node(ForRunnerBodyInvocation(id="inner_body", fail_on=fail_on)) + graph.add_node( + ForReturnInvocation( + id="inner_return", + continue_condition=False if break_inner_after_first else None, + ) + ) + graph.add_node(ForRunnerConditionInvocation(id="outer_condition", continue_condition=not break_outer_after_first)) + graph.add_node(ForRunnerCollectionInvocation(id="outer_output")) + graph.add_node(ForReturnInvocation(id="outer_return")) + graph.add_node(ForRunnerCollectionInvocation(id="after")) + graph.add_edge(create_edge("outer_for", "item", "inner_collection", "value")) + graph.add_edge(create_edge("inner_collection", "collection", "inner_for", "collection")) + graph.add_edge(create_edge("inner_for", "item", "inner_body", "value")) + graph.add_edge(create_edge("inner_body", "value", "inner_return", "output")) + graph.add_edge(create_edge("inner_for", "output_collection", "outer_output", "collection")) + graph.add_edge(create_edge("outer_output", "collection", "outer_return", "output")) + graph.add_edge(create_edge("inner_for", "output_collection", "outer_condition", "value")) + graph.add_edge(create_edge("outer_condition", "value", "outer_return", "continue_condition")) + graph.add_edge(create_edge("outer_for", "state", "outer_return", "state")) + graph.add_edge(create_edge("outer_for", "output_collection", "after", "collection")) + graph.add_edge(create_loop_linkage("outer_for", "outer_return")) + graph.add_edge(create_loop_linkage("inner_for", "inner_return")) + return graph + + +def _build_runner( + monkeypatch: pytest.MonkeyPatch, + *, + on_after_run_node=None, +) -> tuple[DefaultSessionRunner, Event, _DummySessionQueue, _DummyEvents]: + monkeypatch.setattr( + "invokeai.app.services.session_processor.session_processor_default.build_invocation_context", + lambda data, services, is_canceled: None, + ) + + cancel_event = Event() + session_queue = _DummySessionQueue() + events = _DummyEvents() + runner = DefaultSessionRunner(on_after_run_node_callbacks=[] if on_after_run_node is None else [on_after_run_node]) + runner.start( + services=SimpleNamespace( + performance_statistics=_DummyStats(), + events=events, + logger=_DummyLogger(), + configuration=_DummyConfig(), + session_queue=session_queue, + ), + cancel_event=cancel_event, + ) + return runner, cancel_event, session_queue, events + + +def _build_queue_item(session: GraphExecutionState) -> SimpleNamespace: + return SimpleNamespace( + item_id=1, + status="in_progress", + session=session, + session_id=session.id, + ) + + +def _completed_source_ids(events: _DummyEvents, session: GraphExecutionState) -> list[str]: + return [session.prepared_source_mapping[invocation.id] for invocation, _queue_item, _output in events.completed] + + +def test_session_runner_completes_for_loop_and_persists_final_outputs(monkeypatch: pytest.MonkeyPatch) -> None: + session = GraphExecutionState(graph=_build_graph()) + runner, _cancel_event, session_queue, events = _build_runner(monkeypatch) + queue_item = _build_queue_item(session) + session_queue.add_queue_item(queue_item) + + runner.run(queue_item) + + assert queue_item.status == "completed" + assert session_queue.completed_item_ids == [queue_item.item_id] + assert session_queue.session_updates[-1] == (queue_item.item_id, session) + assert session.is_complete() + assert _completed_source_ids(events, session).count("for") == 3 + assert _completed_source_ids(events, session).count("body") == 3 + assert _completed_source_ids(events, session).count("return") == 3 + assert _completed_source_ids(events, session)[-1] == "after" + + [after_exec_id] = session.source_prepared_mapping["after"] + assert session.results[after_exec_id] == ForRunnerCollectionOutput(collection=[1, 2, 3]) + assert any( + isinstance(output := session.results.get(exec_id), ForInvocationOutput) + and output.output_collection == [1, 2, 3] + for exec_id in session.source_prepared_mapping["for"] + ) + + +def test_session_runner_cancellation_stops_for_loop_without_releasing_final_outputs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = GraphExecutionState(graph=_build_graph()) + callback_state: dict[str, Any] = {} + + def cancel_after_first_return(invocation, queue_item, output) -> None: + session_queue = callback_state["session_queue"] + cancel_event = callback_state["cancel_event"] + if queue_item.session.prepared_source_mapping[invocation.id] == "return": + session_queue.cancel_queue_item(queue_item.item_id) + cancel_event.set() + + runner, cancel_event, session_queue, events = _build_runner( + monkeypatch, on_after_run_node=cancel_after_first_return + ) + callback_state.update(cancel_event=cancel_event, session_queue=session_queue) + queue_item = _build_queue_item(session) + session_queue.add_queue_item(queue_item) + + runner.run(queue_item) + + completed_source_ids = _completed_source_ids(events, session) + assert queue_item.status == "canceled" + assert session_queue.canceled_item_ids == [queue_item.item_id] + assert session_queue.completed_item_ids == [] + assert session_queue.session_updates[-1] == (queue_item.item_id, session) + assert not session.is_complete() + assert completed_source_ids.count("for") == 1 + assert completed_source_ids.count("body") == 1 + assert completed_source_ids.count("return") == 1 + assert "after" not in session.source_prepared_mapping + assert "for" not in session.finalized_loop_nodes + assert len(session.source_prepared_mapping["for"]) == 2 + assert sum(exec_id in session.results for exec_id in session.source_prepared_mapping["for"]) == 1 + assert not any( + isinstance(output := session.results.get(exec_id), ForInvocationOutput) and output.output_collection + for exec_id in session.source_prepared_mapping["for"] + ) + + +def test_session_runner_body_exception_fails_and_cleans_up_for_loop(monkeypatch: pytest.MonkeyPatch) -> None: + session = GraphExecutionState(graph=_build_graph(fail_on=2)) + runner, _cancel_event, session_queue, events = _build_runner(monkeypatch) + queue_item = _build_queue_item(session) + session_queue.add_queue_item(queue_item) + + runner.run(queue_item) + + completed_source_ids = _completed_source_ids(events, session) + assert queue_item.status == "failed" + assert session_queue.failed_item_ids == [queue_item.item_id] + assert session_queue.completed_item_ids == [] + assert session_queue.session_updates[-1] == (queue_item.item_id, session) + assert session.has_error() + assert len(session.errors) == 1 + [failed_exec_id] = session.errors + assert session.prepared_source_mapping[failed_exec_id] == "body" + assert session.errors[failed_exec_id] == "ValueError: Refusing loop value 2" + assert failed_exec_id not in session.results + assert len(events.errors) == 1 + assert events.errors[0][1].id == failed_exec_id + assert completed_source_ids.count("for") == 2 + assert completed_source_ids.count("body") == 1 + assert completed_source_ids.count("return") == 1 + assert "after" not in session.source_prepared_mapping + assert "for" not in session.finalized_loop_nodes + assert len(session.source_prepared_mapping["for"]) == 2 + assert session.next() is None + assert not any( + isinstance(output := session.results.get(exec_id), ForInvocationOutput) and output.output_collection + for exec_id in session.source_prepared_mapping["for"] + ) + + +def test_session_runner_nested_iterate_cancellation_stops_outer_loop(monkeypatch: pytest.MonkeyPatch) -> None: + session = GraphExecutionState(graph=_build_nested_graph()) + callback_state: dict[str, Any] = {} + + def cancel_after_nested_return(invocation, queue_item, output) -> None: + if queue_item.session.prepared_source_mapping[invocation.id] == "return": + callback_state["session_queue"].cancel_queue_item(queue_item.item_id) + callback_state["cancel_event"].set() + + runner, cancel_event, session_queue, events = _build_runner( + monkeypatch, on_after_run_node=cancel_after_nested_return + ) + callback_state.update(cancel_event=cancel_event, session_queue=session_queue) + queue_item = _build_queue_item(session) + session_queue.add_queue_item(queue_item) + + runner.run(queue_item) + + completed_source_ids = _completed_source_ids(events, session) + assert queue_item.status == "canceled" + assert completed_source_ids.count("for") == 1 + assert completed_source_ids.count("iterate") == 2 + assert completed_source_ids.count("body") == 2 + assert completed_source_ids.count("collect") == 1 + assert completed_source_ids.count("return") == 1 + assert "after" not in session.source_prepared_mapping + assert not session.finalized_loop_nodes + + +def test_session_runner_nested_for_cancellation_stops_outer_loop_without_final_outputs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = GraphExecutionState(graph=_build_nested_for_graph()) + callback_state: dict[str, Any] = {} + + def cancel_after_inner_return(invocation, queue_item, output) -> None: + if queue_item.session.prepared_source_mapping[invocation.id] == "inner_return": + callback_state["session_queue"].cancel_queue_item(queue_item.item_id) + callback_state["cancel_event"].set() + + runner, cancel_event, session_queue, events = _build_runner( + monkeypatch, on_after_run_node=cancel_after_inner_return + ) + callback_state.update(cancel_event=cancel_event, session_queue=session_queue) + queue_item = _build_queue_item(session) + session_queue.add_queue_item(queue_item) + + runner.run(queue_item) + + completed_source_ids = _completed_source_ids(events, session) + assert queue_item.status == "canceled" + assert session_queue.canceled_item_ids == [queue_item.item_id] + assert session_queue.completed_item_ids == [] + assert session_queue.session_updates[-1] == (queue_item.item_id, session) + assert not session.is_complete() + assert completed_source_ids.count("outer_for") == 1 + assert completed_source_ids.count("inner_for") == 1 + assert completed_source_ids.count("inner_body") == 1 + assert completed_source_ids.count("inner_return") == 1 + assert "outer_return" not in completed_source_ids + assert "after" not in session.source_prepared_mapping + assert not session.finalized_loop_nodes + assert len(session.source_prepared_mapping["inner_for"]) == 2 + assert sum(exec_id in session.results for exec_id in session.source_prepared_mapping["inner_for"]) == 1 + assert len(session.source_prepared_mapping["inner_body"]) == 2 + assert sum(exec_id in session.results for exec_id in session.source_prepared_mapping["inner_body"]) == 1 + assert len(session.source_prepared_mapping["inner_return"]) == 2 + assert sum(exec_id in session.results for exec_id in session.source_prepared_mapping["inner_return"]) == 1 + assert not any( + isinstance(output := session.results.get(exec_id), ForInvocationOutput) and output.output_collection + for exec_id in session.source_prepared_mapping.get("outer_for", set()) + ) + + +def test_session_runner_nested_iterate_body_exception_fails_outer_loop(monkeypatch: pytest.MonkeyPatch) -> None: + session = GraphExecutionState(graph=_build_nested_graph(fail_on=2)) + runner, _cancel_event, session_queue, events = _build_runner(monkeypatch) + queue_item = _build_queue_item(session) + session_queue.add_queue_item(queue_item) + + runner.run(queue_item) + + completed_source_ids = _completed_source_ids(events, session) + assert queue_item.status == "failed" + assert session.has_error() + [failed_exec_id] = session.errors + assert session.prepared_source_mapping[failed_exec_id] == "body" + assert session.errors[failed_exec_id] == "ValueError: Refusing loop value 2" + assert completed_source_ids.count("for") == 1 + assert completed_source_ids.count("iterate") == 2 + assert completed_source_ids.count("body") == 1 + assert "after" not in session.source_prepared_mapping + assert not session.finalized_loop_nodes + + +def test_session_runner_completes_nested_for_and_releases_outer_final_outputs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = GraphExecutionState(graph=_build_nested_for_graph()) + runner, _cancel_event, session_queue, events = _build_runner(monkeypatch) + queue_item = _build_queue_item(session) + session_queue.add_queue_item(queue_item) + + runner.run(queue_item) + + assert queue_item.status == "completed" + assert session_queue.completed_item_ids == [queue_item.item_id] + assert session.is_complete() + completed_source_ids = _completed_source_ids(events, session) + assert completed_source_ids.count("outer_for") == 2 + assert completed_source_ids.count("inner_for") == 4 + assert completed_source_ids.count("inner_body") == 4 + assert completed_source_ids.count("inner_return") == 4 + assert completed_source_ids.count("outer_return") == 2 + + [after_exec_id] = session.source_prepared_mapping["after"] + assert session.results[after_exec_id] == ForRunnerCollectionOutput(collection=[[1, 2], [3, 4]]) + + +def test_session_runner_completes_nested_for_with_empty_inner_collection( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = GraphExecutionState(graph=_build_nested_for_graph(collection=[[], [3, 4]])) + runner, _cancel_event, session_queue, events = _build_runner(monkeypatch) + queue_item = _build_queue_item(session) + session_queue.add_queue_item(queue_item) + + runner.run(queue_item) + + assert queue_item.status == "completed" + assert session.is_complete() + completed_source_ids = _completed_source_ids(events, session) + assert completed_source_ids.count("outer_return") == 2 + assert completed_source_ids.count("inner_body") == 2 + assert completed_source_ids.count("inner_return") == 2 + [after_exec_id] = session.source_prepared_mapping["after"] + assert session.results[after_exec_id] == ForRunnerCollectionOutput(collection=[[], [3, 4]]) + + +def test_session_runner_nested_for_early_break_releases_each_inner_context( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = GraphExecutionState(graph=_build_nested_for_graph(break_inner_after_first=True)) + runner, _cancel_event, session_queue, events = _build_runner(monkeypatch) + queue_item = _build_queue_item(session) + session_queue.add_queue_item(queue_item) + + runner.run(queue_item) + + assert queue_item.status == "completed" + assert session.is_complete() + completed_source_ids = _completed_source_ids(events, session) + assert completed_source_ids.count("outer_for") == 2 + assert completed_source_ids.count("inner_for") == 2 + assert completed_source_ids.count("inner_body") == 2 + assert completed_source_ids.count("inner_return") == 2 + assert completed_source_ids.count("outer_return") == 2 + [after_exec_id] = session.source_prepared_mapping["after"] + assert session.results[after_exec_id] == ForRunnerCollectionOutput(collection=[[1], [3]]) + + +def test_session_runner_nested_for_connected_early_break_releases_outer_context( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = GraphExecutionState(graph=_build_nested_for_graph(break_outer_after_first=True)) + runner, _cancel_event, session_queue, events = _build_runner(monkeypatch) + queue_item = _build_queue_item(session) + session_queue.add_queue_item(queue_item) + + runner.run(queue_item) + + assert queue_item.status == "completed" + assert session.is_complete() + completed_source_ids = _completed_source_ids(events, session) + assert completed_source_ids.count("outer_for") == 1 + assert completed_source_ids.count("inner_for") == 2 + assert completed_source_ids.count("inner_body") == 2 + assert completed_source_ids.count("inner_return") == 2 + assert completed_source_ids.count("outer_condition") == 1 + assert completed_source_ids.count("outer_return") == 1 + [after_exec_id] = session.source_prepared_mapping["after"] + assert session.results[after_exec_id] == ForRunnerCollectionOutput(collection=[[1, 2]]) + + +def test_session_runner_nested_for_body_exception_fails_without_outer_final_outputs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = GraphExecutionState(graph=_build_nested_for_graph(fail_on=3)) + runner, _cancel_event, session_queue, events = _build_runner(monkeypatch) + queue_item = _build_queue_item(session) + session_queue.add_queue_item(queue_item) + + runner.run(queue_item) + + assert queue_item.status == "failed" + assert session.has_error() + completed_source_ids = _completed_source_ids(events, session) + assert completed_source_ids.count("outer_return") == 1 + assert completed_source_ids.count("inner_body") == 2 + assert completed_source_ids.count("inner_return") == 2 + assert "after" not in session.source_prepared_mapping + assert not session.finalized_loop_nodes diff --git a/tests/app/services/test_workflow_graph_builder.py b/tests/app/services/test_workflow_graph_builder.py index d30a1995265..697951462f1 100644 --- a/tests/app/services/test_workflow_graph_builder.py +++ b/tests/app/services/test_workflow_graph_builder.py @@ -2,6 +2,7 @@ from invokeai.app.services.shared.graph import Graph from invokeai.app.services.shared.workflow_graph_builder import ( + InvalidWorkflowInputError, UnsupportedWorkflowNodeError, build_graph_from_workflow, ) @@ -122,6 +123,252 @@ def test_build_graph_from_workflow_converts_invocation_nodes(): assert graph.nodes["return-1"].get_type() == "workflow_return" +def test_build_graph_from_workflow_preserves_loop_linkage_edges(): + workflow = _build_workflow( + nodes=[ + _build_workflow_node("for-1", "for", {"collection": ["a"]}), + _build_workflow_node("for-return-1", "for_return", {}), + _build_workflow_node("workflow-return-1", "workflow_return", {"values": []}), + ], + edges=[ + { + "id": "edge-for-body", + "type": "default", + "source": "for-1", + "sourceHandle": "item", + "target": "for-return-1", + "targetHandle": "output", + }, + { + "id": "edge-for-linkage", + "type": "loop_linkage", + "source": "for-1", + "sourceHandle": "loop_linkage", + "target": "for-return-1", + "targetHandle": "loop_linkage", + }, + ], + ) + + graph = build_graph_from_workflow(workflow) + + assert [edge.type for edge in graph.edges] == ["default", "loop_linkage"] + assert graph.edges[1].source.node_id == "for-1" + assert graph.edges[1].destination.node_id == "for-return-1" + + +def test_build_graph_from_workflow_canonicalizes_connector_loop_linkage(): + workflow = _build_workflow( + nodes=[ + _build_workflow_node("for-1", "for", {"collection": ["a"]}), + _build_connector_node("connector-1"), + _build_workflow_node("for-return-1", "for_return", {}), + _build_workflow_node("workflow-return-1", "workflow_return", {"values": []}), + ], + edges=[ + { + "id": "edge-for-body", + "type": "default", + "source": "for-1", + "sourceHandle": "item", + "target": "for-return-1", + "targetHandle": "output", + }, + { + "id": "edge-linkage-input", + "type": "default", + "source": "for-1", + "sourceHandle": "loop_linkage", + "target": "connector-1", + "targetHandle": "in", + }, + { + "id": "edge-linkage-output", + "type": "default", + "source": "connector-1", + "sourceHandle": "out", + "target": "for-return-1", + "targetHandle": "loop_linkage", + }, + ], + ) + + graph = build_graph_from_workflow(workflow) + + assert [edge.type for edge in graph.edges] == ["default", "loop_linkage"] + assert graph.edges[1].source.node_id == "for-1" + assert graph.edges[1].source.field == "loop_linkage" + assert graph.edges[1].destination.node_id == "for-return-1" + assert graph.edges[1].destination.field == "loop_linkage" + + +def test_build_graph_from_workflow_canonicalizes_chained_connector_loop_linkage(): + workflow = _build_workflow( + nodes=[ + _build_workflow_node("for-1", "for", {"collection": ["a"]}), + _build_connector_node("connector-1"), + _build_connector_node("connector-2"), + _build_workflow_node("for-return-1", "for_return", {}), + _build_workflow_node("workflow-return-1", "workflow_return", {"values": []}), + ], + edges=[ + { + "id": "edge-linkage-input", + "type": "default", + "source": "for-1", + "sourceHandle": "loop_linkage", + "target": "connector-1", + "targetHandle": "in", + }, + { + "id": "edge-linkage-chain", + "type": "default", + "source": "connector-1", + "sourceHandle": "out", + "target": "connector-2", + "targetHandle": "in", + }, + { + "id": "edge-linkage-output", + "type": "default", + "source": "connector-2", + "sourceHandle": "out", + "target": "for-return-1", + "targetHandle": "loop_linkage", + }, + ], + ) + + graph = build_graph_from_workflow(workflow) + + assert len(graph.edges) == 1 + assert graph.edges[0].type == "loop_linkage" + assert graph.edges[0].source.node_id == "for-1" + assert graph.edges[0].destination.node_id == "for-return-1" + + +def test_build_graph_from_workflow_rejects_branched_connector_loop_linkage(): + workflow = _build_workflow( + nodes=[ + _build_workflow_node("for-1", "for", {"collection": ["a"]}), + _build_connector_node("connector-1"), + _build_workflow_node("for-return-1", "for_return", {}), + _build_workflow_node("for-return-2", "for_return", {}), + _build_workflow_node("workflow-return-1", "workflow_return", {"values": []}), + ], + edges=[ + { + "id": "edge-linkage-input", + "type": "default", + "source": "for-1", + "sourceHandle": "loop_linkage", + "target": "connector-1", + "targetHandle": "in", + }, + { + "id": "edge-linkage-output-1", + "type": "default", + "source": "connector-1", + "sourceHandle": "out", + "target": "for-return-1", + "targetHandle": "loop_linkage", + }, + { + "id": "edge-linkage-output-2", + "type": "default", + "source": "connector-1", + "sourceHandle": "out", + "target": "for-return-2", + "targetHandle": "loop_linkage", + }, + ], + ) + + with pytest.raises(InvalidWorkflowInputError, match="loop_linkage connector path"): + build_graph_from_workflow(workflow) + + +def test_build_graph_from_workflow_rejects_connector_loop_linkage_duplicate_ownership(): + workflow = _build_workflow( + nodes=[ + _build_workflow_node("for-1", "for", {"collection": ["a"]}), + _build_connector_node("connector-1"), + _build_connector_node("connector-2"), + _build_workflow_node("for-return-1", "for_return", {}), + _build_workflow_node("workflow-return-1", "workflow_return", {"values": []}), + ], + edges=[ + { + "id": "edge-linkage-input-1", + "type": "default", + "source": "for-1", + "sourceHandle": "loop_linkage", + "target": "connector-1", + "targetHandle": "in", + }, + { + "id": "edge-linkage-output-1", + "type": "default", + "source": "connector-1", + "sourceHandle": "out", + "target": "for-return-1", + "targetHandle": "loop_linkage", + }, + { + "id": "edge-linkage-input-2", + "type": "default", + "source": "for-1", + "sourceHandle": "loop_linkage", + "target": "connector-2", + "targetHandle": "in", + }, + { + "id": "edge-linkage-output-2", + "type": "default", + "source": "connector-2", + "sourceHandle": "out", + "target": "for-return-1", + "targetHandle": "loop_linkage", + }, + ], + ) + + with pytest.raises(InvalidWorkflowInputError, match="loop_linkage connector path"): + build_graph_from_workflow(workflow) + + +def test_build_graph_from_workflow_rejects_loop_linkage_connector_ordinary_fanout(): + workflow = _build_workflow( + nodes=[ + _build_workflow_node("for-1", "for", {"collection": ["a"]}), + _build_connector_node("connector-1"), + _build_workflow_node("add-1", "add", {"a": 1, "b": 2}), + _build_workflow_node("workflow-return-1", "workflow_return", {"values": []}), + ], + edges=[ + { + "id": "edge-linkage-input", + "type": "default", + "source": "for-1", + "sourceHandle": "loop_linkage", + "target": "connector-1", + "targetHandle": "in", + }, + { + "id": "edge-ordinary-output", + "type": "default", + "source": "connector-1", + "sourceHandle": "out", + "target": "add-1", + "targetHandle": "a", + }, + ], + ) + + with pytest.raises(InvalidWorkflowInputError, match="loop_linkage connector path"): + build_graph_from_workflow(workflow) + + def test_build_graph_from_workflow_flattens_connector_edges(): workflow = _build_workflow( nodes=[ diff --git a/tests/test_graph_execution_state.py b/tests/test_graph_execution_state.py index a65b169c2a0..1103282eaca 100644 --- a/tests/test_graph_execution_state.py +++ b/tests/test_graph_execution_state.py @@ -1,15 +1,35 @@ from collections import defaultdict, deque from collections.abc import Iterator -from typing import Optional +from typing import Any, Optional from unittest.mock import Mock import pytest from pydantic import TypeAdapter -from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput, InvocationContext -from invokeai.app.invocations.collections import RangeInvocation +from invokeai.app.invocations.baseinvocation import ( + BaseInvocation, + BaseInvocationOutput, + InvocationContext, + invocation, + invocation_output, +) +from invokeai.app.invocations.collections import ( + CollectionCartesianInvocation, + CollectionConcatInvocation, + CollectionZipInvocation, + RangeInvocation, +) from invokeai.app.invocations.fields import InputField, OutputField from invokeai.app.invocations.logic import IfInvocation, IfInvocationOutput +from invokeai.app.invocations.loops import ( + ForInvocation, + ForInvocationOutput, + ForReturnInvocation, + ForReturnInvocationOutput, + LoopState, + StateGetInvocation, + StateSetInvocation, +) from invokeai.app.invocations.math import AddInvocation, MultiplyInvocation from invokeai.app.invocations.primitives import ( BooleanCollectionInvocation, @@ -18,6 +38,7 @@ BooleanOutput, IntegerCollectionInvocation, ) +from invokeai.app.services.invocation_cache.invocation_cache_memory import MemoryInvocationCache from invokeai.app.services.shared.graph import ( CollectInvocation, Graph, @@ -30,14 +51,62 @@ from tests.test_nodes import ( AnyTypeTestInvocation, AnyTypeTestInvocationOutput, + PolymorphicStringTestInvocation, PromptCollectionTestInvocation, PromptTestInvocation, TestEventService, TextToImageTestInvocation, create_edge, + create_loop_linkage, ) +def add_test_loop_linkages(graph: Graph) -> Graph: + """Add the explicit boundary edges shared by the runtime fixture graphs.""" + for_node_ids = [node.id for node in graph.nodes.values() if isinstance(node, ForInvocation)] + return_node_ids = {node.id for node in graph.nodes.values() if isinstance(node, ForReturnInvocation)} + for for_node_id in for_node_ids: + return_node_id = "return" if for_node_id == "for" else for_node_id.removesuffix("_for") + "_return" + if return_node_id in return_node_ids: + graph.add_edge(create_loop_linkage(for_node_id, return_node_id)) + return graph + + +@invocation_output("test_two_any_output") +class TwoAnyTestInvocationOutput(BaseInvocationOutput): + value: Any = OutputField() + + +@invocation("test_two_any", version="1.0.0") +class TwoAnyTestInvocation(BaseInvocation): + first: Any = InputField(default=None) + second: Any = InputField(default=None) + + def invoke(self, context: InvocationContext) -> TwoAnyTestInvocationOutput: + return TwoAnyTestInvocationOutput(value=(self.first, self.second)) + + +@invocation("test_continue_on_value", version="1.0.0") +class ContinueOnValueTestInvocation(BaseInvocation): + value: Any = InputField(default=None) + + def invoke(self, context: InvocationContext) -> BooleanOutput: + return BooleanOutput(value=self.value != "stop") + + +@invocation_output("test_nested_any_collection_output") +class NestedAnyCollectionTestInvocationOutput(BaseInvocationOutput): + collection: list[list[Any]] = OutputField(default=[]) + + +@invocation("test_nested_any_collection", version="1.0.0") +class NestedAnyCollectionTestInvocation(BaseInvocation): + collection: list[list[Any]] = InputField(default=[]) + + def invoke(self, context: InvocationContext) -> NestedAnyCollectionTestInvocationOutput: + return NestedAnyCollectionTestInvocationOutput(collection=self.collection) + + class IntegerCollectionTestInvocationOutput(BaseInvocationOutput): collection: list[int] = OutputField(default=[]) @@ -50,6 +119,32 @@ def invoke(self, context: InvocationContext) -> IntegerCollectionTestInvocationO return IntegerCollectionTestInvocationOutput(collection=[base, base + 1]) +@invocation_output("test_any_collection_from_value_output") +class AnyCollectionFromValueTestInvocationOutput(BaseInvocationOutput): + collection: list[Any] = OutputField(default=[]) + + +@invocation("test_any_collection_from_value", version="1.0.0") +class AnyCollectionFromValueTestInvocation(BaseInvocation): + value: Any = InputField(default=None) + + def invoke(self, context: InvocationContext) -> AnyCollectionFromValueTestInvocationOutput: + return AnyCollectionFromValueTestInvocationOutput(collection=self.value) + + +@invocation_output("test_empty_collection_output") +class EmptyCollectionTestInvocationOutput(BaseInvocationOutput): + collection: list[Any] = OutputField(default=[]) + + +@invocation("test_empty_collection", version="1.0.0") +class EmptyCollectionTestInvocation(BaseInvocation): + value: Any = InputField(default=None) + + def invoke(self, context: InvocationContext) -> EmptyCollectionTestInvocationOutput: + return EmptyCollectionTestInvocationOutput(collection=[]) + + class IntegerCollectionWithBranchingTestInvocation(BaseInvocation): value: int = InputField(default=0) branch_count: int = InputField(default=2) @@ -147,6 +242,1913 @@ def test_graph_state_executes_in_order(simple_graph: Graph): assert n2[0].prompt == n1[0].prompt +def test_graph_for_materializes_first_iteration(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta"])) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_edge(create_edge("for", "item", "return", "output")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + + next_node = state.next() + + assert isinstance(next_node, ForInvocation) + assert state.prepared_source_mapping[next_node.id] == "for" + output = next_node.invoke(Mock(InvocationContext)) + + assert isinstance(output, ForInvocationOutput) + assert output.item == "alpha" + assert output.index == 0 + assert output.total == 2 + assert output.state == LoopState() + + +def test_graph_for_return_receives_first_iteration_item(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta"])) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_edge(create_edge("for", "item", "return", "output")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + for_node, for_output = invoke_next(state) + return_node, return_output = invoke_next(state) + + assert state.prepared_source_mapping[for_node.id] == "for" + assert isinstance(return_node, ForReturnInvocation) + assert state.prepared_source_mapping[return_node.id] == "return" + assert isinstance(return_output, ForReturnInvocationOutput) + assert return_output.output == for_output.item + + +def test_graph_for_final_outputs_do_not_materialize_from_iteration_output(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta"])) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + for_node, _for_output = invoke_next(state) + next_node = state.next() + + assert state.prepared_source_mapping[for_node.id] == "for" + assert isinstance(next_node, ForReturnInvocation) + assert "after" not in state.source_prepared_mapping + + +def test_graph_for_return_schedules_next_iteration(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta"])) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_edge(create_edge("for", "item", "return", "output")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + _for_node, _for_output = invoke_next(state) + _return_node, _return_output = invoke_next(state) + + first_for_node_id = _for_node.id + + assert not state.is_complete() + + next_node = state.next() + + assert isinstance(next_node, ForInvocation) + assert state.execution_graph.get_node(first_for_node_id).collection == [] + assert next_node.collection == ["alpha", "beta"] + assert state.prepared_source_mapping[next_node.id] == "for" + output = next_node.invoke(Mock(InvocationContext)) + + assert isinstance(output, ForInvocationOutput) + assert output.item == "beta" + assert output.index == 1 + assert output.total == 2 + + state.complete(next_node.id, output) + next_return = state.next() + + assert isinstance(next_return, ForReturnInvocation) + return_output = next_return.invoke(Mock(InvocationContext)) + + assert isinstance(return_output, ForReturnInvocationOutput) + assert return_output.output == "beta" + state.complete(next_return.id, return_output) + + assert all( + state.execution_graph.get_node(exec_node_id).collection == [] + for exec_node_id in state.source_prepared_mapping["for"] + ) + assert state.is_complete() + + +def test_graph_sequential_for_uses_previous_final_collection_as_next_input(): + graph = Graph() + graph.add_node(ForInvocation(id="first_for", collection=["alpha", "beta"])) + graph.add_node(ForReturnInvocation(id="first_return")) + graph.add_node(ForInvocation(id="second_for")) + graph.add_node(ForReturnInvocation(id="second_return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("first_for", "item", "first_return", "output")) + graph.add_edge(create_edge("first_for", "output_collection", "second_for", "collection")) + graph.add_edge(create_edge("second_for", "item", "second_return", "output")) + graph.add_edge(create_edge("second_for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "after" + ) + assert state.results[after_exec_id].value == ["alpha", "beta"] + assert state.is_complete() + + +def test_graph_sequential_for_preserves_outer_iteration_scope(): + graph = Graph() + graph.add_node(NestedAnyCollectionTestInvocation(id="source", collection=[["alpha", "beta"], ["charlie"]])) + graph.add_node(IterateInvocation(id="outer_iterate")) + graph.add_node(ForInvocation(id="first_for")) + graph.add_node(ForReturnInvocation(id="first_return")) + graph.add_node(ForInvocation(id="second_for")) + graph.add_node(ForReturnInvocation(id="second_return")) + graph.add_node(CollectInvocation(id="collect")) + graph.add_edge(create_edge("source", "collection", "outer_iterate", "collection")) + graph.add_edge(create_edge("outer_iterate", "item", "first_for", "collection")) + graph.add_edge(create_edge("first_for", "item", "first_return", "output")) + graph.add_edge(create_edge("first_for", "output_collection", "second_for", "collection")) + graph.add_edge(create_edge("second_for", "item", "second_return", "output")) + graph.add_edge(create_edge("second_for", "output_collection", "collect", "item")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + + collect_exec_ids = sorted(state.source_prepared_mapping["collect"], key=state._get_iteration_path) + assert [state._get_iteration_path(exec_id) for exec_id in collect_exec_ids] == [(0,), (1,)] + assert [state.results[exec_id].collection for exec_id in collect_exec_ids] == [[["alpha", "beta"]], [["charlie"]]] + assert state.is_complete() + + +@pytest.mark.parametrize("value", [42, None, {"item": 1}, "text", (1, 2)]) +def test_graph_for_dynamic_non_collection_input_fails_with_a_clear_error(value: Any): + graph = Graph() + graph.add_node(AnyTypeTestInvocation(id="source", value=value)) + graph.add_node(ForInvocation(id="for")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_edge(create_edge("source", "value", "for", "collection")) + graph.add_edge(create_edge("for", "item", "return", "output")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + source_node = state.next() + assert isinstance(source_node, AnyTypeTestInvocation) + state.complete(source_node.id, source_node.invoke(Mock(InvocationContext))) + with pytest.raises(ValueError, match="For collection input must be a list"): + state.next() + + +def test_graph_combines_independent_for_final_outputs_with_different_lengths(): + graph = Graph() + graph.add_node(ForInvocation(id="first_for", collection=["alpha"])) + graph.add_node(ForReturnInvocation(id="first_return")) + graph.add_node(ForInvocation(id="second_for", collection=["beta", "charlie"])) + graph.add_node(ForReturnInvocation(id="second_return")) + graph.add_node(CollectionConcatInvocation(id="concat")) + graph.add_node(AnyTypeTestInvocation(id="after")) + + graph.add_edge(create_edge("first_for", "item", "first_return", "output")) + graph.add_edge(create_edge("second_for", "item", "second_return", "output")) + graph.add_edge(create_edge("first_for", "output_collection", "concat", "first")) + graph.add_edge(create_edge("second_for", "output_collection", "concat", "second")) + graph.add_edge(create_edge("concat", "collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "after" + ) + assert state.results[after_exec_id].value == ["alpha", "beta", "charlie"] + assert state.is_complete() + + +def test_graph_for_retention_survives_partial_json_round_trip(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta", "charlie"])) + graph.add_node(AnyTypeTestInvocation(id="body")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "body", "value")) + graph.add_edge(create_edge("body", "value", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + first_for_node, _first_for_output = invoke_next(state) + _body_node, _body_output = invoke_next(state) + first_return_node, _first_return_output = invoke_next(state) + state.complete( + first_return_node.id, + ForReturnInvocationOutput(output="alpha", state=LoopState(values={"count": 1})), + ) + + raw = state.model_dump_json(warnings=False, exclude_none=True) + resumed = TypeAdapter(GraphExecutionState).validate_json(raw, strict=False) + + prepared_for_nodes = [ + resumed.execution_graph.get_node(exec_node_id) for exec_node_id in resumed.source_prepared_mapping["for"] + ] + assert first_for_node.id in resumed.execution_graph.nodes + assert all( + node.collection == ([] if node.index == 0 else ["alpha", "beta", "charlie"]) for node in prepared_for_nodes + ) + + execute_all_nodes(resumed) + + after_node_id = next(iter(resumed.source_prepared_mapping["after"])) + assert resumed.results[after_node_id].value == ["alpha", "beta", "charlie"] + assert resumed.is_complete() + + +def test_graph_for_return_passes_state_to_next_iteration(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta"])) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_edge(create_edge("for", "item", "return", "output")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + for_node = state.next() + assert isinstance(for_node, ForInvocation) + state.complete(for_node.id, for_node.invoke(Mock(InvocationContext))) + return_node = state.next() + assert isinstance(return_node, ForReturnInvocation) + state.complete(return_node.id, ForReturnInvocationOutput(output="alpha", state=LoopState(values={"count": 1}))) + + next_node = state.next() + + assert isinstance(next_node, ForInvocation) + output = next_node.invoke(Mock(InvocationContext)) + + assert isinstance(output, ForInvocationOutput) + assert output.state == LoopState(values={"count": 1}) + + +def test_graph_for_rematerializes_indirect_body_for_each_iteration(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta"])) + graph.add_node(AnyTypeTestInvocation(id="body")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "body", "value")) + graph.add_edge(create_edge("body", "value", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + executed_source_ids = execute_all_nodes(state) + + assert executed_source_ids == ["for", "body", "return", "for", "body", "return", "after"] + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "after" + ) + assert state.results[after_exec_id].value == ["alpha", "beta"] + assert state.is_complete() + + +def test_graph_for_rematerializes_nested_iterate_body_through_collect(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=[["a", "b"], ["c", "d"]])) + graph.add_node(PolymorphicStringTestInvocation(id="collection_adapter")) + graph.add_node(IterateInvocation(id="nested_iterate")) + graph.add_node(AnyTypeTestInvocation(id="body")) + graph.add_node(CollectInvocation(id="collect")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "collection_adapter", "value")) + graph.add_edge(create_edge("collection_adapter", "collection", "nested_iterate", "collection")) + graph.add_edge(create_edge("nested_iterate", "item", "body", "value")) + graph.add_edge(create_edge("body", "value", "collect", "item")) + graph.add_edge(create_edge("collect", "collection", "return", "output")) + graph.add_edge(create_edge("for", "state", "return", "state")) + graph.add_edge(create_edge("for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "after" + ) + collect_exec_ids = state.source_prepared_mapping["collect"] + return_exec_ids = state.source_prepared_mapping["return"] + + assert sorted(state._get_iteration_path(exec_node_id) for exec_node_id in collect_exec_ids) == [(0,), (1,)] + assert sorted(state._get_iteration_path(exec_node_id) for exec_node_id in return_exec_ids) == [(0,), (1,)] + assert state.results[after_exec_id].value == [["a", "b"], ["c", "d"]] + assert state.is_complete() + + +def test_graph_for_rematerializes_nested_iterate_body_chain_through_collect(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=[["a", "b"], ["c", "d"]])) + graph.add_node(PolymorphicStringTestInvocation(id="collection_adapter")) + graph.add_node(IterateInvocation(id="nested_iterate")) + graph.add_node(AnyTypeTestInvocation(id="first_body")) + graph.add_node(AnyTypeTestInvocation(id="second_body")) + graph.add_node(CollectInvocation(id="collect")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "collection_adapter", "value")) + graph.add_edge(create_edge("collection_adapter", "collection", "nested_iterate", "collection")) + graph.add_edge(create_edge("nested_iterate", "item", "first_body", "value")) + graph.add_edge(create_edge("first_body", "value", "second_body", "value")) + graph.add_edge(create_edge("second_body", "value", "collect", "item")) + graph.add_edge(create_edge("collect", "collection", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "after" + ) + assert state.results[after_exec_id].value == [["a", "b"], ["c", "d"]] + assert state.is_complete() + + +def test_graph_for_nested_iterate_empty_inner_collection_still_returns_one_empty_group(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=[0, 1])) + graph.add_node(MaybeEmptyIntegerCollectionTestInvocation(id="collection_adapter")) + graph.add_node(IterateInvocation(id="nested_iterate")) + graph.add_node(AnyTypeTestInvocation(id="body")) + graph.add_node(CollectInvocation(id="collect")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "collection_adapter", "value")) + graph.add_edge(create_edge("collection_adapter", "collection", "nested_iterate", "collection")) + graph.add_edge(create_edge("nested_iterate", "item", "body", "value")) + graph.add_edge(create_edge("body", "value", "collect", "item")) + graph.add_edge(create_edge("collect", "collection", "return", "output")) + graph.add_edge(create_edge("for", "state", "return", "state")) + graph.add_edge(create_edge("for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "after" + ) + assert state.results[after_exec_id].value == [[], [1]] + assert state.is_complete() + + +def test_graph_for_nested_iterate_resumes_after_json_round_trip(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=[["a", "b"], ["c", "d"]])) + graph.add_node(PolymorphicStringTestInvocation(id="collection_adapter")) + graph.add_node(IterateInvocation(id="nested_iterate")) + graph.add_node(AnyTypeTestInvocation(id="body")) + graph.add_node(CollectInvocation(id="collect")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "collection_adapter", "value")) + graph.add_edge(create_edge("collection_adapter", "collection", "nested_iterate", "collection")) + graph.add_edge(create_edge("nested_iterate", "item", "body", "value")) + graph.add_edge(create_edge("body", "value", "collect", "item")) + graph.add_edge(create_edge("collect", "collection", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + for _ in range(4): + invoke_next(state) + + resumed = TypeAdapter(GraphExecutionState).validate_json( + state.model_dump_json(warnings=False, exclude_none=True), strict=False + ) + execute_all_nodes(resumed) + + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in resumed.prepared_source_mapping.items() + if source_node_id == "after" + ) + assert resumed.results[after_exec_id].value == [["a", "b"], ["c", "d"]] + assert resumed.is_complete() + + +def test_graph_nested_for_resumes_after_json_round_trip_without_replaying_inner_output(): + graph = Graph() + graph.add_node(ForInvocation(id="outer_for", collection=[["a", "b"], ["c", "d"]])) + graph.add_node(AnyCollectionFromValueTestInvocation(id="inner_collection")) + graph.add_node(ForInvocation(id="inner_for")) + graph.add_node(AnyTypeTestInvocation(id="inner_body")) + graph.add_node(StateSetInvocation(id="inner_state", key="last_item")) + graph.add_node(ForReturnInvocation(id="inner_return")) + graph.add_node(ForReturnInvocation(id="outer_return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("outer_for", "item", "inner_collection", "value")) + graph.add_edge(create_edge("inner_collection", "collection", "inner_for", "collection")) + graph.add_edge(create_edge("inner_for", "item", "inner_body", "value")) + graph.add_edge(create_edge("inner_for", "state", "inner_state", "state")) + graph.add_edge(create_edge("inner_for", "item", "inner_state", "value")) + graph.add_edge(create_edge("inner_body", "value", "inner_return", "output")) + graph.add_edge(create_edge("inner_state", "state", "inner_return", "state")) + graph.add_edge(create_edge("inner_for", "output_collection", "outer_return", "output")) + graph.add_edge(create_edge("outer_for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + executed_source_ids: list[str] = [] + for _ in range(6): + invocation, _output = invoke_next(state) + assert invocation is not None + executed_source_ids.append(state.prepared_source_mapping[invocation.id]) + assert executed_source_ids == [ + "outer_for", + "inner_collection", + "inner_for", + "inner_body", + "inner_state", + "inner_return", + ] + first_outer_inner_for_ids = [ + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "inner_for" and state._get_iteration_path(exec_node_id) == (0, 1) + ] + assert len(first_outer_inner_for_ids) == 1 + assert state.execution_graph.get_node(first_outer_inner_for_ids[0]).state == LoopState(values={"last_item": "a"}) + prepared_mapping = state.prepared_source_mapping.copy() + prepared_paths = {exec_node_id: state._get_iteration_path(exec_node_id) for exec_node_id in prepared_mapping} + + resumed = TypeAdapter(GraphExecutionState).validate_json( + state.model_dump_json(warnings=False, exclude_none=True), strict=False + ) + assert {edge.type for edge in resumed.graph.edges} == {"default", "loop_linkage"} + assert resumed.prepared_source_mapping == prepared_mapping + assert { + exec_node_id: resumed._get_iteration_path(exec_node_id) for exec_node_id in prepared_mapping + } == prepared_paths + assert resumed.finalized_loop_contexts == set() + + resumed_source_ids = execute_all_nodes(resumed) + + assert resumed_source_ids == [ + "inner_for", + "inner_body", + "inner_state", + "inner_return", + "outer_return", + "outer_for", + "inner_collection", + "inner_for", + "inner_body", + "inner_state", + "inner_return", + "inner_for", + "inner_body", + "inner_state", + "inner_return", + "outer_return", + "after", + ] + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in resumed.prepared_source_mapping.items() + if source_node_id == "after" + ) + assert resumed.results[after_exec_id].value == [["a", "b"], ["c", "d"]] + assert resumed.is_complete() + + +def test_graph_executes_deeper_nested_for_boundaries(): + graph = Graph() + graph.add_node( + ForInvocation( + id="outer_for", + collection=[[["a", "b"], [], ["c"]], [], [[], ["d"]]], + ) + ) + graph.add_node(AnyCollectionFromValueTestInvocation(id="inner_collection")) + graph.add_node(ForInvocation(id="inner_for")) + graph.add_node(AnyCollectionFromValueTestInvocation(id="leaf_collection")) + graph.add_node(ForInvocation(id="leaf_for")) + graph.add_node(AnyTypeTestInvocation(id="leaf_body")) + graph.add_node(ForReturnInvocation(id="leaf_return")) + graph.add_node(ForReturnInvocation(id="inner_return")) + graph.add_node(ForReturnInvocation(id="outer_return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + + graph.add_edge(create_edge("outer_for", "item", "inner_collection", "value")) + graph.add_edge(create_edge("inner_collection", "collection", "inner_for", "collection")) + graph.add_edge(create_edge("inner_for", "item", "leaf_collection", "value")) + graph.add_edge(create_edge("leaf_collection", "collection", "leaf_for", "collection")) + graph.add_edge(create_edge("leaf_for", "item", "leaf_body", "value")) + graph.add_edge(create_edge("leaf_body", "value", "leaf_return", "output")) + graph.add_edge(create_edge("leaf_for", "output_collection", "inner_return", "output")) + graph.add_edge(create_edge("inner_for", "output_collection", "outer_return", "output")) + graph.add_edge(create_edge("outer_for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "after" + ) + assert state.results[after_exec_id].value == [[["a", "b"], [], ["c"]], [], [[], ["d"]]] + assert state.is_complete() + + +def test_graph_executes_nested_for_with_outer_continuation_using_inner_final_output(): + graph = Graph() + graph.add_node(ForInvocation(id="outer_for", collection=[[], ["a"]])) + graph.add_node(AnyCollectionFromValueTestInvocation(id="inner_collection")) + graph.add_node(ForInvocation(id="inner_for")) + graph.add_node(AnyTypeTestInvocation(id="inner_body")) + graph.add_node(ForReturnInvocation(id="inner_return")) + graph.add_node(TwoAnyTestInvocation(id="continuation")) + graph.add_node(AnyTypeTestInvocation(id="continuation_tail")) + graph.add_node(ForReturnInvocation(id="outer_return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + + graph.add_edge(create_edge("outer_for", "item", "inner_collection", "value")) + graph.add_edge(create_edge("inner_collection", "collection", "inner_for", "collection")) + graph.add_edge(create_edge("inner_for", "item", "inner_body", "value")) + graph.add_edge(create_edge("inner_body", "value", "inner_return", "output")) + graph.add_edge(create_edge("outer_for", "item", "continuation", "first")) + graph.add_edge(create_edge("inner_for", "output_collection", "continuation", "second")) + graph.add_edge(create_edge("continuation", "value", "continuation_tail", "value")) + graph.add_edge(create_edge("continuation_tail", "value", "outer_return", "output")) + graph.add_edge(create_edge("outer_for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "after" + ) + assert state.results[after_exec_id].value == [([], []), (["a"], ["a"])] + assert state.is_complete() + + +def test_graph_nested_for_inner_early_break_resumes_outer_loop(): + graph = Graph() + graph.add_node(ForInvocation(id="outer_for", collection=[["a", "stop"], ["later"]])) + graph.add_node(AnyCollectionFromValueTestInvocation(id="inner_collection")) + graph.add_node(ForInvocation(id="inner_for")) + graph.add_node(ContinueOnValueTestInvocation(id="inner_condition")) + graph.add_node(ForReturnInvocation(id="inner_return")) + graph.add_node(ForReturnInvocation(id="outer_return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + + graph.add_edge(create_edge("outer_for", "item", "inner_collection", "value")) + graph.add_edge(create_edge("inner_collection", "collection", "inner_for", "collection")) + graph.add_edge(create_edge("inner_for", "item", "inner_condition", "value")) + graph.add_edge(create_edge("inner_for", "item", "inner_return", "output")) + graph.add_edge(create_edge("inner_condition", "value", "inner_return", "continue_condition")) + graph.add_edge(create_edge("inner_for", "output_collection", "outer_return", "output")) + graph.add_edge(create_edge("outer_for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "after" + ) + assert state.results[after_exec_id].value == [["a", "stop"], ["later"]] + assert state.is_complete() + + +def test_graph_executes_independent_nested_for_children_through_explicit_fan_in(): + graph = Graph() + graph.add_node(ForInvocation(id="outer_for", collection=[[], ["a", "b"], ["c"]])) + graph.add_node(ForInvocation(id="first_for")) + graph.add_node(ForInvocation(id="second_for")) + graph.add_node(AnyTypeTestInvocation(id="first_body")) + graph.add_node(AnyTypeTestInvocation(id="second_body")) + graph.add_node(ForReturnInvocation(id="first_return")) + graph.add_node(ForReturnInvocation(id="second_return")) + graph.add_node(TwoAnyTestInvocation(id="fan_in")) + graph.add_node(ForReturnInvocation(id="outer_return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + + graph.add_edge(create_edge("outer_for", "item", "first_for", "collection")) + graph.add_edge(create_edge("outer_for", "item", "second_for", "collection")) + graph.add_edge(create_edge("first_for", "item", "first_body", "value")) + graph.add_edge(create_edge("first_body", "value", "first_return", "output")) + graph.add_edge(create_edge("second_for", "item", "second_body", "value")) + graph.add_edge(create_edge("second_body", "value", "second_return", "output")) + graph.add_edge(create_edge("first_for", "output_collection", "fan_in", "first")) + graph.add_edge(create_edge("second_for", "output_collection", "fan_in", "second")) + graph.add_edge(create_edge("fan_in", "value", "outer_return", "output")) + graph.add_edge(create_edge("outer_for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "after" + ) + assert state.results[after_exec_id].value == [([], []), (["a", "b"], ["a", "b"]), (["c"], ["c"])] + assert state.is_complete() + + +def test_graph_executes_sibling_for_through_collection_concat(): + graph = Graph() + graph.add_node(ForInvocation(id="outer_for", collection=[["a", "b"]])) + graph.add_node(ForInvocation(id="first_for")) + graph.add_node(ForInvocation(id="second_for")) + graph.add_node(AnyTypeTestInvocation(id="first_body")) + graph.add_node(AnyTypeTestInvocation(id="second_body")) + graph.add_node(ForReturnInvocation(id="first_return")) + graph.add_node(ForReturnInvocation(id="second_return")) + graph.add_node(CollectionConcatInvocation(id="concat")) + graph.add_node(ForReturnInvocation(id="outer_return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + + graph.add_edge(create_edge("outer_for", "item", "first_for", "collection")) + graph.add_edge(create_edge("outer_for", "item", "second_for", "collection")) + graph.add_edge(create_edge("first_for", "item", "first_body", "value")) + graph.add_edge(create_edge("first_body", "value", "first_return", "output")) + graph.add_edge(create_edge("second_for", "item", "second_body", "value")) + graph.add_edge(create_edge("second_body", "value", "second_return", "output")) + graph.add_edge(create_edge("first_for", "output_collection", "concat", "first")) + graph.add_edge(create_edge("second_for", "output_collection", "concat", "second")) + graph.add_edge(create_edge("concat", "collection", "outer_return", "output")) + graph.add_edge(create_edge("outer_for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "after" + ) + assert state.results[after_exec_id].value == [["a", "b", "a", "b"]] + assert state.is_complete() + + +def test_graph_executes_sibling_for_through_collection_zip(): + graph = Graph() + graph.add_node(ForInvocation(id="outer_for", collection=[[1, 2]])) + graph.add_node(ForInvocation(id="first_for")) + graph.add_node(ForInvocation(id="second_for")) + graph.add_node(AnyTypeTestInvocation(id="first_body")) + graph.add_node(AddInvocation(id="second_body", b=10)) + graph.add_node(ForReturnInvocation(id="first_return")) + graph.add_node(ForReturnInvocation(id="second_return")) + graph.add_node(CollectionZipInvocation(id="zip")) + graph.add_node(ForReturnInvocation(id="outer_return")) + + graph.add_edge(create_edge("outer_for", "item", "first_for", "collection")) + graph.add_edge(create_edge("outer_for", "item", "second_for", "collection")) + graph.add_edge(create_edge("first_for", "item", "first_body", "value")) + graph.add_edge(create_edge("first_body", "value", "first_return", "output")) + graph.add_edge(create_edge("second_for", "item", "second_body", "a")) + graph.add_edge(create_edge("second_body", "value", "second_return", "output")) + graph.add_edge(create_edge("first_for", "output_collection", "zip", "first")) + graph.add_edge(create_edge("second_for", "output_collection", "zip", "second")) + graph.add_edge(create_edge("zip", "collection", "outer_return", "output")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + + outer_return_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "outer_return" + ) + assert state.results[outer_return_id].output == [[1, 11], [2, 12]] + assert state.is_complete() + + +def test_graph_executes_sibling_for_through_collection_cartesian(): + graph = Graph() + graph.add_node(ForInvocation(id="outer_for", collection=[[1, 2]])) + graph.add_node(ForInvocation(id="first_for")) + graph.add_node(ForInvocation(id="second_for")) + graph.add_node(AnyTypeTestInvocation(id="first_body")) + graph.add_node(AddInvocation(id="second_body", b=10)) + graph.add_node(ForReturnInvocation(id="first_return")) + graph.add_node(ForReturnInvocation(id="second_return")) + graph.add_node(CollectionCartesianInvocation(id="cartesian")) + graph.add_node(ForReturnInvocation(id="outer_return")) + + graph.add_edge(create_edge("outer_for", "item", "first_for", "collection")) + graph.add_edge(create_edge("outer_for", "item", "second_for", "collection")) + graph.add_edge(create_edge("first_for", "item", "first_body", "value")) + graph.add_edge(create_edge("first_body", "value", "first_return", "output")) + graph.add_edge(create_edge("second_for", "item", "second_body", "a")) + graph.add_edge(create_edge("second_body", "value", "second_return", "output")) + graph.add_edge(create_edge("first_for", "output_collection", "cartesian", "first")) + graph.add_edge(create_edge("second_for", "output_collection", "cartesian", "second")) + graph.add_edge(create_edge("cartesian", "collection", "outer_return", "output")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + + outer_return_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "outer_return" + ) + assert state.results[outer_return_id].output == [[1, 11], [1, 12], [2, 11], [2, 12]] + assert state.is_complete() + + +def test_graph_nested_sibling_failure_does_not_release_outer_final_outputs(): + graph = Graph() + graph.add_node(ForInvocation(id="outer_for", collection=[["a"]])) + graph.add_node(ForInvocation(id="first_for")) + graph.add_node(ForInvocation(id="second_for")) + graph.add_node(AnyTypeTestInvocation(id="first_body")) + graph.add_node(AnyTypeTestInvocation(id="second_body")) + graph.add_node(ForReturnInvocation(id="first_return")) + graph.add_node(ForReturnInvocation(id="second_return")) + graph.add_node(TwoAnyTestInvocation(id="fan_in")) + graph.add_node(ForReturnInvocation(id="outer_return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + + graph.add_edge(create_edge("outer_for", "item", "first_for", "collection")) + graph.add_edge(create_edge("outer_for", "item", "second_for", "collection")) + graph.add_edge(create_edge("first_for", "item", "first_body", "value")) + graph.add_edge(create_edge("first_body", "value", "first_return", "output")) + graph.add_edge(create_edge("second_for", "item", "second_body", "value")) + graph.add_edge(create_edge("second_body", "value", "second_return", "output")) + graph.add_edge(create_edge("first_for", "output_collection", "fan_in", "first")) + graph.add_edge(create_edge("second_for", "output_collection", "fan_in", "second")) + graph.add_edge(create_edge("fan_in", "value", "outer_return", "output")) + graph.add_edge(create_edge("outer_for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + while True: + node = state.next() + assert node is not None + source_node_id = state.prepared_source_mapping[node.id] + if source_node_id == "first_body": + state.set_node_error(node.id, "first sibling failed") + break + state.complete(node.id, node.invoke(Mock(InvocationContext))) + + assert state.has_error() + assert state.next() is None + assert "fan_in" not in state.source_prepared_mapping + assert "outer_return" not in state.source_prepared_mapping + assert "after" not in state.source_prepared_mapping + + +def test_graph_executes_sibling_for_with_one_empty_child_context(): + graph = Graph() + graph.add_node(ForInvocation(id="outer_for", collection=[["a", "b"], ["c"]])) + graph.add_node(ForInvocation(id="first_for")) + graph.add_node(EmptyCollectionTestInvocation(id="second_collection")) + graph.add_node(ForInvocation(id="second_for")) + graph.add_node(AnyTypeTestInvocation(id="first_body")) + graph.add_node(AnyTypeTestInvocation(id="second_body")) + graph.add_node(ForReturnInvocation(id="first_return")) + graph.add_node(ForReturnInvocation(id="second_return")) + graph.add_node(TwoAnyTestInvocation(id="fan_in")) + graph.add_node(ForReturnInvocation(id="outer_return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + + graph.add_edge(create_edge("outer_for", "item", "first_for", "collection")) + graph.add_edge(create_edge("outer_for", "item", "second_collection", "value")) + graph.add_edge(create_edge("second_collection", "collection", "second_for", "collection")) + graph.add_edge(create_edge("first_for", "item", "first_body", "value")) + graph.add_edge(create_edge("first_body", "value", "first_return", "output")) + graph.add_edge(create_edge("second_for", "item", "second_body", "value")) + graph.add_edge(create_edge("second_body", "value", "second_return", "output")) + graph.add_edge(create_edge("first_for", "output_collection", "fan_in", "first")) + graph.add_edge(create_edge("second_for", "output_collection", "fan_in", "second")) + graph.add_edge(create_edge("fan_in", "value", "outer_return", "output")) + graph.add_edge(create_edge("outer_for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "after" + ) + assert state.results[after_exec_id].value == [(["a", "b"], []), (["c"], [])] + assert state.is_complete() + assert set(state.graph.nx_graph_flat().nodes) <= state.executed + + +def test_graph_nested_for_continuation_failure_does_not_release_outer_final_outputs(): + graph = Graph() + graph.add_node(ForInvocation(id="outer_for", collection=[["a"]])) + graph.add_node(AnyCollectionFromValueTestInvocation(id="inner_collection")) + graph.add_node(ForInvocation(id="inner_for")) + graph.add_node(AnyTypeTestInvocation(id="inner_body")) + graph.add_node(ForReturnInvocation(id="inner_return")) + graph.add_node(TwoAnyTestInvocation(id="continuation")) + graph.add_node(ForReturnInvocation(id="outer_return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + + graph.add_edge(create_edge("outer_for", "item", "inner_collection", "value")) + graph.add_edge(create_edge("inner_collection", "collection", "inner_for", "collection")) + graph.add_edge(create_edge("inner_for", "item", "inner_body", "value")) + graph.add_edge(create_edge("inner_body", "value", "inner_return", "output")) + graph.add_edge(create_edge("outer_for", "item", "continuation", "first")) + graph.add_edge(create_edge("inner_for", "output_collection", "continuation", "second")) + graph.add_edge(create_edge("continuation", "value", "outer_return", "output")) + graph.add_edge(create_edge("outer_for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + while True: + node = state.next() + assert node is not None + if state.prepared_source_mapping[node.id] == "continuation": + state.set_node_error(node.id, "outer continuation failed") + break + state.complete(node.id, node.invoke(Mock(InvocationContext))) + + assert state.has_error() + assert state.next() is None + assert "after" not in state.source_prepared_mapping + assert not any( + exec_node_id in state.results for exec_node_id in state.source_prepared_mapping.get("outer_return", set()) + ) + + +def test_graph_nested_for_continuation_resumes_after_json_round_trip(): + graph = Graph() + graph.add_node(ForInvocation(id="outer_for", collection=[["a"], ["b"]])) + graph.add_node(AnyCollectionFromValueTestInvocation(id="inner_collection")) + graph.add_node(ForInvocation(id="inner_for")) + graph.add_node(AnyTypeTestInvocation(id="inner_body")) + graph.add_node(ForReturnInvocation(id="inner_return")) + graph.add_node(TwoAnyTestInvocation(id="continuation")) + graph.add_node(ForReturnInvocation(id="outer_return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + + graph.add_edge(create_edge("outer_for", "item", "inner_collection", "value")) + graph.add_edge(create_edge("inner_collection", "collection", "inner_for", "collection")) + graph.add_edge(create_edge("inner_for", "item", "inner_body", "value")) + graph.add_edge(create_edge("inner_body", "value", "inner_return", "output")) + graph.add_edge(create_edge("outer_for", "item", "continuation", "first")) + graph.add_edge(create_edge("inner_for", "output_collection", "continuation", "second")) + graph.add_edge(create_edge("continuation", "value", "outer_return", "output")) + graph.add_edge(create_edge("outer_for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + for _ in range(5): + invoke_next(state) + + continuation_ids = state.source_prepared_mapping["continuation"].copy() + assert len(continuation_ids) == 1 + resumed = TypeAdapter(GraphExecutionState).validate_json( + state.model_dump_json(warnings=False, exclude_none=True), strict=False + ) + assert resumed.source_prepared_mapping["continuation"] == continuation_ids + + execute_all_nodes(resumed) + + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in resumed.prepared_source_mapping.items() + if source_node_id == "after" + ) + assert resumed.results[after_exec_id].value == [(["a"], ["a"]), (["b"], ["b"])] + assert resumed.is_complete() + + +def test_graph_deeper_nested_for_failure_does_not_release_final_outputs(): + graph = Graph() + graph.add_node(ForInvocation(id="outer_for", collection=[[["a"]]])) + graph.add_node(AnyCollectionFromValueTestInvocation(id="inner_collection")) + graph.add_node(ForInvocation(id="inner_for")) + graph.add_node(AnyCollectionFromValueTestInvocation(id="leaf_collection")) + graph.add_node(ForInvocation(id="leaf_for")) + graph.add_node(AnyTypeTestInvocation(id="leaf_body")) + graph.add_node(ForReturnInvocation(id="leaf_return")) + graph.add_node(ForReturnInvocation(id="inner_return")) + graph.add_node(ForReturnInvocation(id="outer_return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + + graph.add_edge(create_edge("outer_for", "item", "inner_collection", "value")) + graph.add_edge(create_edge("inner_collection", "collection", "inner_for", "collection")) + graph.add_edge(create_edge("inner_for", "item", "leaf_collection", "value")) + graph.add_edge(create_edge("leaf_collection", "collection", "leaf_for", "collection")) + graph.add_edge(create_edge("leaf_for", "item", "leaf_body", "value")) + graph.add_edge(create_edge("leaf_body", "value", "leaf_return", "output")) + graph.add_edge(create_edge("leaf_for", "output_collection", "inner_return", "output")) + graph.add_edge(create_edge("inner_for", "output_collection", "outer_return", "output")) + graph.add_edge(create_edge("outer_for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + while True: + node = state.next() + assert node is not None + if state.prepared_source_mapping[node.id] == "leaf_body": + state.set_node_error(node.id, "deeply nested body failed") + break + state.complete(node.id, node.invoke(Mock(InvocationContext))) + + assert state.has_error() + assert state.next() is None + assert "after" not in state.source_prepared_mapping + assert not any( + exec_node_id in state.results for exec_node_id in state.source_prepared_mapping.get("outer_return", set()) + ) + + +def test_graph_for_nested_iterate_scopes_under_parent_iterator(): + graph = Graph() + graph.add_node(NestedAnyCollectionTestInvocation(id="source", collection=[[["a", "b"], ["c"]], [["d", "e"]]])) + graph.add_node(IterateInvocation(id="parent_iterate")) + graph.add_node(ForInvocation(id="for")) + graph.add_node(PolymorphicStringTestInvocation(id="collection_adapter")) + graph.add_node(IterateInvocation(id="nested_iterate")) + graph.add_node(AnyTypeTestInvocation(id="body")) + graph.add_node(CollectInvocation(id="collect")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(CollectInvocation(id="after")) + graph.add_edge(create_edge("source", "collection", "parent_iterate", "collection")) + graph.add_edge(create_edge("parent_iterate", "item", "for", "collection")) + graph.add_edge(create_edge("for", "item", "collection_adapter", "value")) + graph.add_edge(create_edge("collection_adapter", "collection", "nested_iterate", "collection")) + graph.add_edge(create_edge("nested_iterate", "item", "body", "value")) + graph.add_edge(create_edge("body", "value", "collect", "item")) + graph.add_edge(create_edge("collect", "collection", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "item")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + + after_exec_ids = sorted(state.source_prepared_mapping["after"], key=state._get_iteration_path) + assert [state._get_iteration_path(exec_node_id) for exec_node_id in after_exec_ids] == [(0,), (1,)] + assert [state.results[exec_node_id].collection for exec_node_id in after_exec_ids] == [ + [[["a", "b"], ["c"]]], + [[["d", "e"]]], + ] + assert state.is_complete() + + +def test_graph_for_nested_iterate_mixed_empty_groups_under_parent_iterator(): + graph = Graph() + graph.add_node(NestedAnyCollectionTestInvocation(id="source", collection=[[[], [1]], [[2]]])) + graph.add_node(IterateInvocation(id="parent_iterate")) + graph.add_node(ForInvocation(id="for")) + graph.add_node(AnyCollectionFromValueTestInvocation(id="collection_adapter")) + graph.add_node(IterateInvocation(id="nested_iterate")) + graph.add_node(AnyTypeTestInvocation(id="body")) + graph.add_node(CollectInvocation(id="collect")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(CollectInvocation(id="after")) + graph.add_edge(create_edge("source", "collection", "parent_iterate", "collection")) + graph.add_edge(create_edge("parent_iterate", "item", "for", "collection")) + graph.add_edge(create_edge("for", "item", "collection_adapter", "value")) + graph.add_edge(create_edge("collection_adapter", "collection", "nested_iterate", "collection")) + graph.add_edge(create_edge("nested_iterate", "item", "body", "value")) + graph.add_edge(create_edge("body", "value", "collect", "item")) + graph.add_edge(create_edge("collect", "collection", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "item")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + + after_exec_ids = sorted(state.source_prepared_mapping["after"], key=state._get_iteration_path) + assert [state._get_iteration_path(exec_node_id) for exec_node_id in after_exec_ids] == [(0,), (1,)] + assert [state.results[exec_node_id].collection for exec_node_id in after_exec_ids] == [ + [[[], [1]]], + [[[2]]], + ] + assert state.is_complete() + + +def test_graph_for_nested_iterate_failure_does_not_release_final_outputs(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=[["a", "b"], ["c", "d"]])) + graph.add_node(PolymorphicStringTestInvocation(id="collection_adapter")) + graph.add_node(IterateInvocation(id="nested_iterate")) + graph.add_node(AnyTypeTestInvocation(id="body")) + graph.add_node(CollectInvocation(id="collect")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "collection_adapter", "value")) + graph.add_edge(create_edge("collection_adapter", "collection", "nested_iterate", "collection")) + graph.add_edge(create_edge("nested_iterate", "item", "body", "value")) + graph.add_edge(create_edge("body", "value", "collect", "item")) + graph.add_edge(create_edge("collect", "collection", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + _for_node, _for_output = invoke_next(state) + _adapter_node, _adapter_output = invoke_next(state) + _inner_node, _inner_output = invoke_next(state) + _inner_node, _inner_output = invoke_next(state) + body_node = state.next() + assert isinstance(body_node, AnyTypeTestInvocation) + + state.set_node_error(body_node.id, "nested body failed") + + assert state.has_error() + assert state.next() is None + assert "after" not in state.source_prepared_mapping + assert not any(exec_node_id in state.results for exec_node_id in state.source_prepared_mapping.get("return", set())) + + +def test_graph_for_rematerialized_body_carries_returned_state(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta"])) + graph.add_node(AnyTypeTestInvocation(id="body")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_edge(create_edge("for", "item", "body", "value")) + graph.add_edge(create_edge("body", "value", "return", "output")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + for_0 = state.next() + assert isinstance(for_0, ForInvocation) + state.complete(for_0.id, for_0.invoke(Mock(InvocationContext))) + body_0 = state.next() + assert isinstance(body_0, AnyTypeTestInvocation) + state.complete(body_0.id, body_0.invoke(Mock(InvocationContext))) + return_0 = state.next() + assert isinstance(return_0, ForReturnInvocation) + state.complete(return_0.id, ForReturnInvocationOutput(output="alpha", state=LoopState(values={"count": 1}))) + + for_1 = state.next() + + assert isinstance(for_1, ForInvocation) + assert for_1.state == LoopState(values={"count": 1}) + + +def test_graph_for_iteration_does_not_deep_copy_collection_twice(): + class DeepCopyCounter: + copies = 0 + + def __deepcopy__(self, memo): + type(self).copies += 1 + return self + + item = DeepCopyCounter() + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=[item, "last"])) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_edge(create_edge("for", "item", "return", "output")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + for_0 = state.next() + assert isinstance(for_0, ForInvocation) + DeepCopyCounter.copies = 0 + state.complete(for_0.id, for_0.invoke(Mock(InvocationContext))) + return_0 = state.next() + assert isinstance(return_0, ForReturnInvocation) + state.complete(return_0.id, ForReturnInvocationOutput(output="first", state=LoopState())) + + for_1 = state.next() + assert isinstance(for_1, ForInvocation) + assert for_1.collection[0] is item + assert DeepCopyCounter.copies == 2 + + +def test_graph_for_body_state_helper_updates_state_for_next_iteration_and_final_output(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta", "charlie"])) + graph.add_node(StateSetInvocation(id="state_set", key="last_item")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "state", "state_set", "state")) + graph.add_edge(create_edge("for", "item", "state_set", "value")) + graph.add_edge(create_edge("state_set", "state", "return", "state")) + graph.add_edge(create_edge("for", "final_state", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + executed_source_ids = execute_all_nodes(state) + + assert executed_source_ids == [ + "for", + "state_set", + "return", + "for", + "state_set", + "return", + "for", + "state_set", + "return", + "after", + ] + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "after" + ) + assert state.results[after_exec_id].value == LoopState(values={"last_item": "charlie"}) + assert state.is_complete() + + +def test_graph_for_body_state_helper_return_state_is_visible_to_next_iteration(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta"])) + graph.add_node(StateSetInvocation(id="state_set", key="last_item")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "state", "return", "output")) + graph.add_edge(create_edge("for", "state", "state_set", "state")) + graph.add_edge(create_edge("for", "item", "state_set", "value")) + graph.add_edge(create_edge("state_set", "state", "return", "state")) + graph.add_edge(create_edge("for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "after" + ) + + assert state.results[after_exec_id].value == [ + LoopState(), + LoopState(values={"last_item": "alpha"}), + ] + assert state.is_complete() + + +@pytest.mark.parametrize( + ("completed_count", "expected_remaining_source_ids"), + [ + (1, ["state_set", "return", "for", "state_set", "return", "after"]), + (2, ["return", "for", "state_set", "return", "after"]), + (3, ["for", "state_set", "return", "after"]), + ], +) +def test_graph_for_partially_completed_stateful_loop_resumes_after_serialization( + completed_count: int, expected_remaining_source_ids: list[str] +) -> None: + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta"])) + graph.add_node(StateSetInvocation(id="state_set", key="last_item")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "state", "state_set", "state")) + graph.add_edge(create_edge("for", "item", "state_set", "value")) + graph.add_edge(create_edge("state_set", "state", "return", "state")) + graph.add_edge(create_edge("for", "final_state", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + for _ in range(completed_count): + invoke_next(state) + + raw = state.model_dump_json(warnings=False, exclude_none=True) + resumed = TypeAdapter(GraphExecutionState).validate_json(raw, strict=False) + registry = resumed._prepared_registry() + executed_source_ids = execute_all_nodes(resumed) + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in resumed.prepared_source_mapping.items() + if source_node_id == "after" + ) + + assert all( + registry.get_iteration_path(exec_node_id) is not None for exec_node_id in resumed.prepared_source_mapping + ) + assert executed_source_ids == expected_remaining_source_ids + assert resumed.results[after_exec_id].value == LoopState(values={"last_item": "beta"}) + assert resumed.is_complete() + + +def test_graph_for_rematerialized_body_cache_keys_overlap_for_matching_item_inputs(): + def execute_loop_and_get_body_cache_keys(collection: list[int]) -> dict[int, int]: + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=collection)) + graph.add_node(StateSetInvocation(id="state_set", key="item")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_edge(create_edge("for", "item", "state_set", "value")) + graph.add_edge(create_edge("state_set", "state", "return", "state")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + + return { + state.execution_graph.get_node(exec_node_id).value: MemoryInvocationCache.create_key( + state.execution_graph.get_node(exec_node_id) + ) + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "state_set" + } + + first_keys = execute_loop_and_get_body_cache_keys([0, 1, 4, 5]) + second_keys = execute_loop_and_get_body_cache_keys([0, 2, 5, 7]) + + assert first_keys[0] == second_keys[0] + assert first_keys[5] == second_keys[5] + assert first_keys[1] not in second_keys.values() + assert first_keys[4] not in second_keys.values() + + +def test_graph_for_rematerialized_body_cache_keys_include_loop_state_inputs(): + def execute_loop_and_get_body_cache_keys(collection: list[int]) -> dict[int, int]: + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=collection)) + graph.add_node(StateSetInvocation(id="state_set", key="item")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_edge(create_edge("for", "state", "state_set", "state")) + graph.add_edge(create_edge("for", "item", "state_set", "value")) + graph.add_edge(create_edge("state_set", "state", "return", "state")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + + return { + state.execution_graph.get_node(exec_node_id).value: MemoryInvocationCache.create_key( + state.execution_graph.get_node(exec_node_id) + ) + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "state_set" + } + + first_keys = execute_loop_and_get_body_cache_keys([0, 5]) + second_keys = execute_loop_and_get_body_cache_keys([1, 5]) + + assert first_keys[5] != second_keys[5] + + +def test_graph_for_body_failure_stops_loop_without_releasing_final_outputs(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta"])) + graph.add_node(AnyTypeTestInvocation(id="body")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(TwoAnyTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "body", "value")) + graph.add_edge(create_edge("body", "value", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "first")) + graph.add_edge(create_edge("for", "final_state", "after", "second")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + _for_node, _for_output = invoke_next(state) + body_node = state.next() + assert isinstance(body_node, AnyTypeTestInvocation) + + state.set_node_error(body_node.id, "body failed") + + assert state.has_error() + assert state.next() is None + assert "after" not in state.source_prepared_mapping + assert state.source_prepared_mapping["for"] == {_for_node.id} + assert not any(exec_node_id in state.results for exec_node_id in state.source_prepared_mapping.get("return", set())) + + +def test_graph_for_return_failure_stops_loop_without_releasing_final_outputs(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta"])) + graph.add_node(AnyTypeTestInvocation(id="body")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(TwoAnyTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "body", "value")) + graph.add_edge(create_edge("body", "value", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "first")) + graph.add_edge(create_edge("for", "final_state", "after", "second")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + _for_node, _for_output = invoke_next(state) + _body_node, _body_output = invoke_next(state) + return_node = state.next() + assert isinstance(return_node, ForReturnInvocation) + + state.set_node_error(return_node.id, "return failed") + + assert state.has_error() + assert state.next() is None + assert "after" not in state.source_prepared_mapping + assert len(state.source_prepared_mapping["for"]) == 1 + assert not any(exec_node_id in state.results for exec_node_id in state.source_prepared_mapping.get("return", set())) + + +def test_graph_for_failure_after_successful_iteration_does_not_release_partial_outputs(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta"])) + graph.add_node(AnyTypeTestInvocation(id="body")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(TwoAnyTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "body", "value")) + graph.add_edge(create_edge("body", "value", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "first")) + graph.add_edge(create_edge("for", "final_state", "after", "second")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + _for_0, _for_output_0 = invoke_next(state) + _body_0, _body_output_0 = invoke_next(state) + return_0 = state.next() + assert isinstance(return_0, ForReturnInvocation) + state.complete(return_0.id, ForReturnInvocationOutput(output="alpha", state=LoopState(values={"count": 1}))) + _for_1, _for_output_1 = invoke_next(state) + body_1 = state.next() + assert isinstance(body_1, AnyTypeTestInvocation) + + state.set_node_error(body_1.id, "second body failed") + + assert state.has_error() + assert state.next() is None + assert "after" not in state.source_prepared_mapping + assert sum(exec_node_id in state.results for exec_node_id in state.source_prepared_mapping["return"]) == 1 + + +def test_graph_for_failure_state_round_trip_does_not_resume_loop_or_release_final_outputs(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta"])) + graph.add_node(AnyTypeTestInvocation(id="body")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(TwoAnyTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "body", "value")) + graph.add_edge(create_edge("body", "value", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "first")) + graph.add_edge(create_edge("for", "final_state", "after", "second")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + _for_node, _for_output = invoke_next(state) + body_node = state.next() + assert isinstance(body_node, AnyTypeTestInvocation) + state.set_node_error(body_node.id, "body failed") + + raw = state.model_dump_json(warnings=False, exclude_none=True) + resumed = TypeAdapter(GraphExecutionState).validate_json(raw, strict=False) + + assert resumed.has_error() + assert resumed.next() is None + assert "after" not in resumed.source_prepared_mapping + assert not any( + exec_node_id in resumed.results for exec_node_id in resumed.source_prepared_mapping.get("return", set()) + ) + + +def test_graph_for_rematerialized_body_reuses_external_input_each_iteration(): + graph = Graph() + graph.add_node(PromptTestInvocation(id="external", prompt="shared")) + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta"])) + graph.add_node(TwoAnyTestInvocation(id="body")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "body", "first")) + graph.add_edge(create_edge("external", "prompt", "body", "second")) + graph.add_edge(create_edge("body", "value", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + executed_source_ids = execute_all_nodes(state) + + assert executed_source_ids.count("external") == 1 + assert executed_source_ids.count("for") == 2 + assert executed_source_ids.count("body") == 2 + assert executed_source_ids.count("return") == 2 + assert executed_source_ids[-1] == "after" + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "after" + ) + assert state.results[after_exec_id].value == [("alpha", "shared"), ("beta", "shared")] + assert state.is_complete() + + +def test_graph_for_output_collection_is_scoped_to_parent_iterator_context(): + graph = Graph() + graph.add_node(NestedAnyCollectionTestInvocation(id="nested", collection=[["alpha"], ["beta"]])) + graph.add_node(IterateInvocation(id="outer_iterate")) + graph.add_node(ForInvocation(id="for")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(CollectInvocation(id="collect")) + graph.add_edge(create_edge("nested", "collection", "outer_iterate", "collection")) + graph.add_edge(create_edge("outer_iterate", "item", "for", "collection")) + graph.add_edge(create_edge("for", "item", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "collect", "item")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + collect_exec_ids = sorted( + ( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "collect" + ), + key=state._get_iteration_path, + ) + + assert [state.results[exec_node_id].collection for exec_node_id in collect_exec_ids] == [ + [["alpha"]], + [["beta"]], + ] + + +def test_graph_for_output_collection_preserves_multiple_items_per_parent_iterator_context(): + graph = Graph() + graph.add_node(NestedAnyCollectionTestInvocation(id="nested", collection=[["alpha", "beta"], ["gamma"]])) + graph.add_node(IterateInvocation(id="outer_iterate")) + graph.add_node(ForInvocation(id="for")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(CollectInvocation(id="collect")) + graph.add_edge(create_edge("nested", "collection", "outer_iterate", "collection")) + graph.add_edge(create_edge("outer_iterate", "item", "for", "collection")) + graph.add_edge(create_edge("for", "item", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "collect", "item")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + collect_exec_ids = sorted( + ( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "collect" + ), + key=state._get_iteration_path, + ) + + assert [state._get_iteration_path(exec_node_id) for exec_node_id in collect_exec_ids] == [(0,), (1,)] + assert [state.results[exec_node_id].collection for exec_node_id in collect_exec_ids] == [ + [["alpha", "beta"]], + [["gamma"]], + ] + + +def test_graph_for_does_not_release_final_outputs_until_each_parent_context_finishes(): + graph = Graph() + graph.add_node(NestedAnyCollectionTestInvocation(id="nested", collection=[["alpha"], ["beta", "gamma"]])) + graph.add_node(IterateInvocation(id="outer_iterate")) + graph.add_node(ForInvocation(id="for")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(CollectInvocation(id="collect")) + graph.add_edge(create_edge("nested", "collection", "outer_iterate", "collection")) + graph.add_edge(create_edge("outer_iterate", "item", "for", "collection")) + graph.add_edge(create_edge("for", "item", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "collect", "item")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + while len(state.finalized_loop_contexts) < 1: + invocation, output = invoke_next(state) + assert invocation is not None + assert output is not None + + assert len(state.finalized_loop_contexts) == 1 + assert "collect" not in state.source_prepared_mapping + + resumed = TypeAdapter(GraphExecutionState).validate_json(state.model_dump_json(warnings=False), strict=False) + assert resumed.finalized_loop_contexts == state.finalized_loop_contexts + assert "collect" not in resumed.source_prepared_mapping + + execute_all_nodes(resumed) + collect_exec_ids = sorted( + ( + exec_node_id + for exec_node_id, source_node_id in resumed.prepared_source_mapping.items() + if source_node_id == "collect" + ), + key=resumed._get_iteration_path, + ) + assert [resumed.results[exec_node_id].collection for exec_node_id in collect_exec_ids] == [ + [["alpha"]], + [["beta", "gamma"]], + ] + + +def test_graph_for_final_state_is_scoped_to_parent_iterator_context(): + graph = Graph() + graph.add_node(NestedAnyCollectionTestInvocation(id="nested", collection=[["alpha"], ["beta"]])) + graph.add_node(IterateInvocation(id="outer_iterate")) + graph.add_node(ForInvocation(id="for")) + graph.add_node(StateSetInvocation(id="state_set", key="item")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(CollectInvocation(id="collect")) + graph.add_edge(create_edge("nested", "collection", "outer_iterate", "collection")) + graph.add_edge(create_edge("outer_iterate", "item", "for", "collection")) + graph.add_edge(create_edge("for", "state", "state_set", "state")) + graph.add_edge(create_edge("for", "item", "state_set", "value")) + graph.add_edge(create_edge("state_set", "state", "return", "state")) + graph.add_edge(create_edge("for", "final_state", "collect", "item")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + collect_exec_ids = sorted( + ( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "collect" + ), + key=state._get_iteration_path, + ) + + assert [state.results[exec_node_id].collection for exec_node_id in collect_exec_ids] == [ + [LoopState(values={"item": "alpha"})], + [LoopState(values={"item": "beta"})], + ] + + +def test_graph_for_final_output_collection_materializes_after_last_direct_return(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta"])) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + _for_0, _for_output_0 = invoke_next(state) + _return_0, _return_output_0 = invoke_next(state) + _for_1, _for_output_1 = invoke_next(state) + _return_1, _return_output_1 = invoke_next(state) + + after_node = state.next() + + assert isinstance(after_node, AnyTypeTestInvocation) + assert after_node.value == ["alpha", "beta"] + + +def test_graph_for_final_state_materializes_after_last_direct_return(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta"])) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "return", "output")) + graph.add_edge(create_edge("for", "final_state", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + for_0 = state.next() + assert isinstance(for_0, ForInvocation) + state.complete(for_0.id, for_0.invoke(Mock(InvocationContext))) + return_0 = state.next() + assert isinstance(return_0, ForReturnInvocation) + state.complete(return_0.id, ForReturnInvocationOutput(output="alpha", state=LoopState(values={"count": 1}))) + for_1 = state.next() + assert isinstance(for_1, ForInvocation) + state.complete(for_1.id, for_1.invoke(Mock(InvocationContext))) + return_1 = state.next() + assert isinstance(return_1, ForReturnInvocation) + state.complete(return_1.id, ForReturnInvocationOutput(output="beta", state=LoopState(values={"count": 2}))) + + after_node = state.next() + + assert isinstance(after_node, AnyTypeTestInvocation) + assert after_node.value == LoopState(values={"count": 2}) + + +def test_graph_for_return_can_break_early_and_release_final_outputs(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta", "charlie"])) + graph.add_node(ForReturnInvocation(id="return", continue_condition=False)) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + for_node, _for_output = invoke_next(state) + assert isinstance(for_node, ForInvocation) + return_node, return_output = invoke_next(state) + assert isinstance(return_node, ForReturnInvocation) + assert isinstance(return_output, ForReturnInvocationOutput) + assert return_output.output == "alpha" + + after_node = state.next() + + assert isinstance(after_node, AnyTypeTestInvocation) + assert after_node.value == ["alpha"] + assert not any( + isinstance(state.execution_graph.get_node(exec_node_id), ForInvocation) + and state.execution_graph.get_node(exec_node_id).index == 1 + for exec_node_id in state.source_prepared_mapping["for"] + ) + + state.complete(after_node.id, after_node.invoke(Mock(InvocationContext))) + assert state.is_complete() + + +def test_graph_for_return_evaluates_connected_break_condition_for_each_iteration(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "stop", "charlie"])) + graph.add_node(ContinueOnValueTestInvocation(id="condition")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "condition", "value")) + graph.add_edge(create_edge("condition", "value", "return", "continue_condition")) + graph.add_edge(create_edge("for", "item", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + executed_source_ids: list[str] = [] + while True: + node = state.next() + assert node is not None + source_id = state.prepared_source_mapping[node.id] + if source_id == "after": + after_node = node + break + executed_source_ids.append(source_id) + state.complete(node.id, node.invoke(Mock(InvocationContext))) + + assert executed_source_ids.count("condition") == 2 + assert executed_source_ids.count("return") == 2 + assert isinstance(after_node, AnyTypeTestInvocation) + assert after_node.value == ["alpha", "stop"] + state.complete(after_node.id, after_node.invoke(Mock(InvocationContext))) + + +def test_graph_for_return_early_break_survives_resume_with_returned_state(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta"], state=LoopState(values={"count": 0}))) + graph.add_node(StateSetInvocation(id="state_set", key="count", value=1)) + graph.add_node(ForReturnInvocation(id="return", continue_condition=False)) + graph.add_node(TwoAnyTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "return", "output")) + graph.add_edge(create_edge("for", "state", "state_set", "state")) + graph.add_edge(create_edge("state_set", "state", "return", "state")) + graph.add_edge(create_edge("for", "output_collection", "after", "first")) + graph.add_edge(create_edge("for", "final_state", "after", "second")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + _for_node, _for_output = invoke_next(state) + _state_set_node, _state_set_output = invoke_next(state) + _return_node, _return_output = invoke_next(state) + + resumed = TypeAdapter(GraphExecutionState).validate_json(state.model_dump_json(warnings=False), strict=False) + after_node = resumed.next() + + assert isinstance(after_node, TwoAnyTestInvocation) + assert after_node.first == ["alpha"] + assert after_node.second == LoopState(values={"count": 1}) + resumed.complete(after_node.id, after_node.invoke(Mock(InvocationContext))) + assert resumed.is_complete() + + +def test_graph_for_empty_collection_materializes_final_outputs(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=[], state=LoopState(values={"initial": True}))) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(TwoAnyTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "first")) + graph.add_edge(create_edge("for", "final_state", "after", "second")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + after_node = state.next() + + assert isinstance(after_node, TwoAnyTestInvocation) + assert after_node.first == [] + assert after_node.second == LoopState(values={"initial": True}) + assert "return" not in state.source_prepared_mapping + state.complete(after_node.id, after_node.invoke(Mock(InvocationContext))) + + assert state.is_complete() + + +def test_graph_for_empty_collection_round_trips_without_optional_item(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=[])) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + after_node = state.next() + assert isinstance(after_node, AnyTypeTestInvocation) + + resumed = TypeAdapter(GraphExecutionState).validate_json( + state.model_dump_json(warnings=False, exclude_none=True), strict=False + ) + resumed_after_node = resumed.next() + + assert isinstance(resumed_after_node, AnyTypeTestInvocation) + assert resumed_after_node.value == [] + + +def test_graph_for_empty_collection_round_trips_missing_loop_state_value(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=[])) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(StateGetInvocation(id="get", key="missing")) + graph.add_edge(create_edge("for", "item", "return", "output")) + graph.add_edge(create_edge("for", "final_state", "get", "state")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + get_node = state.next() + assert isinstance(get_node, StateGetInvocation) + state.complete(get_node.id, get_node.invoke(Mock(InvocationContext))) + + resumed = TypeAdapter(GraphExecutionState).validate_json( + state.model_dump_json(warnings=False, exclude_none=True), strict=False + ) + + assert resumed.results[get_node.id].value is None + + +def test_graph_for_empty_collection_preserves_connected_initial_state(): + graph = Graph() + graph.add_node(IntegerCollectionInvocation(id="collection", collection=[])) + graph.add_node(StateSetInvocation(id="initial_state", key="initial", value=True)) + graph.add_node(ForInvocation(id="for")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(TwoAnyTestInvocation(id="after")) + graph.add_edge(create_edge("collection", "collection", "for", "collection")) + graph.add_edge(create_edge("initial_state", "state", "for", "state")) + graph.add_edge(create_edge("for", "item", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "first")) + graph.add_edge(create_edge("for", "final_state", "after", "second")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "after" + ) + + assert state.results[after_exec_id].value == ([], LoopState(values={"initial": True})) + assert state.is_complete() + + +def test_graph_for_independent_empty_and_nonempty_final_outputs_join_correctly(): + graph = Graph() + graph.add_node(ForInvocation(id="empty_for", collection=[])) + graph.add_node(ForInvocation(id="nonempty_for", collection=["alpha", "beta"])) + graph.add_node(ForReturnInvocation(id="empty_return")) + graph.add_node(ForReturnInvocation(id="nonempty_return")) + graph.add_node(TwoAnyTestInvocation(id="after")) + graph.add_edge(create_edge("empty_for", "item", "empty_return", "output")) + graph.add_edge(create_edge("nonempty_for", "item", "nonempty_return", "output")) + graph.add_edge(create_edge("empty_for", "output_collection", "after", "first")) + graph.add_edge(create_edge("nonempty_for", "output_collection", "after", "second")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "after" + ) + + assert state.results[after_exec_id].value == ([], ["alpha", "beta"]) + assert state.is_complete() + + +def test_graph_for_nested_parent_context_survives_state_round_trip(): + graph = Graph() + graph.add_node(NestedAnyCollectionTestInvocation(id="nested", collection=[["alpha", "beta"], ["gamma"]])) + graph.add_node(IterateInvocation(id="outer_iterate")) + graph.add_node(ForInvocation(id="for")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(CollectInvocation(id="collect")) + graph.add_edge(create_edge("nested", "collection", "outer_iterate", "collection")) + graph.add_edge(create_edge("outer_iterate", "item", "for", "collection")) + graph.add_edge(create_edge("for", "item", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "collect", "item")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + for _ in range(5): + invocation, output = invoke_next(state) + assert invocation is not None + assert output is not None + + resumed = TypeAdapter(GraphExecutionState).validate_json(state.model_dump_json(warnings=False), strict=False) + execute_all_nodes(resumed) + collect_exec_ids = sorted( + ( + exec_node_id + for exec_node_id, source_node_id in resumed.prepared_source_mapping.items() + if source_node_id == "collect" + ), + key=resumed._get_iteration_path, + ) + + assert [resumed._get_iteration_path(exec_node_id) for exec_node_id in collect_exec_ids] == [(0,), (1,)] + assert [resumed.results[exec_node_id].collection for exec_node_id in collect_exec_ids] == [ + [["alpha", "beta"]], + [["gamma"]], + ] + + +def test_graph_for_empty_and_nonempty_parent_iterator_contexts_both_finalize(): + graph = Graph() + graph.add_node(NestedAnyCollectionTestInvocation(id="nested", collection=[[], ["alpha"]])) + graph.add_node(IterateInvocation(id="outer_iterate")) + graph.add_node(ForInvocation(id="for")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(CollectInvocation(id="collect")) + graph.add_edge(create_edge("nested", "collection", "outer_iterate", "collection")) + graph.add_edge(create_edge("outer_iterate", "item", "for", "collection")) + graph.add_edge(create_edge("for", "item", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "collect", "item")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + collect_exec_ids = sorted( + ( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "collect" + ), + key=state._get_iteration_path, + ) + + assert [state.results[exec_node_id].collection for exec_node_id in collect_exec_ids] == [ + [[]], + [["alpha"]], + ] + assert state.is_complete() + + +def test_graph_for_under_empty_parent_iterator_collects_and_completes(): + graph = Graph() + graph.add_node(NestedAnyCollectionTestInvocation(id="nested", collection=[])) + graph.add_node(IterateInvocation(id="outer_iterate")) + graph.add_node(ForInvocation(id="for")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(CollectInvocation(id="collect")) + graph.add_edge(create_edge("nested", "collection", "outer_iterate", "collection")) + graph.add_edge(create_edge("outer_iterate", "item", "for", "collection")) + graph.add_edge(create_edge("for", "item", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "collect", "item")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + collect_exec_ids = [ + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "collect" + ] + + assert [state.results[exec_node_id].collection for exec_node_id in collect_exec_ids] == [[]] + assert state.is_complete() + + +def test_graph_for_empty_collection_with_indirect_body_completes_without_body_execution(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=[])) + graph.add_node(AnyTypeTestInvocation(id="body")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "body", "value")) + graph.add_edge(create_edge("body", "value", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + after_node = state.next() + + assert isinstance(after_node, AnyTypeTestInvocation) + assert after_node.value == [] + assert "body" not in state.source_prepared_mapping + assert "return" not in state.source_prepared_mapping + state.complete(after_node.id, after_node.invoke(Mock(InvocationContext))) + + assert state.is_complete() + + +def test_graph_for_return_omitted_output_is_not_collected(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta"])) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "state", "return", "state")) + graph.add_edge(create_edge("for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "after" + ) + + assert state.results[after_exec_id].value == [] + + +def test_graph_for_multiple_final_edges_to_same_node_do_not_crash(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha"])) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(TwoAnyTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "first")) + graph.add_edge(create_edge("for", "final_state", "after", "second")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + _for_node, _for_output = invoke_next(state) + _return_node, _return_output = invoke_next(state) + after_node = state.next() + + assert isinstance(after_node, TwoAnyTestInvocation) + assert after_node.first == ["alpha"] + assert after_node.second == LoopState() + state.complete(after_node.id, after_node.invoke(Mock(InvocationContext))) + + assert state.is_complete() + + def test_graph_is_complete(simple_graph: Graph): g = GraphExecutionState(graph=simple_graph) _ = invoke_next(g) diff --git a/tests/test_node_graph.py b/tests/test_node_graph.py index 93e198b3763..c49ab16a990 100644 --- a/tests/test_node_graph.py +++ b/tests/test_node_graph.py @@ -4,6 +4,7 @@ import sys import textwrap from pathlib import Path +from typing import Any import pytest from pydantic import TypeAdapter, ValidationError @@ -16,12 +17,15 @@ invocation, invocation_output, ) +from invokeai.app.invocations.fields import InputField, OutputField, OutputScope +from invokeai.app.invocations.loops import ForInvocation, ForReturnInvocation from invokeai.app.invocations.math import AddInvocation from invokeai.app.invocations.primitives import ( ColorInvocation, FloatCollectionInvocation, FloatInvocation, IntegerInvocation, + StringCollectionInvocation, StringInvocation, ) from invokeai.app.invocations.upscale import ESRGANInvocation @@ -37,6 +41,7 @@ NodeAlreadyInGraphError, NodeNotFoundError, are_connections_compatible, + get_output_field_scope, ) from tests.test_nodes import ( AnyTypeTestInvocation, @@ -61,7 +66,51 @@ def create_edge(from_id: str, from_field: str, to_id: str, to_field: str) -> Edg ) +def create_loop_linkage(from_id: str, to_id: str) -> Edge: + return Edge( + type="loop_linkage", + source=EdgeConnection(node_id=from_id, field="loop_linkage"), + destination=EdgeConnection(node_id=to_id, field="loop_linkage"), + ) + + +@invocation_output("test_scoped_output") +class ScopedTestInvocationOutput(BaseInvocationOutput): + iteration_value: str = OutputField(output_scope=OutputScope.Iteration) + final_value: str = OutputField(output_scope=OutputScope.Final) + ordinary_value: str = OutputField() + + +@invocation("test_scoped", version="1.0.0") +class ScopedTestInvocation(BaseInvocation): + def invoke(self) -> ScopedTestInvocationOutput: + return ScopedTestInvocationOutput(iteration_value="iteration", final_value="final", ordinary_value="ordinary") + + +@invocation_output("test_two_any_graph_output") +class TwoAnyGraphTestInvocationOutput(BaseInvocationOutput): + value: Any = OutputField() + + +@invocation("test_two_any_graph", version="1.0.0") +class TwoAnyGraphTestInvocation(BaseInvocation): + first: Any = InputField(default=None) + second: Any = InputField(default=None) + + def invoke(self) -> TwoAnyGraphTestInvocationOutput: + return TwoAnyGraphTestInvocationOutput(value=(self.first, self.second)) + + # Tests +def test_get_output_field_scope_reads_scoped_output_metadata(): + node = ScopedTestInvocation(id="1") + + assert get_output_field_scope(node, "iteration_value") == OutputScope.Iteration + assert get_output_field_scope(node, "final_value") == OutputScope.Final + assert get_output_field_scope(node, "ordinary_value") is None + assert get_output_field_scope(node, "missing_value") is None + + def test_connections_are_compatible(): from_node = TextToImageTestInvocation(id="1", prompt="Banana sushi") from_field = "image" @@ -73,6 +122,713 @@ def test_connections_are_compatible(): assert result is True +def test_graph_validates_direct_for_boundary_pair(): + g = Graph() + loop = ForInvocation(id="for", collection=["a", "b"]) + body_return = ForReturnInvocation(id="return") + + g.add_node(loop) + g.add_node(body_return) + g.add_edge(create_edge(loop.id, "item", body_return.id, "output")) + g.add_edge(create_loop_linkage(loop.id, body_return.id)) + + g.validate_self() + + +def test_graph_validates_for_boundary_pair_with_loop_linkage(): + g = Graph() + loop = ForInvocation(id="for", collection=["a", "b"]) + body_return = ForReturnInvocation(id="return") + + g.add_node(loop) + g.add_node(body_return) + g.add_edge(create_edge(loop.id, "item", body_return.id, "output")) + g.add_edge( + Edge( + type="loop_linkage", + source=EdgeConnection(node_id=loop.id, field="loop_linkage"), + destination=EdgeConnection(node_id=body_return.id, field="loop_linkage"), + ) + ) + + g.validate_self() + + +def test_graph_round_trips_for_loop_linkage(): + g = Graph() + loop = ForInvocation(id="for", collection=["a", "b"]) + body_return = ForReturnInvocation(id="return") + + g.add_node(loop) + g.add_node(body_return) + g.add_edge(create_edge(loop.id, "item", body_return.id, "output")) + g.add_edge(create_loop_linkage(loop.id, body_return.id)) + + restored = Graph.model_validate_json(g.model_dump_json()) + + assert restored.edges[-1].type == "loop_linkage" + assert restored.edges[-1].source.node_id == loop.id + assert restored.edges[-1].destination.node_id == body_return.id + + +def test_graph_rejects_invalid_loop_linkage_endpoints(): + g = Graph() + source = ForInvocation(id="source", collection=["a"]) + body_return = ForReturnInvocation(id="return") + + g.add_node(source) + g.add_node(body_return) + g.edges.append( + Edge( + type="loop_linkage", + source=EdgeConnection(node_id=source.id, field="item"), + destination=EdgeConnection(node_id=body_return.id, field="loop_linkage"), + ) + ) + with pytest.raises(InvalidEdgeError, match="Invalid loop linkage"): + g.validate_self() + + +def test_graph_rejects_default_edges_using_loop_linkage_fields(): + g = Graph() + loop = ForInvocation(id="for", collection=["a"]) + body_return = ForReturnInvocation(id="return") + + g.add_node(loop) + g.add_node(body_return) + g.edges.extend( + [ + create_loop_linkage(loop.id, body_return.id), + create_edge(loop.id, "item", body_return.id, "loop_linkage"), + ] + ) + + with pytest.raises(InvalidEdgeError, match="must use a loop_linkage edge"): + g.validate_self() + + +def test_graph_rejects_duplicate_loop_linkage(): + g = Graph() + first_loop = ForInvocation(id="first", collection=["a"]) + second_loop = ForInvocation(id="second", collection=["b"]) + body_return = ForReturnInvocation(id="return") + + g.add_node(first_loop) + g.add_node(second_loop) + g.add_node(body_return) + g.edges.extend( + [ + create_loop_linkage(first_loop.id, body_return.id), + create_loop_linkage(second_loop.id, body_return.id), + ] + ) + with pytest.raises(InvalidEdgeError, match="exactly one loop linkage"): + g.validate_self() + + +def test_graph_rejects_edge_to_for_scheduler_index(): + g = Graph() + index_source = IntegerInvocation(id="index_source", value=99) + loop = ForInvocation(id="for", collection=["a", "b"]) + body_return = ForReturnInvocation(id="return") + + g.add_node(index_source) + g.add_node(loop) + g.add_node(body_return) + g.edges.extend( + [ + create_edge(index_source.id, "value", loop.id, "index"), + create_edge(loop.id, "item", body_return.id, "output"), + create_loop_linkage(loop.id, body_return.id), + ] + ) + + with pytest.raises(InvalidEdgeError, match="direct input"): + g.validate_self() + + +@pytest.mark.parametrize( + ("node_type", "destination_field", "expected_message"), + [ + ("for", "collection", "For loop may have only one collection input edge"), + ("for", "state", "For loop may have only one state input edge"), + ("for_return", "output", "ForReturn may have only one input edge per field"), + ("for_return", "state", "ForReturn may have only one input edge per field"), + ("for_return", "continue_condition", "ForReturn may have only one input edge per field"), + ], +) +def test_graph_rejects_duplicate_loop_boundary_inputs(node_type, destination_field, expected_message): + g = Graph() + g.add_node(AnyTypeTestInvocation(id="first")) + g.add_node(AnyTypeTestInvocation(id="second")) + g.add_node(ForInvocation(id="for", collection=[1])) + g.add_node(ForReturnInvocation(id="return")) + g.edges.append(create_loop_linkage("for", "return")) + if node_type == "for_return": + g.edges.append(create_edge("for", "item", "return", "output")) + source_ids = ["first", "second"] + if destination_field == "state": + g.edges.append(create_edge("for", "state", "return", "state")) + source_ids = ["first", "second"] + else: + source_ids = ["first", "second"] + g.edges.extend( + create_edge(source_id, "value", "for" if node_type == "for" else "return", destination_field) + for source_id in source_ids + ) + + with pytest.raises(InvalidEdgeError, match=expected_message): + g.validate_self() + + +def test_graph_validates_nested_for_boundary_pair(): + g = Graph() + g.add_node(ForInvocation(id="outer", collection=[["a"]])) + g.add_node(AnyTypeTestInvocation(id="inner_collection")) + g.add_node(ForInvocation(id="inner")) + g.add_node(AnyTypeTestInvocation(id="inner_body")) + g.add_node(ForReturnInvocation(id="inner_return")) + g.add_node(ForReturnInvocation(id="outer_return")) + g.add_edge(create_edge("outer", "item", "inner_collection", "value")) + g.add_edge(create_edge("inner_collection", "value", "inner", "collection")) + g.add_edge(create_edge("inner", "item", "inner_body", "value")) + g.add_edge(create_edge("inner_body", "value", "inner_return", "output")) + g.add_edge(create_edge("inner", "output_collection", "outer_return", "output")) + g.add_edge(create_loop_linkage("inner", "inner_return")) + g.add_edge(create_loop_linkage("outer", "outer_return")) + + g.validate_self() + + +def test_for_body_path_resolution_uses_loop_linkage_for_ambiguous_reachable_returns(): + g = Graph() + loop = ForInvocation(id="for", collection=["a"]) + matching_return = ForReturnInvocation(id="matching-return") + other_return = ForReturnInvocation(id="other-return") + + g.add_node(loop) + g.add_node(matching_return) + g.add_node(other_return) + g.add_edge(create_edge(loop.id, "item", matching_return.id, "output")) + g.add_edge(create_edge(loop.id, "item", other_return.id, "output")) + g.add_edge(create_loop_linkage(loop.id, matching_return.id)) + + body_path_to_return = g._get_for_body_path_to_return(loop.id, g.nx_graph_flat()) + + assert body_path_to_return is not None + assert body_path_to_return[1] == matching_return.id + + +def test_for_body_path_resolution_rejects_missing_loop_linkage(): + g = Graph() + loop = ForInvocation(id="for", collection=["a"]) + first_return = ForReturnInvocation(id="first-return") + second_return = ForReturnInvocation(id="second-return") + + g.add_node(loop) + g.add_node(first_return) + g.add_node(second_return) + g.add_edge(create_edge(loop.id, "item", first_return.id, "output")) + g.add_edge(create_edge(loop.id, "item", second_return.id, "output")) + + assert g._get_for_body_path_to_return(loop.id, g.nx_graph_flat()) is None + + +def test_graph_rejects_missing_for_loop_linkage(): + g = Graph() + loop = ForInvocation(id="for", collection=["a", "b"]) + body_return = ForReturnInvocation(id="return") + + g.add_node(loop) + g.add_node(body_return) + g.add_edge(create_edge(loop.id, "item", body_return.id, "output")) + + with pytest.raises(InvalidEdgeError, match="exactly one loop linkage"): + g.validate_self() + + +def test_graph_rejects_missing_for_return_loop_linkage(): + g = Graph() + body_return = ForReturnInvocation(id="return") + + g.add_node(body_return) + + with pytest.raises(InvalidEdgeError, match="exactly one loop linkage"): + g.validate_self() + + +def test_graph_validates_indirect_for_body(): + g = Graph() + loop = ForInvocation(id="for", collection=["a", "b"]) + body = PromptTestInvocation(id="body") + body_return = ForReturnInvocation(id="return") + + g.add_node(loop) + g.add_node(body) + g.add_node(body_return) + g.add_edge(create_edge(body.id, "prompt", body_return.id, "output")) + g.add_edge(create_edge(loop.id, "item", body.id, "prompt")) + g.add_edge(create_loop_linkage(loop.id, body_return.id)) + + g.validate_self() + + +def test_graph_validates_for_body_inputs_from_outside_body_boundary(): + g = Graph() + loop = ForInvocation(id="for", collection=["a", "b"]) + external = PromptTestInvocation(id="external", prompt="outside") + body = TextToImageTestInvocation(id="body") + body_return = ForReturnInvocation(id="return") + + g.add_node(loop) + g.add_node(external) + g.add_node(body) + g.add_node(body_return) + g.add_edge(create_edge(loop.id, "item", body.id, "prompt")) + g.add_edge(create_edge(external.id, "prompt", body.id, "prompt2")) + g.add_edge(create_edge(body.id, "image", body_return.id, "output")) + g.add_edge(create_loop_linkage(loop.id, body_return.id)) + + g.validate_self() + + +def test_graph_rejects_for_body_inputs_from_external_iterator_scope(): + g = Graph() + external_values = StringCollectionInvocation(id="external_values", collection=["external-a", "external-b"]) + external_iterate = IterateInvocation(id="external_iterate") + external_adapter = PromptTestInvocation(id="external_adapter") + loop = ForInvocation(id="for", collection=["loop-a", "loop-b"]) + body = TextToImageTestInvocation(id="body") + body_return = ForReturnInvocation(id="return") + + g.add_node(external_values) + g.add_node(external_iterate) + g.add_node(external_adapter) + g.add_node(loop) + g.add_node(body) + g.add_node(body_return) + g.add_edge(create_edge(external_values.id, "collection", external_iterate.id, "collection")) + g.add_edge(create_edge(external_iterate.id, "item", external_adapter.id, "prompt")) + g.add_edge(create_edge(external_adapter.id, "prompt", body.id, "prompt2")) + g.add_edge(create_edge(loop.id, "item", body.id, "prompt")) + g.add_edge(create_edge(body.id, "image", body_return.id, "output")) + g.add_edge(create_loop_linkage(loop.id, body_return.id)) + + with pytest.raises(InvalidEdgeError, match="iterator-derived external inputs"): + g.validate_self() + + +def test_graph_rejects_for_without_matching_return(): + g = Graph() + loop = ForInvocation(id="for", collection=["a", "b"]) + body = PromptTestInvocation(id="body") + + g.add_node(loop) + g.add_node(body) + g.add_edge(create_edge(loop.id, "item", body.id, "prompt")) + + with pytest.raises(InvalidEdgeError, match="exactly one loop linkage"): + g.validate_self() + + +def test_graph_rejects_nested_for_until_linkage_exists(): + g = Graph() + loop = ForInvocation(id="for", collection=["a", "b"]) + nested_loop = ForInvocation(id="nested_for", collection=["c", "d"]) + body_return = ForReturnInvocation(id="return") + + g.add_node(loop) + g.add_node(nested_loop) + g.add_node(body_return) + g.add_edge(create_edge(nested_loop.id, "item", body_return.id, "output")) + g.add_edge(create_edge(loop.id, "item", nested_loop.id, "collection")) + + with pytest.raises(InvalidEdgeError, match="exactly one loop linkage"): + g.validate_self() + + +def test_graph_validates_deeper_nested_for_loops_with_one_child_per_boundary(): + g = Graph() + outer = ForInvocation(id="outer", collection=[[]]) + outer_collection = AnyTypeTestInvocation(id="outer_collection") + inner = ForInvocation(id="inner") + inner_collection = AnyTypeTestInvocation(id="inner_collection") + leaf = ForInvocation(id="leaf") + leaf_body = AnyTypeTestInvocation(id="leaf_body") + leaf_return = ForReturnInvocation(id="leaf_return") + inner_return = ForReturnInvocation(id="inner_return") + outer_return = ForReturnInvocation(id="outer_return") + + for node in ( + outer, + outer_collection, + inner, + inner_collection, + leaf, + leaf_body, + leaf_return, + inner_return, + outer_return, + ): + g.add_node(node) + g.add_edge(create_edge("outer", "item", "outer_collection", "value")) + g.add_edge(create_edge("outer_collection", "value", "inner", "collection")) + g.add_edge(create_edge("inner", "item", "inner_collection", "value")) + g.add_edge(create_edge("inner_collection", "value", "leaf", "collection")) + g.add_edge(create_edge("leaf", "item", "leaf_body", "value")) + g.add_edge(create_edge("leaf_body", "value", "leaf_return", "output")) + g.add_edge(create_edge("leaf", "output_collection", "inner_return", "output")) + g.add_edge(create_edge("inner", "output_collection", "outer_return", "output")) + g.add_edge(create_loop_linkage("leaf", "leaf_return")) + g.add_edge(create_loop_linkage("inner", "inner_return")) + g.add_edge(create_loop_linkage("outer", "outer_return")) + + g.validate_self() + + +def test_graph_validates_nested_for_with_shared_outer_continuation_path(): + g = Graph() + g.add_node(ForInvocation(id="outer", collection=[[]])) + g.add_node(AnyTypeTestInvocation(id="inner_collection")) + g.add_node(ForInvocation(id="inner")) + g.add_node(AnyTypeTestInvocation(id="inner_body")) + g.add_node(ForReturnInvocation(id="inner_return")) + g.add_node(AnyTypeTestInvocation(id="continuation")) + g.add_node(AnyTypeTestInvocation(id="continuation_tail")) + g.add_node(ForReturnInvocation(id="outer_return")) + g.add_edge(create_edge("outer", "item", "inner_collection", "value")) + g.add_edge(create_edge("inner_collection", "value", "inner", "collection")) + g.add_edge(create_edge("inner", "item", "inner_body", "value")) + g.add_edge(create_edge("inner_body", "value", "inner_return", "output")) + g.add_edge(create_edge("inner", "output_collection", "continuation", "value")) + g.add_edge(create_edge("continuation", "value", "continuation_tail", "value")) + g.add_edge(create_edge("continuation_tail", "value", "outer_return", "output")) + g.add_edge(create_edge("continuation_tail", "value", "outer_return", "continue_condition")) + g.add_edge(create_loop_linkage("inner", "inner_return")) + g.add_edge(create_loop_linkage("outer", "outer_return")) + + g.validate_self() + + +def test_graph_validates_nested_for_return_continue_condition(): + g = Graph() + g.add_node(ForInvocation(id="outer", collection=[[]])) + g.add_node(AnyTypeTestInvocation(id="inner_collection")) + g.add_node(ForInvocation(id="inner")) + g.add_node(AnyTypeTestInvocation(id="inner_body")) + g.add_node(AnyTypeTestInvocation(id="inner_condition")) + g.add_node(ForReturnInvocation(id="inner_return")) + g.add_node(ForReturnInvocation(id="outer_return")) + g.add_edge(create_edge("outer", "item", "inner_collection", "value")) + g.add_edge(create_edge("inner_collection", "value", "inner", "collection")) + g.add_edge(create_edge("inner", "item", "inner_body", "value")) + g.add_edge(create_edge("inner", "item", "inner_condition", "value")) + g.add_edge(create_edge("inner_body", "value", "inner_return", "output")) + g.add_edge(create_edge("inner_condition", "value", "inner_return", "continue_condition")) + g.add_edge(create_edge("inner", "output_collection", "outer_return", "output")) + g.add_edge(create_loop_linkage("inner", "inner_return")) + g.add_edge(create_loop_linkage("outer", "outer_return")) + + g.validate_self() + + +def test_graph_rejects_nested_for_return_continue_condition_from_external_scope(): + g = Graph() + g.add_node(ForInvocation(id="outer", collection=[[]])) + g.add_node(AnyTypeTestInvocation(id="inner_collection")) + g.add_node(ForInvocation(id="inner")) + g.add_node(AnyTypeTestInvocation(id="inner_body")) + g.add_node(ForReturnInvocation(id="inner_return")) + g.add_node(ForReturnInvocation(id="outer_return")) + g.add_node(AnyTypeTestInvocation(id="external_condition")) + g.add_edge(create_edge("outer", "item", "inner_collection", "value")) + g.add_edge(create_edge("inner_collection", "value", "inner", "collection")) + g.add_edge(create_edge("inner", "item", "inner_body", "value")) + g.add_edge(create_edge("inner_body", "value", "inner_return", "output")) + g.add_edge(create_edge("inner", "output_collection", "outer_return", "output")) + g.add_edge(create_edge("external_condition", "value", "outer_return", "continue_condition")) + g.add_edge(create_loop_linkage("inner", "inner_return")) + g.add_edge(create_loop_linkage("outer", "outer_return")) + + with pytest.raises(InvalidEdgeError, match="Nested For loops"): + g.validate_self() + + +def test_graph_rejects_nested_for_return_state_from_outer_scope(): + g = Graph() + g.add_node(ForInvocation(id="outer", collection=[[]])) + g.add_node(AnyTypeTestInvocation(id="inner_collection")) + g.add_node(ForInvocation(id="inner")) + g.add_node(AnyTypeTestInvocation(id="inner_body")) + g.add_node(ForReturnInvocation(id="inner_return")) + g.add_node(ForReturnInvocation(id="outer_return")) + g.add_edge(create_edge("outer", "item", "inner_collection", "value")) + g.add_edge(create_edge("inner_collection", "value", "inner", "collection")) + g.add_edge(create_edge("inner", "item", "inner_body", "value")) + g.add_edge(create_edge("inner_body", "value", "inner_return", "output")) + g.add_edge(create_edge("outer", "state", "inner_return", "state")) + g.add_edge(create_edge("inner", "output_collection", "outer_return", "output")) + g.add_edge(create_loop_linkage("inner", "inner_return")) + g.add_edge(create_loop_linkage("outer", "outer_return")) + + with pytest.raises(InvalidEdgeError, match="Nested For loops"): + g.validate_self() + + +def test_graph_rejects_nested_for_continuation_branch_without_outer_return(): + g = Graph() + g.add_node(ForInvocation(id="outer", collection=[[]])) + g.add_node(AnyTypeTestInvocation(id="inner_collection")) + g.add_node(ForInvocation(id="inner")) + g.add_node(AnyTypeTestInvocation(id="inner_body")) + g.add_node(ForReturnInvocation(id="inner_return")) + g.add_node(AnyTypeTestInvocation(id="continuation")) + g.add_node(AnyTypeTestInvocation(id="dead_branch")) + g.add_node(ForReturnInvocation(id="outer_return")) + g.add_edge(create_edge("outer", "item", "inner_collection", "value")) + g.add_edge(create_edge("inner_collection", "value", "inner", "collection")) + g.add_edge(create_edge("inner", "item", "inner_body", "value")) + g.add_edge(create_edge("inner_body", "value", "inner_return", "output")) + g.add_edge(create_edge("inner", "output_collection", "continuation", "value")) + g.add_edge(create_edge("continuation", "value", "outer_return", "output")) + g.add_edge(create_edge("continuation", "value", "dead_branch", "value")) + g.add_edge(create_loop_linkage("inner", "inner_return")) + g.add_edge(create_loop_linkage("outer", "outer_return")) + + with pytest.raises(InvalidEdgeError, match="Nested For loops"): + g.validate_self() + + +def test_graph_validates_independent_nested_for_children_with_explicit_fan_in(): + g = Graph() + g.add_node(ForInvocation(id="outer", collection=[[]])) + g.add_node(ForInvocation(id="first")) + g.add_node(ForInvocation(id="second")) + g.add_node(AnyTypeTestInvocation(id="first_body")) + g.add_node(AnyTypeTestInvocation(id="second_body")) + g.add_node(ForReturnInvocation(id="first_return")) + g.add_node(ForReturnInvocation(id="second_return")) + g.add_node(TwoAnyGraphTestInvocation(id="fan_in")) + g.add_node(ForReturnInvocation(id="outer_return")) + g.add_edge(create_edge("outer", "item", "first", "collection")) + g.add_edge(create_edge("outer", "item", "second", "collection")) + g.add_edge(create_edge("first", "item", "first_body", "value")) + g.add_edge(create_edge("first_body", "value", "first_return", "output")) + g.add_edge(create_edge("second", "item", "second_body", "value")) + g.add_edge(create_edge("second_body", "value", "second_return", "output")) + g.add_edge(create_edge("first", "output_collection", "fan_in", "first")) + g.add_edge(create_edge("second", "output_collection", "fan_in", "second")) + g.add_edge(create_edge("fan_in", "value", "outer_return", "output")) + g.add_edge(create_loop_linkage("first", "first_return")) + g.add_edge(create_loop_linkage("second", "second_return")) + g.add_edge(create_loop_linkage("outer", "outer_return")) + + g.validate_self() + + +def test_graph_rejects_multiple_direct_nested_for_children(): + g = Graph() + g.add_node(ForInvocation(id="outer", collection=[[]])) + g.add_node(ForInvocation(id="first")) + g.add_node(ForInvocation(id="second")) + g.add_node(ForReturnInvocation(id="first_return")) + g.add_node(ForReturnInvocation(id="second_return")) + g.add_node(ForReturnInvocation(id="outer_return")) + g.add_edge(create_edge("outer", "item", "first", "collection")) + g.add_edge(create_edge("outer", "item", "second", "collection")) + g.add_edge(create_edge("first", "item", "first_return", "output")) + g.add_edge(create_edge("second", "item", "second_return", "output")) + g.add_edge(create_edge("first", "output_collection", "outer_return", "output")) + g.add_edge(create_loop_linkage("first", "first_return")) + g.add_edge(create_loop_linkage("second", "second_return")) + g.add_edge(create_loop_linkage("outer", "outer_return")) + + with pytest.raises(InvalidEdgeError, match="Nested For loops"): + g.validate_self() + + +def test_graph_rejects_mixed_nested_for_and_iterate_body(): + g = Graph() + outer = ForInvocation(id="outer", collection=[[]]) + inner_collection = AnyTypeTestInvocation(id="inner_collection") + inner = ForInvocation(id="inner") + iterate_collection = PolymorphicStringTestInvocation(id="iterate_collection") + iterate = IterateInvocation(id="iterate") + body = AnyTypeTestInvocation(id="body") + collect = CollectInvocation(id="collect") + inner_return = ForReturnInvocation(id="inner_return") + outer_return = ForReturnInvocation(id="outer_return") + + for node in ( + outer, + inner_collection, + inner, + iterate_collection, + iterate, + body, + collect, + inner_return, + outer_return, + ): + g.add_node(node) + g.add_edge(create_edge("outer", "item", "inner_collection", "value")) + g.add_edge(create_edge("inner_collection", "value", "inner", "collection")) + g.add_edge(create_edge("inner", "item", "iterate_collection", "value")) + g.add_edge(create_edge("iterate_collection", "collection", "iterate", "collection")) + g.add_edge(create_edge("iterate", "item", "body", "value")) + g.add_edge(create_edge("body", "value", "collect", "item")) + g.add_edge(create_edge("collect", "collection", "inner_return", "output")) + g.add_edge(create_edge("inner", "output_collection", "outer_return", "output")) + g.add_edge(create_loop_linkage("inner", "inner_return")) + g.add_edge(create_loop_linkage("outer", "outer_return")) + + with pytest.raises(InvalidEdgeError, match="Nested For loops"): + g.validate_self() + + +def test_graph_rejects_for_return_shared_by_two_loops(): + first = ForInvocation(id="first", collection=["a"]) + second = ForInvocation(id="second", collection=["b"]) + body_return = ForReturnInvocation(id="return") + + g = Graph( + nodes={first.id: first, second.id: second, body_return.id: body_return}, + edges=[ + create_edge("first", "item", "return", "output"), + create_edge("second", "item", "return", "output"), + create_loop_linkage("first", "return"), + create_loop_linkage("second", "return"), + ], + ) + + with pytest.raises(InvalidEdgeError, match="exactly one loop linkage"): + g.validate_self() + + +def test_graph_rejects_iterate_inside_for_body(): + g = Graph() + loop = ForInvocation(id="for", collection=[["a", "b"]]) + collection_adapter = PolymorphicStringTestInvocation(id="collection_adapter") + nested_iterate = IterateInvocation(id="nested_iterate") + body_return = ForReturnInvocation(id="return") + + g.add_node(loop) + g.add_node(collection_adapter) + g.add_node(nested_iterate) + g.add_node(body_return) + g.add_edge(create_edge(loop.id, "item", collection_adapter.id, "value")) + g.add_edge(create_edge(collection_adapter.id, "collection", nested_iterate.id, "collection")) + g.add_edge(create_edge(nested_iterate.id, "item", body_return.id, "output")) + g.add_edge(create_loop_linkage(loop.id, body_return.id)) + + with pytest.raises(InvalidEdgeError, match="Iterate nodes inside For loop bodies"): + g.validate_self() + + +def test_graph_rejects_iterate_collect_for_return_condition_without_scalar_aggregation(): + g = Graph() + loop = ForInvocation(id="for", collection=[["a", "b"], ["c", "d"]]) + collection_adapter = PolymorphicStringTestInvocation(id="collection_adapter") + nested_iterate = IterateInvocation(id="nested_iterate") + body = AnyTypeTestInvocation(id="body") + condition = AnyTypeTestInvocation(id="condition") + collect = CollectInvocation(id="collect") + body_return = ForReturnInvocation(id="return") + + for node in (loop, collection_adapter, nested_iterate, body, condition, collect, body_return): + g.add_node(node) + g.add_edge(create_edge(loop.id, "item", collection_adapter.id, "value")) + g.add_edge(create_edge(collection_adapter.id, "collection", nested_iterate.id, "collection")) + g.add_edge(create_edge(nested_iterate.id, "item", body.id, "value")) + g.add_edge(create_edge(nested_iterate.id, "item", condition.id, "value")) + g.add_edge(create_edge(body.id, "value", collect.id, "item")) + g.add_edge(create_edge(collect.id, "collection", body_return.id, "output")) + g.add_edge(create_edge(condition.id, "value", body_return.id, "continue_condition")) + g.add_edge(create_loop_linkage(loop.id, body_return.id)) + + with pytest.raises(InvalidEdgeError, match="Iterate nodes inside For loop bodies"): + g.validate_self() + + +def test_graph_rejects_for_body_edges_that_escape_to_after_loop_nodes(): + g = Graph() + loop = ForInvocation(id="for", collection=["a", "b"]) + body = PromptTestInvocation(id="body") + body_return = ForReturnInvocation(id="return") + after = AnyTypeTestInvocation(id="after") + + g.add_node(loop) + g.add_node(body) + g.add_node(body_return) + g.add_node(after) + g.add_edge(create_edge(body.id, "prompt", body_return.id, "output")) + g.add_edge(create_edge(loop.id, "item", body.id, "prompt")) + g.add_edge(create_edge(body.id, "prompt", after.id, "value")) + g.add_edge(create_loop_linkage(loop.id, body_return.id)) + + with pytest.raises(InvalidEdgeError, match="escape"): + g.validate_self() + + +def test_graph_rejects_for_iteration_branch_that_does_not_reach_return(): + g = Graph() + loop = ForInvocation(id="for", collection=["a", "b"]) + body_return = ForReturnInvocation(id="return") + after = AnyTypeTestInvocation(id="after") + + g.add_node(loop) + g.add_node(body_return) + g.add_node(after) + g.add_edge(create_edge(loop.id, "item", body_return.id, "output")) + g.add_edge(create_edge(loop.id, "index", after.id, "value")) + g.add_edge(create_loop_linkage(loop.id, body_return.id)) + + with pytest.raises(InvalidEdgeError, match="terminate"): + g.validate_self() + + +def test_graph_rejects_for_return_outputs_to_after_loop_nodes(): + g = Graph() + loop = ForInvocation(id="for", collection=["a", "b"]) + body_return = ForReturnInvocation(id="return") + after = AnyTypeTestInvocation(id="after") + + g.add_node(loop) + g.add_node(body_return) + g.add_node(after) + g.add_edge(create_edge(loop.id, "item", body_return.id, "output")) + g.add_edge(create_edge(body_return.id, "output", after.id, "value")) + g.add_edge(create_loop_linkage(loop.id, body_return.id)) + + with pytest.raises(InvalidEdgeError, match="terminate"): + g.validate_self() + + +def test_graph_rejects_final_scoped_for_output_into_body(): + g = Graph() + loop = ForInvocation(id="for", collection=["a", "b"]) + body_return = ForReturnInvocation(id="return") + + g.add_node(loop) + g.add_node(body_return) + g.add_edge(create_edge(loop.id, "item", body_return.id, "output")) + g.add_edge(create_edge(loop.id, "final_state", body_return.id, "state")) + g.add_edge(create_loop_linkage(loop.id, body_return.id)) + + with pytest.raises(InvalidEdgeError, match="final-scoped"): + g.validate_self() + + +def test_graph_rejects_orphan_for_return(): + g = Graph() + body_return = ForReturnInvocation(id="return") + + g.add_node(body_return) + + with pytest.raises(InvalidEdgeError, match="exactly one loop linkage"): + g.validate_self() + + def test_connections_are_incompatible(): from_node = TextToImageTestInvocation(id="1", prompt="Banana sushi") from_field = "image" diff --git a/tests/test_nodes.py b/tests/test_nodes.py index 6e8d25a6034..47541fd4c12 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -132,6 +132,14 @@ def create_edge(from_id: str, from_field: str, to_id: str, to_field: str) -> Edg ) +def create_loop_linkage(from_id: str, to_id: str) -> Edge: + return Edge( + type="loop_linkage", + source=EdgeConnection(node_id=from_id, field="loop_linkage"), + destination=EdgeConnection(node_id=to_id, field="loop_linkage"), + ) + + class TestEvent(EventBase): __test__ = False # not a pytest test case From 100b442278551465c7465084d72986de09c22273 Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Sun, 30 Aug 2026 19:15:08 -0500 Subject: [PATCH 2/5] fix(graph): preserve lazy networkx import --- invokeai/app/services/shared/graph.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/invokeai/app/services/shared/graph.py b/invokeai/app/services/shared/graph.py index 62da8178a83..b1139c6994c 100644 --- a/invokeai/app/services/shared/graph.py +++ b/invokeai/app/services/shared/graph.py @@ -686,7 +686,7 @@ def create_for_body_iteration(self, source_for_id: str, prepared_for_id: str) -> return prepared_return_id - def _is_deferred_nested_for_return(self, node_id: str, graph: nx.DiGraph) -> bool: + def _is_deferred_nested_for_return(self, node_id: str, graph: "nx.DiGraph") -> bool: return any( (nested_body := self._state.graph._get_supported_for_nested_for_body(source_for_id, graph)) is not None and nested_body.outer_return_id == node_id @@ -836,8 +836,8 @@ def _create_nested_for_body_iteration( self, source_for_id: str, prepared_for_id: str, - graph: nx.DiGraph, - execution_graph: nx.DiGraph, + graph: "nx.DiGraph", + execution_graph: "nx.DiGraph", nested_body: _SupportedNestedForBody, ) -> Optional[str]: body_path_nodes = nested_body.body_path_nodes @@ -953,8 +953,8 @@ def _create_nested_iterate_body_iteration( self, source_for_id: str, prepared_for_id: str, - graph: nx.DiGraph, - execution_graph: nx.DiGraph, + graph: "nx.DiGraph", + execution_graph: "nx.DiGraph", nested_body: tuple[set[str], str, str, str], ) -> Optional[str]: body_path_nodes, source_return_id, source_iterate_id, source_collect_id = nested_body @@ -3172,7 +3172,7 @@ def _get_for_final_output_edges(self, node_id: str) -> list[Edge]: if get_output_field_scope(node, edge.source.field) == OutputScope.Final ] - def _get_for_reachable_body_nodes(self, iteration_edges: list[Edge], graph: nx.DiGraph) -> set[str]: + def _get_for_reachable_body_nodes(self, iteration_edges: list[Edge], graph: "nx.DiGraph") -> set[str]: body_nodes: set[str] = set() for edge in iteration_edges: body_nodes.add(edge.destination.node_id) @@ -3180,11 +3180,11 @@ def _get_for_reachable_body_nodes(self, iteration_edges: list[Edge], graph: nx.D return body_nodes def _get_for_body_path_nodes( - self, reachable_body_nodes: set[str], return_node_id: str, graph: nx.DiGraph + self, reachable_body_nodes: set[str], return_node_id: str, graph: "nx.DiGraph" ) -> set[str]: return (reachable_body_nodes & nx.ancestors(graph, return_node_id)) | {return_node_id} - def _get_for_body_path_to_return(self, node_id: str, graph: nx.DiGraph) -> tuple[set[str], str] | None: + def _get_for_body_path_to_return(self, node_id: str, graph: "nx.DiGraph") -> tuple[set[str], str] | None: """Resolve the runtime body path to its owning ForReturn. The loop linkage identifies the return endpoint. The ordinary body graph still determines whether that return @@ -3202,7 +3202,7 @@ def _get_for_body_path_to_return(self, node_id: str, graph: nx.DiGraph) -> tuple return self._get_for_body_path_nodes(reachable_body_nodes, return_node_id, graph), return_node_id def _get_supported_for_nested_iterate_body( - self, node_id: str, graph: nx.DiGraph + self, node_id: str, graph: "nx.DiGraph" ) -> tuple[set[str], str, str, str] | None: """Returns the bounded internal Iterate body contract, if this For uses it.""" body_path_to_return = self._get_for_body_path_to_return(node_id, graph) @@ -3269,7 +3269,7 @@ def _get_supported_for_nested_iterate_body( return body_path_nodes, return_node_id, iterate_node_id, collect_node_id - def _get_supported_for_nested_for_body(self, node_id: str, graph: nx.DiGraph) -> _SupportedNestedForBody | None: + def _get_supported_for_nested_for_body(self, node_id: str, graph: "nx.DiGraph") -> _SupportedNestedForBody | None: """Returns the supported recursive nested For contract, if this For uses it. Each direct child loop has its own ForReturn. A single child may close the parent directly or through a From cfd2bc9d2ef75b278a793beba069ef9cef5f4db7 Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Wed, 2 Sep 2026 21:41:53 -0500 Subject: [PATCH 3/5] Migrate For loop frontend to webv2 --- invokeai/frontend/web/openapi.json | 1367 +---------------- invokeai/frontend/web/public/locales/en.json | 26 - .../flow/AddNodeCmdk.mounted.test.tsx | 319 ---- .../flow/AddNodeCmdk/AddNodeCmdk.test.ts | 162 -- .../flow/AddNodeCmdk/AddNodeCmdk.tsx | 279 +--- .../features/nodes/components/flow/Flow.tsx | 16 +- .../flow/LoopBodyBoundaryOverlay.test.tsx | 129 -- .../flow/edges/InvocationLoopLinkageEdge.tsx | 65 - .../flow/nodes/Connector/ConnectorNode.tsx | 8 +- .../flow/nodes/Invocation/InvocationNode.tsx | 20 +- .../Invocation/InvocationNodeInfoIcon.tsx | 2 +- .../InvocationNodeStatusIndicator.tsx | 9 +- .../nodes/Invocation/OutputFields.test.tsx | 62 - .../flow/nodes/Invocation/OutputFields.tsx | 33 - .../Invocation/fields/InputFieldTitle.tsx | 1 - .../src/features/nodes/hooks/useAutoLayout.ts | 6 +- .../features/nodes/hooks/useNodeCopyPaste.ts | 37 +- .../nodes/hooks/useOutputFieldNames.ts | 11 +- .../features/nodes/store/nodesSlice.test.ts | 396 +---- .../src/features/nodes/store/nodesSlice.ts | 127 +- .../store/util/connectorTopology.test.ts | 165 +- .../nodes/store/util/connectorTopology.ts | 405 +---- .../util/getFirstValidConnection.test.ts | 82 +- .../store/util/getFirstValidConnection.ts | 30 +- .../features/nodes/store/util/getHasCycles.ts | 4 +- .../nodes/store/util/reactFlowUtil.test.ts | 18 - .../nodes/store/util/reactFlowUtil.ts | 13 +- .../features/nodes/store/util/testUtils.ts | 254 --- .../store/util/validateConnection.test.ts | 618 +------- .../nodes/store/util/validateConnection.ts | 386 ----- .../util/validateConnectionTypes.test.ts | 18 - .../web/src/features/nodes/types/constants.ts | 3 - .../web/src/features/nodes/types/field.ts | 2 - .../src/features/nodes/types/invocation.ts | 14 +- .../web/src/features/nodes/types/openapi.ts | 13 +- .../web/src/features/nodes/types/workflow.ts | 7 +- .../nodes/util/graph/buildNodesGraph.test.ts | 201 +-- .../nodes/util/graph/buildNodesGraph.ts | 127 +- .../nodes/util/graph/generation/Graph.ts | 2 +- .../nodes/util/graph/loopBodyBoundary.test.ts | 212 --- .../nodes/util/graph/loopBodyBoundary.ts | 219 --- .../util/graph/validateForLoopGraph.test.ts | 604 -------- .../node/getOutputFieldNamesByScope.test.ts | 49 - .../util/node/getOutputFieldNamesByScope.ts | 21 - .../util/node/getOutputFieldRows.test.ts | 37 - .../nodes/util/node/getOutputFieldRows.ts | 21 - .../features/nodes/util/node/nodeUpdate.ts | 2 +- .../util/schema/buildFieldInputTemplate.ts | 10 +- .../schema/buildFieldOutputTemplate.test.ts | 26 - .../util/schema/buildFieldOutputTemplate.ts | 7 +- .../nodes/util/schema/parseSchema.test.ts | 82 +- .../features/nodes/util/schema/parseSchema.ts | 10 +- .../nodes/util/workflow/buildWorkflow.test.ts | 34 +- .../nodes/util/workflow/buildWorkflow.ts | 12 +- .../util/workflow/graphToWorkflow.test.ts | 138 +- .../nodes/util/workflow/graphToWorkflow.ts | 14 +- .../util/workflow/validateWorkflow.test.ts | 234 --- .../nodes/util/workflow/validateWorkflow.ts | 86 +- .../features/queue/store/readiness.test.ts | 63 +- .../web/src/features/queue/store/readiness.ts | 16 +- .../frontend/web/src/services/api/schema.ts | 574 +------ .../frontend/webv2/public/locales/en.json | 11 + .../src/features/generation/core/contracts.ts | 2 + .../features/generation/core/graphBuilder.ts | 1 + .../features/generation/core/previewGraph.ts | 2 + .../features/workflow/core/buildGraph.test.ts | 97 +- .../src/features/workflow/core/buildGraph.ts | 64 +- .../features/workflow/core/connectors.test.ts | 280 ++++ .../src/features/workflow/core/connectors.ts | 265 ++++ .../src/features/workflow/core/document.ts | 27 +- .../features/workflow/core/forLoops.test.ts | 175 +++ .../src/features/workflow/core/forLoops.ts} | 378 ++++- .../features/workflow/core/graphContracts.ts | 2 + .../workflow/core/graphToDocument.test.ts | 39 + .../features/workflow/core/graphToDocument.ts | 11 +- .../workflow/core/layerWorkflow.test.ts | 3 + .../features/workflow/core/layerWorkflow.ts | 2 + .../workflow/core/outputFields.test.ts | 53 + .../features/workflow/core/outputFields.ts | 42 + .../webv2/src/features/workflow/core/types.ts | 4 +- .../src/features/workflow/core/validation.ts | 87 ++ .../workflow/core/workflowJson.test.ts | 37 + .../features/workflow/core/workflowJson.ts | 8 +- .../features/workflow/data/templates.test.ts | 32 + .../src/features/workflow/data/templates.ts | 5 + .../webv2/src/features/workflow/graph.ts | 1 + .../workflow/ui/WorkflowWidgetChrome.tsx | 76 +- .../src/features/workflow/ui/contracts.ts | 1 + .../workflow/ui/editor/AddNodeDialog.tsx | 97 +- .../workflow/ui/editor/InvocationFlowNode.tsx | 30 +- .../ui/editor}/LoopBodyBoundaryOverlay.tsx | 50 +- .../workflow/ui/editor/WorkflowEditorView.tsx | 9 +- .../workflow/ui/editor/flowAdapters.test.ts | 12 + .../workflow/ui/editor/flowAdapters.ts | 36 +- .../ui/graph-preview/GraphPreviewFlow.tsx | 16 +- .../webv2/src/features/workflow/utility.ts | 2 + .../webv2/src/workbench/graphContracts.ts | 4 + .../webv2/src/workbench/workbenchState.ts | 1 + 98 files changed, 2056 insertions(+), 7814 deletions(-) delete mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk.mounted.test.tsx delete mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk.test.ts delete mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/LoopBodyBoundaryOverlay.test.tsx delete mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/edges/InvocationLoopLinkageEdge.tsx delete mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/OutputFields.test.tsx delete mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/OutputFields.tsx delete mode 100644 invokeai/frontend/web/src/features/nodes/util/graph/loopBodyBoundary.test.ts delete mode 100644 invokeai/frontend/web/src/features/nodes/util/graph/loopBodyBoundary.ts delete mode 100644 invokeai/frontend/web/src/features/nodes/util/graph/validateForLoopGraph.test.ts delete mode 100644 invokeai/frontend/web/src/features/nodes/util/node/getOutputFieldNamesByScope.test.ts delete mode 100644 invokeai/frontend/web/src/features/nodes/util/node/getOutputFieldNamesByScope.ts delete mode 100644 invokeai/frontend/web/src/features/nodes/util/node/getOutputFieldRows.test.ts delete mode 100644 invokeai/frontend/web/src/features/nodes/util/node/getOutputFieldRows.ts delete mode 100644 invokeai/frontend/web/src/features/nodes/util/schema/buildFieldOutputTemplate.test.ts create mode 100644 invokeai/frontend/webv2/src/features/workflow/core/connectors.test.ts create mode 100644 invokeai/frontend/webv2/src/features/workflow/core/forLoops.test.ts rename invokeai/frontend/{web/src/features/nodes/util/graph/validateForLoopGraph.ts => webv2/src/features/workflow/core/forLoops.ts} (60%) create mode 100644 invokeai/frontend/webv2/src/features/workflow/core/outputFields.test.ts create mode 100644 invokeai/frontend/webv2/src/features/workflow/core/outputFields.ts rename invokeai/frontend/{web/src/features/nodes/components/flow => webv2/src/features/workflow/ui/editor}/LoopBodyBoundaryOverlay.tsx (69%) diff --git a/invokeai/frontend/web/openapi.json b/invokeai/frontend/web/openapi.json index 74324c4bce4..56213d0e4c3 100644 --- a/invokeai/frontend/web/openapi.json +++ b/invokeai/frontend/web/openapi.json @@ -21309,294 +21309,6 @@ "title": "CollectInvocationOutput", "type": "object" }, - "CollectionCartesianInvocation": { - "category": "batch", - "class": "invocation", - "classification": "stable", - "description": "Emits every pair formed by one item from each collection, up to 100,000 pairs.", - "node_pack": "invokeai", - "properties": { - "id": { - "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", - "field_kind": "node_attribute", - "title": "Id", - "type": "string" - }, - "is_intermediate": { - "default": false, - "description": "Whether or not this is an intermediate invocation.", - "field_kind": "node_attribute", - "input": "direct", - "orig_required": true, - "title": "Is Intermediate", - "type": "boolean", - "ui_hidden": false, - "ui_type": "IsIntermediate" - }, - "use_cache": { - "default": true, - "description": "Whether or not to use the cache", - "field_kind": "node_attribute", - "title": "Use Cache", - "type": "boolean" - }, - "first": { - "default": [], - "description": "The first collection", - "field_kind": "input", - "input": "any", - "items": {}, - "orig_default": [], - "orig_required": false, - "title": "First", - "type": "array", - "ui_type": "CollectionField" - }, - "second": { - "default": [], - "description": "The second collection", - "field_kind": "input", - "input": "any", - "items": {}, - "orig_default": [], - "orig_required": false, - "title": "Second", - "type": "array", - "ui_type": "CollectionField" - }, - "type": { - "const": "collection_cartesian", - "default": "collection_cartesian", - "field_kind": "node_attribute", - "title": "type", - "type": "string" - } - }, - "required": ["type", "id"], - "tags": ["collection", "cartesian", "product"], - "title": "Cartesian Product of Collections", - "type": "object", - "version": "1.0.0", - "output": { - "$ref": "#/components/schemas/CollectionCartesianInvocationOutput" - } - }, - "CollectionCartesianInvocationOutput": { - "class": "output", - "properties": { - "collection": { - "description": "The Cartesian product pairs", - "field_kind": "output", - "items": {}, - "title": "Collection", - "type": "array", - "ui_hidden": false, - "ui_type": "CollectionField" - }, - "type": { - "const": "collection_cartesian_output", - "default": "collection_cartesian_output", - "field_kind": "node_attribute", - "title": "type", - "type": "string" - } - }, - "required": ["output_meta", "collection", "type", "type"], - "title": "CollectionCartesianInvocationOutput", - "type": "object" - }, - "CollectionConcatInvocation": { - "category": "batch", - "class": "invocation", - "classification": "stable", - "description": "Concatenates two collections in left-to-right order.", - "node_pack": "invokeai", - "properties": { - "id": { - "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", - "field_kind": "node_attribute", - "title": "Id", - "type": "string" - }, - "is_intermediate": { - "default": false, - "description": "Whether or not this is an intermediate invocation.", - "field_kind": "node_attribute", - "input": "direct", - "orig_required": true, - "title": "Is Intermediate", - "type": "boolean", - "ui_hidden": false, - "ui_type": "IsIntermediate" - }, - "use_cache": { - "default": true, - "description": "Whether or not to use the cache", - "field_kind": "node_attribute", - "title": "Use Cache", - "type": "boolean" - }, - "first": { - "default": [], - "description": "The first collection", - "field_kind": "input", - "input": "any", - "items": {}, - "orig_default": [], - "orig_required": false, - "title": "First", - "type": "array", - "ui_type": "CollectionField" - }, - "second": { - "default": [], - "description": "The second collection", - "field_kind": "input", - "input": "any", - "items": {}, - "orig_default": [], - "orig_required": false, - "title": "Second", - "type": "array", - "ui_type": "CollectionField" - }, - "type": { - "const": "collection_concat", - "default": "collection_concat", - "field_kind": "node_attribute", - "title": "type", - "type": "string" - } - }, - "required": ["type", "id"], - "tags": ["collection", "concat", "sequential"], - "title": "Concatenate Collections", - "type": "object", - "version": "1.0.0", - "output": { - "$ref": "#/components/schemas/CollectionConcatInvocationOutput" - } - }, - "CollectionConcatInvocationOutput": { - "class": "output", - "properties": { - "collection": { - "description": "The concatenated collection", - "field_kind": "output", - "items": {}, - "title": "Collection", - "type": "array", - "ui_hidden": false, - "ui_type": "CollectionField" - }, - "type": { - "const": "collection_concat_output", - "default": "collection_concat_output", - "field_kind": "node_attribute", - "title": "type", - "type": "string" - } - }, - "required": ["output_meta", "collection", "type", "type"], - "title": "CollectionConcatInvocationOutput", - "type": "object" - }, - "CollectionZipInvocation": { - "category": "batch", - "class": "invocation", - "classification": "stable", - "description": "Pairs items at matching positions from two equally sized collections.", - "node_pack": "invokeai", - "properties": { - "id": { - "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", - "field_kind": "node_attribute", - "title": "Id", - "type": "string" - }, - "is_intermediate": { - "default": false, - "description": "Whether or not this is an intermediate invocation.", - "field_kind": "node_attribute", - "input": "direct", - "orig_required": true, - "title": "Is Intermediate", - "type": "boolean", - "ui_hidden": false, - "ui_type": "IsIntermediate" - }, - "use_cache": { - "default": true, - "description": "Whether or not to use the cache", - "field_kind": "node_attribute", - "title": "Use Cache", - "type": "boolean" - }, - "first": { - "default": [], - "description": "The first collection", - "field_kind": "input", - "input": "any", - "items": {}, - "orig_default": [], - "orig_required": false, - "title": "First", - "type": "array", - "ui_type": "CollectionField" - }, - "second": { - "default": [], - "description": "The second collection", - "field_kind": "input", - "input": "any", - "items": {}, - "orig_default": [], - "orig_required": false, - "title": "Second", - "type": "array", - "ui_type": "CollectionField" - }, - "type": { - "const": "collection_zip", - "default": "collection_zip", - "field_kind": "node_attribute", - "title": "type", - "type": "string" - } - }, - "required": ["type", "id"], - "tags": ["collection", "zip", "pair"], - "title": "Zip Collections", - "type": "object", - "version": "1.0.0", - "output": { - "$ref": "#/components/schemas/CollectionZipInvocationOutput" - } - }, - "CollectionZipInvocationOutput": { - "class": "output", - "properties": { - "collection": { - "description": "The positional pairs", - "field_kind": "output", - "items": {}, - "title": "Collection", - "type": "array", - "ui_hidden": false, - "ui_type": "CollectionField" - }, - "type": { - "const": "collection_zip_output", - "default": "collection_zip_output", - "field_kind": "node_attribute", - "title": "type", - "type": "string" - } - }, - "required": ["output_meta", "collection", "type", "type"], - "title": "CollectionZipInvocationOutput", - "type": "object" - }, "ColorCollectionOutput": { "class": "output", "description": "Base class for nodes that output a collection of colors", @@ -28028,13 +27740,6 @@ }, "Edge": { "properties": { - "type": { - "type": "string", - "enum": ["default", "loop_linkage"], - "title": "Type", - "description": "The kind of relationship represented by this edge", - "default": "default" - }, "source": { "$ref": "#/components/schemas/EdgeConnection", "description": "The connection for the edge's from node and field" @@ -35928,392 +35633,64 @@ "title": "FluxVariantType", "description": "FLUX.1 model variants." }, - "ForInvocation": { + "FoundModel": { + "properties": { + "path": { + "type": "string", + "title": "Path", + "description": "Path to the model" + }, + "is_installed": { + "type": "boolean", + "title": "Is Installed", + "description": "Whether or not the model is already installed" + } + }, + "type": "object", + "required": ["path", "is_installed"], + "title": "FoundModel" + }, + "FreeUConfig": { + "description": "Configuration for the FreeU hyperparameters.\n- https://huggingface.co/docs/diffusers/main/en/using-diffusers/freeu\n- https://github.com/ChenyangSi/FreeU", + "properties": { + "s1": { + "description": "Scaling factor for stage 1 to attenuate the contributions of the skip features. This is done to mitigate the \"oversmoothing effect\" in the enhanced denoising process.", + "maximum": 3, + "minimum": -1, + "title": "S1", + "type": "number" + }, + "s2": { + "description": "Scaling factor for stage 2 to attenuate the contributions of the skip features. This is done to mitigate the \"oversmoothing effect\" in the enhanced denoising process.", + "maximum": 3, + "minimum": -1, + "title": "S2", + "type": "number" + }, + "b1": { + "description": "Scaling factor for stage 1 to amplify the contributions of backbone features.", + "maximum": 3, + "minimum": -1, + "title": "B1", + "type": "number" + }, + "b2": { + "description": "Scaling factor for stage 2 to amplify the contributions of backbone features.", + "maximum": 3, + "minimum": -1, + "title": "B2", + "type": "number" + } + }, + "required": ["s1", "s2", "b1", "b2"], + "title": "FreeUConfig", + "type": "object" + }, + "FreeUInvocation": { + "category": "model", "class": "invocation", "classification": "stable", - "node_pack": "invokeai", - "properties": { - "id": { - "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", - "field_kind": "node_attribute", - "title": "Id", - "type": "string" - }, - "is_intermediate": { - "default": false, - "description": "Whether or not this is an intermediate invocation.", - "field_kind": "node_attribute", - "input": "direct", - "orig_required": true, - "title": "Is Intermediate", - "type": "boolean", - "ui_hidden": false, - "ui_type": "IsIntermediate" - }, - "use_cache": { - "default": true, - "description": "Whether or not to use the cache", - "field_kind": "node_attribute", - "title": "Use Cache", - "type": "boolean" - }, - "collection": { - "default": [], - "description": "The list of items to iterate over", - "field_kind": "input", - "input": "any", - "items": {}, - "orig_default": [], - "orig_required": false, - "title": "Collection", - "type": "array", - "ui_type": "CollectionField" - }, - "state": { - "anyOf": [ - { - "$ref": "#/components/schemas/LoopState" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Optional initial loop state", - "field_kind": "input", - "input": "any", - "orig_default": null, - "orig_required": false - }, - "index": { - "default": -1, - "description": "The internal iteration index for a prepared For execution node", - "field_kind": "input", - "input": "direct", - "orig_default": -1, - "orig_required": false, - "title": "Index", - "type": "integer", - "ui_hidden": true - }, - "type": { - "const": "for", - "default": "for", - "field_kind": "node_attribute", - "title": "type", - "type": "string" - } - }, - "required": ["type", "id"], - "title": "ForInvocation", - "type": "object", - "version": "1.3.0", - "output": { - "$ref": "#/components/schemas/ForInvocationOutput" - } - }, - "ForInvocationOutput": { - "class": "output", - "properties": { - "loop_linkage": { - "description": "The loop linkage to the matching ForReturn", - "field_kind": "output", - "title": "Loop Linkage", - "ui_hidden": false, - "ui_type": "AnyField" - }, - "item": { - "anyOf": [ - {}, - { - "type": "null" - } - ], - "default": null, - "description": "The item for the current loop iteration, or None when the collection is empty", - "field_kind": "output", - "output_scope": "iteration", - "title": "Collection Item", - "ui_hidden": false, - "ui_type": "CollectionItemField" - }, - "index": { - "description": "The index for the current loop iteration", - "field_kind": "output", - "output_scope": "iteration", - "title": "Index", - "type": "integer", - "ui_hidden": false - }, - "total": { - "description": "The total number of items in the loop collection", - "field_kind": "output", - "output_scope": "iteration", - "title": "Total", - "type": "integer", - "ui_hidden": false - }, - "state": { - "$ref": "#/components/schemas/LoopState", - "description": "The state for the current loop iteration", - "field_kind": "output", - "output_scope": "iteration", - "title": "State", - "ui_hidden": false - }, - "output_collection": { - "description": "The collected loop body outputs", - "field_kind": "output", - "items": {}, - "output_scope": "final", - "title": "Output Collection", - "type": "array", - "ui_hidden": false, - "ui_type": "CollectionField" - }, - "final_state": { - "$ref": "#/components/schemas/LoopState", - "description": "The final loop state", - "field_kind": "output", - "output_scope": "final", - "title": "Final State", - "ui_hidden": false - }, - "type": { - "const": "for_output", - "default": "for_output", - "field_kind": "node_attribute", - "title": "type", - "type": "string" - } - }, - "required": [ - "output_meta", - "loop_linkage", - "item", - "index", - "total", - "state", - "output_collection", - "final_state", - "type", - "type" - ], - "title": "ForInvocationOutput", - "type": "object" - }, - "ForReturnInvocation": { - "class": "invocation", - "classification": "stable", - "node_pack": "invokeai", - "properties": { - "id": { - "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", - "field_kind": "node_attribute", - "title": "Id", - "type": "string" - }, - "is_intermediate": { - "default": false, - "description": "Whether or not this is an intermediate invocation.", - "field_kind": "node_attribute", - "input": "direct", - "orig_required": true, - "title": "Is Intermediate", - "type": "boolean", - "ui_hidden": false, - "ui_type": "IsIntermediate" - }, - "use_cache": { - "default": true, - "description": "Whether or not to use the cache", - "field_kind": "node_attribute", - "title": "Use Cache", - "type": "boolean" - }, - "loop_linkage": { - "anyOf": [ - {}, - { - "type": "null" - } - ], - "default": null, - "description": "The loop linkage from the matching For", - "field_kind": "input", - "input": "connection", - "orig_default": null, - "orig_required": false, - "title": "Loop Linkage", - "ui_type": "AnyField" - }, - "output": { - "anyOf": [ - {}, - { - "type": "null" - } - ], - "default": null, - "description": "The output item to append to the loop output collection", - "field_kind": "input", - "input": "any", - "orig_default": null, - "orig_required": false, - "title": "Output", - "ui_type": "CollectionItemField" - }, - "state": { - "anyOf": [ - { - "$ref": "#/components/schemas/LoopState" - }, - { - "type": "null" - } - ], - "default": null, - "description": "The state to pass to the next loop iteration", - "field_kind": "input", - "input": "any", - "orig_default": null, - "orig_required": false - }, - "continue_condition": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "default": true, - "description": "Whether to schedule the next loop iteration; false finalizes the loop", - "field_kind": "input", - "input": "any", - "orig_default": true, - "orig_required": false, - "title": "Continue Condition" - }, - "type": { - "const": "for_return", - "default": "for_return", - "field_kind": "node_attribute", - "title": "type", - "type": "string" - } - }, - "required": ["type", "id"], - "title": "ForReturnInvocation", - "type": "object", - "version": "1.3.2", - "output": { - "$ref": "#/components/schemas/ForReturnInvocationOutput" - } - }, - "ForReturnInvocationOutput": { - "class": "output", - "properties": { - "output": { - "anyOf": [ - {}, - { - "type": "null" - } - ], - "default": null, - "description": "The output item to append to the loop output collection", - "field_kind": "output", - "title": "Output", - "ui_hidden": true, - "ui_type": "CollectionItemField" - }, - "state": { - "anyOf": [ - { - "$ref": "#/components/schemas/LoopState" - }, - { - "type": "null" - } - ], - "default": null, - "description": "The state to pass to the next loop iteration", - "field_kind": "output", - "title": "State", - "ui_hidden": true - }, - "type": { - "const": "for_return_output", - "default": "for_return_output", - "field_kind": "node_attribute", - "title": "type", - "type": "string" - } - }, - "required": ["output_meta", "output", "state", "type", "type"], - "title": "ForReturnInvocationOutput", - "type": "object" - }, - "FoundModel": { - "properties": { - "path": { - "type": "string", - "title": "Path", - "description": "Path to the model" - }, - "is_installed": { - "type": "boolean", - "title": "Is Installed", - "description": "Whether or not the model is already installed" - } - }, - "type": "object", - "required": ["path", "is_installed"], - "title": "FoundModel" - }, - "FreeUConfig": { - "description": "Configuration for the FreeU hyperparameters.\n- https://huggingface.co/docs/diffusers/main/en/using-diffusers/freeu\n- https://github.com/ChenyangSi/FreeU", - "properties": { - "s1": { - "description": "Scaling factor for stage 1 to attenuate the contributions of the skip features. This is done to mitigate the \"oversmoothing effect\" in the enhanced denoising process.", - "maximum": 3, - "minimum": -1, - "title": "S1", - "type": "number" - }, - "s2": { - "description": "Scaling factor for stage 2 to attenuate the contributions of the skip features. This is done to mitigate the \"oversmoothing effect\" in the enhanced denoising process.", - "maximum": 3, - "minimum": -1, - "title": "S2", - "type": "number" - }, - "b1": { - "description": "Scaling factor for stage 1 to amplify the contributions of backbone features.", - "maximum": 3, - "minimum": -1, - "title": "B1", - "type": "number" - }, - "b2": { - "description": "Scaling factor for stage 2 to amplify the contributions of backbone features.", - "maximum": 3, - "minimum": -1, - "title": "B2", - "type": "number" - } - }, - "required": ["s1", "s2", "b1", "b2"], - "title": "FreeUConfig", - "type": "object" - }, - "FreeUInvocation": { - "category": "model", - "class": "invocation", - "classification": "stable", - "description": "Applies FreeU to the UNet. Suggested values (b1/b2/s1/s2):\n\nSD1.5: 1.2/1.4/0.9/0.2,\nSD2: 1.1/1.2/0.9/0.2,\nSDXL: 1.1/1.2/0.6/0.4,", + "description": "Applies FreeU to the UNet. Suggested values (b1/b2/s1/s2):\n\nSD1.5: 1.2/1.4/0.9/0.2,\nSD2: 1.1/1.2/0.9/0.2,\nSDXL: 1.1/1.2/0.6/0.4,", "node_pack": "invokeai", "properties": { "id": { @@ -37544,15 +36921,6 @@ { "$ref": "#/components/schemas/CollectInvocation" }, - { - "$ref": "#/components/schemas/CollectionCartesianInvocation" - }, - { - "$ref": "#/components/schemas/CollectionConcatInvocation" - }, - { - "$ref": "#/components/schemas/CollectionZipInvocation" - }, { "$ref": "#/components/schemas/ColorCorrectInvocation" }, @@ -37754,12 +37122,6 @@ { "$ref": "#/components/schemas/FluxVaeEncodeInvocation" }, - { - "$ref": "#/components/schemas/ForInvocation" - }, - { - "$ref": "#/components/schemas/ForReturnInvocation" - }, { "$ref": "#/components/schemas/FreeUInvocation" }, @@ -38273,18 +37635,6 @@ { "$ref": "#/components/schemas/SpandrelImageToImageInvocation" }, - { - "$ref": "#/components/schemas/StateEmptyInvocation" - }, - { - "$ref": "#/components/schemas/StateGetInvocation" - }, - { - "$ref": "#/components/schemas/StateMergeInvocation" - }, - { - "$ref": "#/components/schemas/StateSetInvocation" - }, { "$ref": "#/components/schemas/StringBatchInvocation" }, @@ -38519,15 +37869,6 @@ { "$ref": "#/components/schemas/CollectInvocationOutput" }, - { - "$ref": "#/components/schemas/CollectionCartesianInvocationOutput" - }, - { - "$ref": "#/components/schemas/CollectionConcatInvocationOutput" - }, - { - "$ref": "#/components/schemas/CollectionZipInvocationOutput" - }, { "$ref": "#/components/schemas/ColorCollectionOutput" }, @@ -38609,12 +37950,6 @@ { "$ref": "#/components/schemas/FluxReduxOutput" }, - { - "$ref": "#/components/schemas/ForInvocationOutput" - }, - { - "$ref": "#/components/schemas/ForReturnInvocationOutput" - }, { "$ref": "#/components/schemas/Gemma2EncoderOutput" }, @@ -38684,12 +38019,6 @@ { "$ref": "#/components/schemas/LoRASelectorOutput" }, - { - "$ref": "#/components/schemas/LoopStateOutput" - }, - { - "$ref": "#/components/schemas/LoopStateValueOutput" - }, { "$ref": "#/components/schemas/MDControlListOutput" }, @@ -38956,37 +38285,6 @@ "title": "Source Prepared Mapping", "description": "The map of original graph nodes to prepared nodes" }, - "finalized_loop_nodes": { - "items": { - "type": "string" - }, - "type": "array", - "uniqueItems": true, - "title": "Finalized Loop Nodes", - "description": "Legacy set of top-level loop source nodes whose final outputs have been materialized" - }, - "finalized_loop_contexts": { - "items": { - "prefixItems": [ - { - "type": "string" - }, - { - "items": { - "type": "integer" - }, - "type": "array" - } - ], - "type": "array", - "maxItems": 2, - "minItems": 2 - }, - "type": "array", - "uniqueItems": true, - "title": "Finalized Loop Contexts", - "description": "The finalized loop source and parent iteration contexts" - }, "prepared_iteration_paths": { "additionalProperties": { "items": { @@ -39026,8 +38324,7 @@ "workflow_call_stack", "workflow_call_history", "prepared_source_mapping", - "source_prepared_mapping", - "finalized_loop_nodes" + "source_prepared_mapping" ], "title": "GraphExecutionState", "description": "Tracks source-graph expansion, execution progress, and runtime results." @@ -46705,15 +46002,6 @@ { "$ref": "#/components/schemas/CollectInvocation" }, - { - "$ref": "#/components/schemas/CollectionCartesianInvocation" - }, - { - "$ref": "#/components/schemas/CollectionConcatInvocation" - }, - { - "$ref": "#/components/schemas/CollectionZipInvocation" - }, { "$ref": "#/components/schemas/ColorCorrectInvocation" }, @@ -46915,12 +46203,6 @@ { "$ref": "#/components/schemas/FluxVaeEncodeInvocation" }, - { - "$ref": "#/components/schemas/ForInvocation" - }, - { - "$ref": "#/components/schemas/ForReturnInvocation" - }, { "$ref": "#/components/schemas/FreeUInvocation" }, @@ -47434,18 +46716,6 @@ { "$ref": "#/components/schemas/SpandrelImageToImageInvocation" }, - { - "$ref": "#/components/schemas/StateEmptyInvocation" - }, - { - "$ref": "#/components/schemas/StateGetInvocation" - }, - { - "$ref": "#/components/schemas/StateMergeInvocation" - }, - { - "$ref": "#/components/schemas/StateSetInvocation" - }, { "$ref": "#/components/schemas/StringBatchInvocation" }, @@ -47637,15 +46907,6 @@ { "$ref": "#/components/schemas/CollectInvocationOutput" }, - { - "$ref": "#/components/schemas/CollectionCartesianInvocationOutput" - }, - { - "$ref": "#/components/schemas/CollectionConcatInvocationOutput" - }, - { - "$ref": "#/components/schemas/CollectionZipInvocationOutput" - }, { "$ref": "#/components/schemas/ColorCollectionOutput" }, @@ -47727,12 +46988,6 @@ { "$ref": "#/components/schemas/FluxReduxOutput" }, - { - "$ref": "#/components/schemas/ForInvocationOutput" - }, - { - "$ref": "#/components/schemas/ForReturnInvocationOutput" - }, { "$ref": "#/components/schemas/Gemma2EncoderOutput" }, @@ -47802,12 +47057,6 @@ { "$ref": "#/components/schemas/LoRASelectorOutput" }, - { - "$ref": "#/components/schemas/LoopStateOutput" - }, - { - "$ref": "#/components/schemas/LoopStateValueOutput" - }, { "$ref": "#/components/schemas/MDControlListOutput" }, @@ -48164,15 +47413,6 @@ { "$ref": "#/components/schemas/CollectInvocation" }, - { - "$ref": "#/components/schemas/CollectionCartesianInvocation" - }, - { - "$ref": "#/components/schemas/CollectionConcatInvocation" - }, - { - "$ref": "#/components/schemas/CollectionZipInvocation" - }, { "$ref": "#/components/schemas/ColorCorrectInvocation" }, @@ -48374,12 +47614,6 @@ { "$ref": "#/components/schemas/FluxVaeEncodeInvocation" }, - { - "$ref": "#/components/schemas/ForInvocation" - }, - { - "$ref": "#/components/schemas/ForReturnInvocation" - }, { "$ref": "#/components/schemas/FreeUInvocation" }, @@ -48893,18 +48127,6 @@ { "$ref": "#/components/schemas/SpandrelImageToImageInvocation" }, - { - "$ref": "#/components/schemas/StateEmptyInvocation" - }, - { - "$ref": "#/components/schemas/StateGetInvocation" - }, - { - "$ref": "#/components/schemas/StateMergeInvocation" - }, - { - "$ref": "#/components/schemas/StateSetInvocation" - }, { "$ref": "#/components/schemas/StringBatchInvocation" }, @@ -49187,15 +48409,6 @@ "collect": { "$ref": "#/components/schemas/CollectInvocationOutput" }, - "collection_cartesian": { - "$ref": "#/components/schemas/CollectionCartesianInvocationOutput" - }, - "collection_concat": { - "$ref": "#/components/schemas/CollectionConcatInvocationOutput" - }, - "collection_zip": { - "$ref": "#/components/schemas/CollectionZipInvocationOutput" - }, "color": { "$ref": "#/components/schemas/ColorOutput" }, @@ -49397,12 +48610,6 @@ "flux_vae_encode": { "$ref": "#/components/schemas/LatentsOutput" }, - "for": { - "$ref": "#/components/schemas/ForInvocationOutput" - }, - "for_return": { - "$ref": "#/components/schemas/ForReturnInvocationOutput" - }, "freeu": { "$ref": "#/components/schemas/UNetOutput" }, @@ -49916,18 +49123,6 @@ "spandrel_image_to_image_autoscale": { "$ref": "#/components/schemas/ImageOutput" }, - "state_empty": { - "$ref": "#/components/schemas/LoopStateOutput" - }, - "state_get": { - "$ref": "#/components/schemas/LoopStateValueOutput" - }, - "state_merge": { - "$ref": "#/components/schemas/LoopStateOutput" - }, - "state_set": { - "$ref": "#/components/schemas/LoopStateOutput" - }, "string": { "$ref": "#/components/schemas/StringOutput" }, @@ -50110,9 +49305,6 @@ "cogview4_model_loader", "cogview4_text_encoder", "collect", - "collection_cartesian", - "collection_concat", - "collection_zip", "color", "color_correct", "color_map", @@ -50180,8 +49372,6 @@ "flux_text_encoder", "flux_vae_decode", "flux_vae_encode", - "for", - "for_return", "freeu", "gemini_image_generation", "gemma2_encoder_loader", @@ -50353,10 +49543,6 @@ "show_image", "spandrel_image_to_image", "spandrel_image_to_image_autoscale", - "state_empty", - "state_get", - "state_merge", - "state_set", "string", "string_batch", "string_collection", @@ -50579,15 +49765,6 @@ { "$ref": "#/components/schemas/CollectInvocation" }, - { - "$ref": "#/components/schemas/CollectionCartesianInvocation" - }, - { - "$ref": "#/components/schemas/CollectionConcatInvocation" - }, - { - "$ref": "#/components/schemas/CollectionZipInvocation" - }, { "$ref": "#/components/schemas/ColorCorrectInvocation" }, @@ -50789,12 +49966,6 @@ { "$ref": "#/components/schemas/FluxVaeEncodeInvocation" }, - { - "$ref": "#/components/schemas/ForInvocation" - }, - { - "$ref": "#/components/schemas/ForReturnInvocation" - }, { "$ref": "#/components/schemas/FreeUInvocation" }, @@ -51308,18 +50479,6 @@ { "$ref": "#/components/schemas/SpandrelImageToImageInvocation" }, - { - "$ref": "#/components/schemas/StateEmptyInvocation" - }, - { - "$ref": "#/components/schemas/StateGetInvocation" - }, - { - "$ref": "#/components/schemas/StateMergeInvocation" - }, - { - "$ref": "#/components/schemas/StateSetInvocation" - }, { "$ref": "#/components/schemas/StringBatchInvocation" }, @@ -51702,15 +50861,6 @@ { "$ref": "#/components/schemas/CollectInvocation" }, - { - "$ref": "#/components/schemas/CollectionCartesianInvocation" - }, - { - "$ref": "#/components/schemas/CollectionConcatInvocation" - }, - { - "$ref": "#/components/schemas/CollectionZipInvocation" - }, { "$ref": "#/components/schemas/ColorCorrectInvocation" }, @@ -51912,12 +51062,6 @@ { "$ref": "#/components/schemas/FluxVaeEncodeInvocation" }, - { - "$ref": "#/components/schemas/ForInvocation" - }, - { - "$ref": "#/components/schemas/ForReturnInvocation" - }, { "$ref": "#/components/schemas/FreeUInvocation" }, @@ -52431,18 +51575,6 @@ { "$ref": "#/components/schemas/SpandrelImageToImageInvocation" }, - { - "$ref": "#/components/schemas/StateEmptyInvocation" - }, - { - "$ref": "#/components/schemas/StateGetInvocation" - }, - { - "$ref": "#/components/schemas/StateMergeInvocation" - }, - { - "$ref": "#/components/schemas/StateSetInvocation" - }, { "$ref": "#/components/schemas/StringBatchInvocation" }, @@ -60122,67 +59254,6 @@ "title": "LogoutResponse", "description": "Response from logout." }, - "LoopState": { - "properties": { - "values": { - "additionalProperties": true, - "title": "Values", - "type": "object" - } - }, - "title": "LoopState", - "type": "object" - }, - "LoopStateOutput": { - "class": "output", - "properties": { - "state": { - "$ref": "#/components/schemas/LoopState", - "description": "The loop state", - "field_kind": "output", - "ui_hidden": false - }, - "type": { - "const": "loop_state_output", - "default": "loop_state_output", - "field_kind": "node_attribute", - "title": "type", - "type": "string" - } - }, - "required": ["output_meta", "state", "type", "type"], - "title": "LoopStateOutput", - "type": "object" - }, - "LoopStateValueOutput": { - "class": "output", - "properties": { - "value": { - "anyOf": [ - {}, - { - "type": "null" - } - ], - "default": null, - "description": "The value read from the loop state, or None when the key is missing", - "field_kind": "output", - "title": "Value", - "ui_hidden": false, - "ui_type": "AnyField" - }, - "type": { - "const": "loop_state_value_output", - "default": "loop_state_value_output", - "field_kind": "node_attribute", - "title": "type", - "type": "string" - } - }, - "required": ["output_meta", "value", "type", "type"], - "title": "LoopStateValueOutput", - "type": "object" - }, "LoraModelDefaultSettings": { "properties": { "weight": { @@ -77917,29 +76988,12 @@ } ], "default": null - }, - "output_scope": { - "anyOf": [ - { - "$ref": "#/components/schemas/OutputScope" - }, - { - "type": "null" - } - ], - "default": null } }, - "required": ["field_kind", "ui_hidden", "ui_order", "ui_type", "output_scope"], + "required": ["field_kind", "ui_hidden", "ui_order", "ui_type"], "title": "OutputFieldJSONSchemaExtra", "type": "object" }, - "OutputScope": { - "description": "The execution scope for an output field.\n- `Iteration`: The field emits values for a loop body's current iteration.\n- `Final`: The field emits values after a loop boundary completes.", - "enum": ["iteration", "final"], - "title": "OutputScope", - "type": "string" - }, "PBRMapsInvocation": { "category": "controlnet_preprocessors", "class": "invocation", @@ -89187,309 +88241,6 @@ "required": ["description", "source", "name", "base", "type"], "title": "StarterModelWithoutDependencies" }, - "StateEmptyInvocation": { - "category": "workflow", - "class": "invocation", - "classification": "stable", - "description": "Creates an empty loop state.", - "node_pack": "invokeai", - "properties": { - "id": { - "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", - "field_kind": "node_attribute", - "title": "Id", - "type": "string" - }, - "is_intermediate": { - "default": false, - "description": "Whether or not this is an intermediate invocation.", - "field_kind": "node_attribute", - "input": "direct", - "orig_required": true, - "title": "Is Intermediate", - "type": "boolean", - "ui_hidden": false, - "ui_type": "IsIntermediate" - }, - "use_cache": { - "default": true, - "description": "Whether or not to use the cache", - "field_kind": "node_attribute", - "title": "Use Cache", - "type": "boolean" - }, - "type": { - "const": "state_empty", - "default": "state_empty", - "field_kind": "node_attribute", - "title": "type", - "type": "string" - } - }, - "required": ["type", "id"], - "tags": ["loop", "state"], - "title": "Empty Loop State", - "type": "object", - "version": "1.0.0", - "output": { - "$ref": "#/components/schemas/LoopStateOutput" - } - }, - "StateGetInvocation": { - "category": "workflow", - "class": "invocation", - "classification": "stable", - "description": "Reads a value from loop state.", - "node_pack": "invokeai", - "properties": { - "id": { - "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", - "field_kind": "node_attribute", - "title": "Id", - "type": "string" - }, - "is_intermediate": { - "default": false, - "description": "Whether or not this is an intermediate invocation.", - "field_kind": "node_attribute", - "input": "direct", - "orig_required": true, - "title": "Is Intermediate", - "type": "boolean", - "ui_hidden": false, - "ui_type": "IsIntermediate" - }, - "use_cache": { - "default": true, - "description": "Whether or not to use the cache", - "field_kind": "node_attribute", - "title": "Use Cache", - "type": "boolean" - }, - "state": { - "anyOf": [ - { - "$ref": "#/components/schemas/LoopState" - }, - { - "type": "null" - } - ], - "default": null, - "description": "The loop state to read", - "field_kind": "input", - "input": "any", - "orig_required": true - }, - "key": { - "default": "", - "description": "The state key to read", - "field_kind": "input", - "input": "any", - "orig_default": "", - "orig_required": false, - "title": "Key", - "type": "string" - }, - "default": { - "anyOf": [ - {}, - { - "type": "null" - } - ], - "default": null, - "description": "The value to return when the key is missing", - "input": "any", - "field_kind": "input", - "orig_required": false, - "orig_default": null, - "ui_type": "AnyField", - "title": "Default" - }, - "type": { - "const": "state_get", - "default": "state_get", - "field_kind": "node_attribute", - "title": "type", - "type": "string" - } - }, - "required": ["type", "id"], - "tags": ["loop", "state"], - "title": "Get Loop State Value", - "type": "object", - "version": "1.0.2", - "output": { - "$ref": "#/components/schemas/LoopStateValueOutput" - } - }, - "StateMergeInvocation": { - "category": "workflow", - "class": "invocation", - "classification": "stable", - "description": "Returns loop state with multiple values merged.", - "node_pack": "invokeai", - "properties": { - "id": { - "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", - "field_kind": "node_attribute", - "title": "Id", - "type": "string" - }, - "is_intermediate": { - "default": false, - "description": "Whether or not this is an intermediate invocation.", - "field_kind": "node_attribute", - "input": "direct", - "orig_required": true, - "title": "Is Intermediate", - "type": "boolean", - "ui_hidden": false, - "ui_type": "IsIntermediate" - }, - "use_cache": { - "default": true, - "description": "Whether or not to use the cache", - "field_kind": "node_attribute", - "title": "Use Cache", - "type": "boolean" - }, - "state": { - "anyOf": [ - { - "$ref": "#/components/schemas/LoopState" - }, - { - "type": "null" - } - ], - "default": null, - "description": "The loop state to update", - "field_kind": "input", - "input": "any", - "orig_default": null, - "orig_required": false - }, - "values": { - "additionalProperties": true, - "default": {}, - "description": "The values to merge into the loop state. Connect an output to this input.", - "field_kind": "input", - "input": "any", - "orig_default": {}, - "orig_required": false, - "title": "Values", - "type": "object", - "ui_type": "AnyField" - }, - "type": { - "const": "state_merge", - "default": "state_merge", - "field_kind": "node_attribute", - "title": "type", - "type": "string" - } - }, - "required": ["type", "id"], - "tags": ["loop", "state"], - "title": "Merge Loop State Values", - "type": "object", - "version": "1.0.1", - "output": { - "$ref": "#/components/schemas/LoopStateOutput" - } - }, - "StateSetInvocation": { - "category": "workflow", - "class": "invocation", - "classification": "stable", - "description": "Returns loop state with one value set.", - "node_pack": "invokeai", - "properties": { - "id": { - "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", - "field_kind": "node_attribute", - "title": "Id", - "type": "string" - }, - "is_intermediate": { - "default": false, - "description": "Whether or not this is an intermediate invocation.", - "field_kind": "node_attribute", - "input": "direct", - "orig_required": true, - "title": "Is Intermediate", - "type": "boolean", - "ui_hidden": false, - "ui_type": "IsIntermediate" - }, - "use_cache": { - "default": true, - "description": "Whether or not to use the cache", - "field_kind": "node_attribute", - "title": "Use Cache", - "type": "boolean" - }, - "state": { - "anyOf": [ - { - "$ref": "#/components/schemas/LoopState" - }, - { - "type": "null" - } - ], - "default": null, - "description": "The loop state to update", - "field_kind": "input", - "input": "any", - "orig_default": null, - "orig_required": false - }, - "key": { - "default": "", - "description": "The state key to set", - "field_kind": "input", - "input": "any", - "orig_default": "", - "orig_required": false, - "title": "Key", - "type": "string" - }, - "value": { - "anyOf": [ - {}, - { - "type": "null" - } - ], - "default": null, - "description": "The value to set. Connect an output to this input.", - "field_kind": "input", - "input": "any", - "orig_default": null, - "orig_required": false, - "title": "Value", - "ui_type": "AnyField" - }, - "type": { - "const": "state_set", - "default": "state_set", - "field_kind": "node_attribute", - "title": "type", - "type": "string" - } - }, - "required": ["type", "id"], - "tags": ["loop", "state"], - "title": "Set Loop State Value", - "type": "object", - "version": "1.0.1", - "output": { - "$ref": "#/components/schemas/LoopStateOutput" - } - }, "String2Output": { "class": "output", "description": "Base class for invocations that output two strings", @@ -93442,7 +92193,7 @@ "type": "object" }, "UIType": { - "description": "Type hints for the UI for situations in which the field type is not enough to infer the correct UI type.\n\n- Model Fields\nThe most common node-author-facing use will be for model fields. Internally, there is no difference\nbetween SD-1, SD-2 and SDXL model fields - they all use the class `MainModelField`. To ensure the\nbase-model-specific UI is rendered, use e.g. `ui_type=UIType.SDXLMainModelField` to indicate that\nthe field is an SDXL main model field.\n\n- Any Field\nWe cannot infer the usage of `typing.Any` via schema parsing, so you *must* use `ui_type=UIType.Any` to\nindicate that the field accepts any type. Use with caution. On inputs, this renders as a connection-only field.\n\n- Scheduler Field\nSpecial handling in the UI is needed for this field, which otherwise would be parsed as a plain enum field.\n\n- Internal Fields\nSimilar to the Any Field, the `collect` and `iterate` nodes make use of `typing.Any`. To facilitate\nhandling these types in the client, we use `UIType._Collection` and `UIType._CollectionItem`. These\nshould not be used by node authors.\n\n- DEPRECATED Fields\nThese types are deprecated and should not be used by node authors. A warning will be logged if one is\nused, and the type will be ignored. They are included here for backwards compatibility.", + "description": "Type hints for the UI for situations in which the field type is not enough to infer the correct UI type.\n\n- Model Fields\nThe most common node-author-facing use will be for model fields. Internally, there is no difference\nbetween SD-1, SD-2 and SDXL model fields - they all use the class `MainModelField`. To ensure the\nbase-model-specific UI is rendered, use e.g. `ui_type=UIType.SDXLMainModelField` to indicate that\nthe field is an SDXL main model field.\n\n- Any Field\nWe cannot infer the usage of `typing.Any` via schema parsing, so you *must* use `ui_type=UIType.Any` to\nindicate that the field accepts any type. Use with caution. This cannot be used on outputs.\n\n- Scheduler Field\nSpecial handling in the UI is needed for this field, which otherwise would be parsed as a plain enum field.\n\n- Internal Fields\nSimilar to the Any Field, the `collect` and `iterate` nodes make use of `typing.Any`. To facilitate\nhandling these types in the client, we use `UIType._Collection` and `UIType._CollectionItem`. These\nshould not be used by node authors.\n\n- DEPRECATED Fields\nThese types are deprecated and should not be used by node authors. A warning will be logged if one is\nused, and the type will be ignored. They are included here for backwards compatibility.", "enum": [ "SchedulerField", "AnyField", diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 8437970dcf3..9fbe7cdb6c4 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1550,30 +1550,6 @@ "colorCodeEdges": "Color-Code Edges", "colorCodeEdgesHelp": "Color-code edges according to their connected fields", "connectionWouldCreateCycle": "Connection would create a cycle", - "loopOutputScopeConflict": "Final loop outputs cannot feed the loop body", - "forLoopMissingIterationOutput": "For loop must have an iteration output", - "forLoopReturnCount": "For loop body must have one return", - "forLoopUnterminatedBody": "All For loop body paths must terminate at its return", - "forLoopNestedUnsupported": "This nested For loop shape is not supported", - "forLoopIterateUnsupported": "This Iterate shape inside the For body is not supported", - "forLoopIteratorInputUnsupported": "Iterator-derived external inputs cannot feed a For loop body", - "forLoopFinalOutputInBody": "Final For loop outputs cannot feed its body", - "forLoopBodyEscape": "For loop body outputs cannot escape before its return", - "forLoopLinkageMissing": "For loop linkage is missing between For and ForReturn", - "forLoopLinkageInvalid": "For loop linkage must connect a For to its ForReturn", - "forLoopLinkageDuplicate": "For loop linkage must connect each For and ForReturn exactly once", - "forLoopInputCount": "For loop may have only one collection and state input each", - "forReturnInputCount": "ForReturn may have only one output and state input each", - "forReturnOwnership": "For return must belong to exactly one For loop", - "forLoopBodyBoundary": "For loop body", - "forLoopBodyBoundaryStatus": { - "missing_linkage": "missing loop linkage", - "invalid_linkage": "invalid loop linkage", - "duplicate_linkage": "duplicate loop linkage", - "missing_return": "missing ForReturn", - "multiple_returns": "multiple ForReturns", - "orphan_return": "unowned ForReturn" - }, "currentImage": "Current Image", "currentImageDescription": "Displays the current image in the Node Editor", "downloadWorkflow": "Download Workflow JSON", @@ -1586,7 +1562,6 @@ "executionStateError": "Error", "executionStateInProgress": "In Progress", "fieldTypesMustMatch": "Field types must match", - "finalOutputs": "Final Outputs", "fitViewportNodes": "Fit View", "float": "Float", "fullyContainNodes": "Fully Contain Nodes to Select", @@ -1598,7 +1573,6 @@ "hideLegendNodes": "Hide Field Type Legend", "hideMinimapnodes": "Hide MiniMap", "inputMayOnlyHaveOneConnection": "Input may only have one connection", - "iterationOutputs": "Iteration Outputs", "integer": "Integer", "ipAdapter": "IP-Adapter", "loadingNodes": "Loading Nodes...", diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk.mounted.test.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk.mounted.test.tsx deleted file mode 100644 index 6702565ac97..00000000000 --- a/invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk.mounted.test.tsx +++ /dev/null @@ -1,319 +0,0 @@ -// @vitest-environment happy-dom -import { applyEdgeChanges, applyNodeChanges } from '@xyflow/react'; -import { - $addNodeCmdk, - $cursorPos, - $edgePendingUpdate, - $pendingConnection, - $templates, - edgesChanged, - nodesChanged, -} from 'features/nodes/store/nodesSlice'; -import type { PendingConnection } from 'features/nodes/store/types'; -import { CONNECTOR_INPUT_HANDLE, CONNECTOR_OUTPUT_HANDLE } from 'features/nodes/store/util/connectorTopology'; -import { buildEdge, buildLoopLinkageEdge, buildNode, for_loop, for_return } from 'features/nodes/store/util/testUtils'; -import type { AnyEdge, AnyNode } from 'features/nodes/types/invocation'; -import type { ChangeEvent, ReactNode } from 'react'; -import * as React from 'react'; -import { act } from 'react'; -import type { Root } from 'react-dom/client'; -import { createRoot } from 'react-dom/client'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -const mocks = vi.hoisted(() => ({ - nodes: [] as AnyNode[], - edges: [] as AnyEdge[], - dispatch: vi.fn(), - buildNode: vi.fn(), -})); - -vi.mock('app/store/storeHooks', () => { - const getState = () => ({ - nodes: { present: { nodes: mocks.nodes, edges: mocks.edges } }, - ui: { activeTab: 'workflows' }, - workflowSettings: { shouldGroupNodesByCategory: false }, - }); - - return { - useAppDispatch: () => mocks.dispatch, - useAppSelector: (selector: (state: ReturnType) => unknown) => selector(getState()), - useAppStore: () => ({ getState, dispatch: mocks.dispatch }), - }; -}); - -vi.mock('features/nodes/hooks/useBuildNode', () => ({ - useBuildNode: () => mocks.buildNode, -})); - -vi.mock('features/system/components/HotkeysModal/useHotkeyData', () => ({ - useRegisteredHotkeys: () => undefined, -})); - -vi.mock('features/toast/toast', () => ({ - toast: vi.fn(), -})); - -vi.mock('common/components/IAIImageFallback', () => ({ - IAINoContentFallback: () => null, -})); - -vi.mock('common/components/OverlayScrollbars/ScrollableContent', () => ({ - default: ({ children }: { children: ReactNode }) =>
{children}
, -})); - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => - ({ - 'nodes.nodeSearch': 'Search nodes', - 'common.noMatchingItems': 'No matching items', - 'common.expandAll': 'Expand all', - 'common.collapseAll': 'Collapse all', - })[key] ?? key, - }), -})); - -vi.mock('@invoke-ai/ui-library', () => { - type Props = { - children?: ReactNode; - onClick?: () => void; - onChange?: (event: ChangeEvent) => void; - placeholder?: string; - value?: string; - [key: string]: unknown; - }; - - const getDomProps = ({ children: _children, ...props }: Props): React.HTMLAttributes => - Object.fromEntries( - Object.entries(props).filter( - ([key]) => - key === 'aria-label' || - key === 'onClick' || - key === 'onChange' || - key === 'onKeyDown' || - key === 'onPointerMove' || - key === 'placeholder' || - key === 'role' || - key === 'tabIndex' || - key === 'value' || - key.startsWith('aria-') || - key.startsWith('data-') - ) - ) as React.HTMLAttributes; - - const Flex = React.forwardRef(function Flex( - { children, ...props }: Props, - ref: React.ForwardedRef - ) { - return ( -
- {children} -
- ); - }); - const Input = React.forwardRef(function Input( - { children, ...props }: Props, - ref: React.ForwardedRef - ) { - return ; - }); - const Box = ({ children }: Props) =>
{children}
; - const Text = ({ children }: Props) => {children}; - const Button = ({ children, ...props }: Props) => ( - - ); - const Modal = ({ children, isOpen }: Props & { isOpen?: boolean }) => (isOpen ?
{children}
: null); - const passthrough = ({ children }: Props) =>
{children}
; - const Icon = () => ; - const ModalOverlay = () => null; - const Spacer = () => ; - - Box.displayName = 'Box'; - Text.displayName = 'Text'; - Button.displayName = 'Button'; - Modal.displayName = 'Modal'; - passthrough.displayName = 'passthrough'; - Icon.displayName = 'Icon'; - ModalOverlay.displayName = 'ModalOverlay'; - Spacer.displayName = 'Spacer'; - - return { - Box, - Button, - Flex, - Icon, - Input, - Modal, - ModalBody: passthrough, - ModalContent: passthrough, - ModalOverlay, - Portal: passthrough, - Spacer, - Text, - }; -}); - -import { AddNodeCmdk } from './AddNodeCmdk/AddNodeCmdk'; - -declare global { - var IS_REACT_ACT_ENVIRONMENT: boolean; -} -globalThis.IS_REACT_ACT_ENVIRONMENT = true; - -const setNodeId = (node: AnyNode, id: string): AnyNode => { - node.id = id; - node.data.id = id; - return node; -}; - -const buildConnector = (id: string): AnyNode => ({ - id, - type: 'connector', - position: { x: 0, y: 0 }, - data: { - id, - type: 'connector', - label: 'Connector', - isOpen: true, - }, -}); - -describe('AddNodeCmdk (mounted)', () => { - let container: HTMLDivElement; - let root: Root; - - beforeEach(() => { - const forNode = setNodeId(buildNode(for_loop), 'for-node'); - mocks.nodes = [forNode]; - mocks.edges = []; - mocks.dispatch.mockReset(); - mocks.dispatch.mockImplementation((action: unknown) => { - if (nodesChanged.match(action)) { - mocks.nodes = applyNodeChanges(action.payload, mocks.nodes); - } - if (edgesChanged.match(action)) { - mocks.edges = applyEdgeChanges(action.payload, mocks.edges); - } - return action; - }); - mocks.buildNode.mockReset(); - mocks.buildNode.mockReturnValue(setNodeId(buildNode(for_return), 'return-node')); - - $templates.set({ for: for_loop, for_return }); - $addNodeCmdk.set(true); - $cursorPos.set({ x: 0, y: 0 }); - $edgePendingUpdate.set(null); - $pendingConnection.set({ - nodeId: 'for-node', - handleId: 'item', - handleType: 'source', - fieldTemplate: for_loop.outputs.item as PendingConnection['fieldTemplate'], - }); - - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - }); - - afterEach(() => { - act(() => { - root.unmount(); - }); - container.remove(); - $addNodeCmdk.set(false); - $cursorPos.set(null); - $edgePendingUpdate.set(null); - $pendingConnection.set(null); - $templates.set({}); - }); - - it('adds ForReturn with loop linkage and auto-connects its output', () => { - act(() => { - root.render(); - }); - - const returnItem = Array.from(container.querySelectorAll('[role="button"]')).find((element) => - element.textContent?.trim().startsWith('ForReturn') - ); - expect(returnItem).toBeDefined(); - - act(() => { - returnItem?.dispatchEvent(new MouseEvent('click', { bubbles: true })); - }); - - expect(mocks.edges).toEqual([ - expect.objectContaining({ - source: 'for-node', - sourceHandle: 'item', - target: 'return-node', - targetHandle: 'output', - }), - expect.objectContaining({ - type: 'loop_linkage', - source: 'for-node', - sourceHandle: 'loop_linkage', - target: 'return-node', - targetHandle: 'loop_linkage', - }), - ]); - expect($addNodeCmdk.get()).toBe(false); - expect($pendingConnection.get()).toBeNull(); - }); - - it('adds loop linkage when an iteration output is routed through a connector', () => { - const connector = buildConnector('connector'); - mocks.nodes = [mocks.nodes[0]!, connector]; - mocks.edges = [buildEdge('for-node', 'item', connector.id, CONNECTOR_INPUT_HANDLE)]; - $pendingConnection.set({ - nodeId: connector.id, - handleId: CONNECTOR_OUTPUT_HANDLE, - handleType: 'source', - fieldTemplate: { - ...for_loop.outputs.item, - name: CONNECTOR_OUTPUT_HANDLE, - } as PendingConnection['fieldTemplate'], - }); - - act(() => { - root.render(); - }); - - const returnItem = Array.from(container.querySelectorAll('[role="button"]')).find((element) => - element.textContent?.trim().startsWith('ForReturn') - ); - act(() => { - returnItem?.dispatchEvent(new MouseEvent('click', { bubbles: true })); - }); - - expect(mocks.edges).toContainEqual( - expect.objectContaining({ - type: 'loop_linkage', - source: 'for-node', - sourceHandle: 'loop_linkage', - target: 'return-node', - targetHandle: 'loop_linkage', - }) - ); - }); - - it('does not add a duplicate linkage when the For is already paired', () => { - const existingReturn = setNodeId(buildNode(for_return), 'existing-return'); - mocks.nodes = [mocks.nodes[0]!, existingReturn]; - mocks.edges = [buildLoopLinkageEdge('for-node', existingReturn.id)]; - - act(() => { - root.render(); - }); - - const returnItem = Array.from(container.querySelectorAll('[role="button"]')).find((element) => - element.textContent?.trim().startsWith('ForReturn') - ); - act(() => { - returnItem?.dispatchEvent(new MouseEvent('click', { bubbles: true })); - }); - - expect(mocks.edges.filter((edge) => edge.type === 'loop_linkage')).toEqual([ - buildLoopLinkageEdge('for-node', existingReturn.id), - ]); - }); -}); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk.test.ts b/invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk.test.ts deleted file mode 100644 index 0f31d8868fa..00000000000 --- a/invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import type { PendingConnection } from 'features/nodes/store/types'; -import { CONNECTOR_INPUT_HANDLE, CONNECTOR_OUTPUT_HANDLE } from 'features/nodes/store/util/connectorTopology'; -import { add, buildEdge, buildNode, for_loop, for_return, templates } from 'features/nodes/store/util/testUtils'; -import type { InvocationTemplate } from 'features/nodes/types/invocation'; -import { describe, expect, it } from 'vitest'; - -import { getPendingConnectionNodeItems, sortNodeCommandItemGroups, sortNodeCommandItems } from './AddNodeCmdk'; - -describe('getPendingConnectionNodeItems', () => { - it('prioritizes ForReturn for an iteration output connection', () => { - const pendingConnection: PendingConnection = { - nodeId: 'for-node', - handleId: 'item', - handleType: 'source' as const, - fieldTemplate: for_loop.outputs.item as PendingConnection['fieldTemplate'], - }; - - const items = getPendingConnectionNodeItems([add, for_return], pendingConnection, ''); - - expect(items[0]?.value).toBe('for_return'); - }); - - it('only offers ForReturn for a For loop linkage connection', () => { - const pendingConnection: PendingConnection = { - nodeId: 'for-node', - handleId: 'loop_linkage', - handleType: 'source' as const, - fieldTemplate: for_loop.outputs.loop_linkage as PendingConnection['fieldTemplate'], - }; - - const items = getPendingConnectionNodeItems([add, for_return], pendingConnection, ''); - - expect(items.map((item) => item.value)).toEqual(['for_return']); - }); - - it('only offers For for a ForReturn loop linkage connection', () => { - const pendingConnection: PendingConnection = { - nodeId: 'return-node', - handleId: 'loop_linkage', - handleType: 'target' as const, - fieldTemplate: for_return.inputs.loop_linkage as PendingConnection['fieldTemplate'], - }; - - const items = getPendingConnectionNodeItems([add, for_loop], pendingConnection, ''); - - expect(items.map((item) => item.value)).toEqual(['for']); - }); - - it('keeps ForReturn first after exact-title search ranking', () => { - const pendingConnection: PendingConnection = { - nodeId: 'for-node', - handleId: 'item', - handleType: 'source' as const, - fieldTemplate: for_loop.outputs.item as PendingConnection['fieldTemplate'], - }; - - const items = getPendingConnectionNodeItems([for_loop, for_return], pendingConnection, 'for'); - - const sortedItems = sortNodeCommandItems(items, 'for', pendingConnection); - - expect(sortedItems.map((item) => item.value)).toEqual(['for_return', 'for']); - }); - - it('prioritizes ForReturn when the iteration output passes through a connector', () => { - const forNode = buildNode(for_loop); - forNode.id = 'for-node'; - const connector = { - id: 'connector-node', - type: 'connector' as const, - position: { x: 0, y: 0 }, - data: { id: 'connector-node', type: 'connector' as const, label: 'Connector', isOpen: true }, - }; - const pendingConnection: PendingConnection = { - nodeId: connector.id, - handleId: CONNECTOR_OUTPUT_HANDLE, - handleType: 'source' as const, - fieldTemplate: { - name: CONNECTOR_OUTPUT_HANDLE, - title: 'Connector Output', - description: '', - fieldKind: 'output', - ui_hidden: false, - type: { name: 'AnyField', cardinality: 'SINGLE', batch: false }, - }, - }; - - const items = getPendingConnectionNodeItems([add, for_return], pendingConnection, '', { - nodes: [forNode, connector], - edges: [buildEdge(forNode.id, 'item', connector.id, CONNECTOR_INPUT_HANDLE)], - templates: { ...templates, for: for_loop, for_return }, - }); - - expect(items[0]?.value).toBe('for_return'); - }); - - it('preserves generic pending connection ordering for non-loop outputs', () => { - const pendingConnection: PendingConnection = { - nodeId: 'add-node', - handleId: 'value', - handleType: 'source' as const, - fieldTemplate: add.outputs.value as PendingConnection['fieldTemplate'], - }; - - const items = getPendingConnectionNodeItems([add, for_return], pendingConnection, ''); - - expect(items.map((item) => item.value)).toEqual(['add', 'for_return']); - }); - - it('preserves exact-title ranking for a non-loop pending connection', () => { - const pendingConnection: PendingConnection = { - nodeId: 'add-node', - handleId: 'value', - handleType: 'source' as const, - fieldTemplate: add.outputs.value as PendingConnection['fieldTemplate'], - }; - const addOther = { ...add, title: 'Add Other', type: 'add_other' } as InvocationTemplate; - - const items = getPendingConnectionNodeItems([add, addOther], pendingConnection, 'add'); - const sortedItems = sortNodeCommandItems(items, 'add', pendingConnection); - - expect(sortedItems.map((item) => item.value)).toEqual(['add', 'add_other']); - }); -}); - -describe('sortNodeCommandItemGroups', () => { - it('promotes the category containing ForReturn for an iteration output connection', () => { - const addItem = getPendingConnectionNodeItems( - [add], - { - nodeId: 'add-node', - handleId: 'value', - handleType: 'source', - fieldTemplate: add.outputs.value as PendingConnection['fieldTemplate'], - }, - '' - )[0]; - const forReturnItem = getPendingConnectionNodeItems( - [for_return], - { - nodeId: 'for-node', - handleId: 'item', - handleType: 'source', - fieldTemplate: for_loop.outputs.item as PendingConnection['fieldTemplate'], - }, - '' - )[0]; - if (!addItem || !forReturnItem) { - throw new Error('Expected command items'); - } - - const groups = sortNodeCommandItemGroups( - [ - ['math', [addItem]], - ['other', [forReturnItem]], - ], - '', - true - ); - - expect(groups.map(([category]) => category)).toEqual(['other', 'math']); - }); -}); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk.tsx index 10d5cbbfb45..8f27a83c14f 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk.tsx @@ -31,15 +31,12 @@ import { nodesChanged, } from 'features/nodes/store/nodesSlice'; import { selectNodesSlice } from 'features/nodes/store/selectors'; -import type { PendingConnection, Templates } from 'features/nodes/store/types'; -import { resolvePendingConnectionSource } from 'features/nodes/store/util/connectorTopology'; import { findUnoccupiedPosition } from 'features/nodes/store/util/findUnoccupiedPosition'; import { getFirstValidConnection } from 'features/nodes/store/util/getFirstValidConnection'; import { connectionToEdge } from 'features/nodes/store/util/reactFlowUtil'; import { validateConnectionTypes } from 'features/nodes/store/util/validateConnectionTypes'; import { selectShouldGroupNodesByCategory } from 'features/nodes/store/workflowSettingsSlice'; -import { LOOP_LINKAGE_FIELD } from 'features/nodes/types/constants'; -import type { AnyEdge, AnyNode, InvocationTemplate } from 'features/nodes/types/invocation'; +import type { AnyEdge, AnyNode } from 'features/nodes/types/invocation'; import { isInvocationNode } from 'features/nodes/types/invocation'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { toast } from 'features/toast/toast'; @@ -57,6 +54,7 @@ import { PiLightningFill, } from 'react-icons/pi'; import type { S } from 'services/api/types'; +import { objectEntries } from 'tsafe'; import { useDebounce } from 'use-debounce'; const useAddNode = () => { @@ -132,34 +130,6 @@ const useAddNode = () => { if (connection) { const newEdge = connectionToEdge(connection); store.dispatch(edgesChanged([{ type: 'add', item: newEdge }])); - - const resolvedSource = resolvePendingConnectionSource(pendingConnection, nodes, edges, templates); - const sourceNode = resolvedSource - ? nodes.find((candidate) => candidate.id === resolvedSource.nodeId) - : nodes.find((candidate) => candidate.id === source); - if ( - newEdge.type === 'default' && - node.data.type === 'for_return' && - sourceNode && - isInvocationNode(sourceNode) && - sourceNode.data.type === 'for' && - resolvedSource?.outputScope === 'iteration' && - !edges.some((edge) => edge.type === 'loop_linkage' && edge.source === sourceNode.id) - ) { - store.dispatch( - edgesChanged([ - { - type: 'add', - item: connectionToEdge({ - source: sourceNode.id, - sourceHandle: LOOP_LINKAGE_FIELD, - target: node.id, - targetHandle: LOOP_LINKAGE_FIELD, - }), - }, - ]) - ); - } } } }, @@ -373,161 +343,6 @@ const filter = memoize( (item: FilterableItem, searchTerm: string) => `${item.type}-${searchTerm}` ); -type PendingConnectionContext = { - nodes: AnyNode[]; - edges: AnyEdge[]; - templates: Templates; -}; - -const isForIterationOutputConnection = ( - pendingConnection: PendingConnection | null, - context?: PendingConnectionContext -) => { - if (!pendingConnection || pendingConnection.handleType !== 'source') { - return false; - } - - const resolvedSource = context - ? resolvePendingConnectionSource(pendingConnection, context.nodes, context.edges, context.templates) - : null; - if (resolvedSource && context) { - const sourceNode = context.nodes.find((node) => node.id === resolvedSource.nodeId); - return ( - isInvocationNode(sourceNode) && - sourceNode.data.type === 'for' && - (resolvedSource.outputScope === 'iteration' || - (resolvedSource.nodeId === pendingConnection.nodeId && - pendingConnection.fieldTemplate.fieldKind === 'output' && - pendingConnection.fieldTemplate.output_scope === 'iteration')) - ); - } - - return ( - pendingConnection.fieldTemplate.fieldKind === 'output' && - pendingConnection.fieldTemplate.output_scope === 'iteration' - ); -}; - -export const getPendingConnectionNodeItems = ( - templatesArray: InvocationTemplate[], - pendingConnection: PendingConnection, - searchTerm: string, - context?: PendingConnectionContext -): NodeCommandItemData[] => { - const items: NodeCommandItemData[] = []; - - for (const template of templatesArray) { - if (!filter(template, searchTerm)) { - continue; - } - - if ( - pendingConnection.handleId === LOOP_LINKAGE_FIELD && - template.type !== (pendingConnection.handleType === 'source' ? 'for_return' : 'for') - ) { - continue; - } - - const candidateFields = pendingConnection.handleType === 'source' ? template.inputs : template.outputs; - for (const fieldTemplate of Object.values(candidateFields)) { - const sourceType = - pendingConnection.handleType === 'source' ? pendingConnection.fieldTemplate.type : fieldTemplate.type; - const targetType = - pendingConnection.handleType === 'target' ? pendingConnection.fieldTemplate.type : fieldTemplate.type; - - if (validateConnectionTypes(sourceType, targetType)) { - items.push({ - label: template.title, - value: template.type, - description: template.description, - classification: template.classification, - nodePack: template.nodePack, - category: template.category, - }); - break; - } - } - } - - return sortNodeCommandItems(items, searchTerm, pendingConnection, context); -}; - -export const sortNodeCommandItems = ( - items: NodeCommandItemData[], - searchTerm: string, - pendingConnection: PendingConnection | null, - context?: PendingConnectionContext -): NodeCommandItemData[] => { - const sortedItems = [...items]; - const shouldPromoteForReturn = isForIterationOutputConnection(pendingConnection, context); - const lowerSearch = searchTerm.toLowerCase(); - - sortedItems.sort((a, b) => { - // Contextual ForReturn priority is a hard first key, including when For is an exact title match. - if (shouldPromoteForReturn) { - if (a.value === 'for_return' && b.value !== 'for_return') { - return -1; - } - if (a.value !== 'for_return' && b.value === 'for_return') { - return 1; - } - } - - if (searchTerm) { - const aExact = a.label.toLowerCase() === lowerSearch; - const bExact = b.label.toLowerCase() === lowerSearch; - if (aExact && !bExact) { - return -1; - } - if (!aExact && bExact) { - return 1; - } - } - - return 0; - }); - - return sortedItems; -}; - -export const sortNodeCommandItemGroups = ( - groups: [string, NodeCommandItemData[]][], - searchTerm: string, - shouldPromoteForReturn: boolean -): [string, NodeCommandItemData[]][] => { - const lowerSearch = searchTerm.toLowerCase(); - return [...groups].sort(([a, aItems], [b, bItems]) => { - if (shouldPromoteForReturn) { - const aHasForReturn = aItems.some((item) => item.value === 'for_return'); - const bHasForReturn = bItems.some((item) => item.value === 'for_return'); - if (aHasForReturn && !bHasForReturn) { - return -1; - } - if (!aHasForReturn && bHasForReturn) { - return 1; - } - } - - if (searchTerm) { - const aHasExact = aItems.some((item) => item.label.toLowerCase() === lowerSearch); - const bHasExact = bItems.some((item) => item.label.toLowerCase() === lowerSearch); - if (aHasExact && !bHasExact) { - return -1; - } - if (!aHasExact && bHasExact) { - return 1; - } - } - if (a === 'other') { - return 1; - } - if (b === 'other') { - return -1; - } - return a.localeCompare(b); - }); -}; - const categoryItemSx: SystemStyleObject = { cursor: 'pointer', userSelect: 'none', @@ -581,13 +396,7 @@ const NodeCommandList = memo( }) => { const { t } = useTranslation(); const templatesArray = useStore($templatesArray); - const templates = useStore($templates); const pendingConnection = useStore($pendingConnection); - const { nodes, edges } = useAppSelector(selectNodesSlice); - const pendingConnectionContext = useMemo( - () => ({ nodes, edges, templates }), - [nodes, edges, templates] - ); const shouldGroupNodesByCategory = useAppSelector(selectShouldGroupNodesByCategory); const currentImageFilterItem = useMemo( () => ({ @@ -645,22 +454,50 @@ const NodeCommandList = memo( } } } else { - _items.push( - ...getPendingConnectionNodeItems(templatesArray, pendingConnection, searchTerm, pendingConnectionContext) - ); + for (const template of templatesArray) { + if (filter(template, searchTerm)) { + const candidateFields = pendingConnection.handleType === 'source' ? template.inputs : template.outputs; + + for (const [_fieldName, fieldTemplate] of objectEntries(candidateFields)) { + const sourceType = + pendingConnection.handleType === 'source' ? pendingConnection.fieldTemplate.type : fieldTemplate.type; + const targetType = + pendingConnection.handleType === 'target' ? pendingConnection.fieldTemplate.type : fieldTemplate.type; + + if (validateConnectionTypes(sourceType, targetType)) { + _items.push({ + label: template.title, + value: template.type, + description: template.description, + classification: template.classification, + nodePack: template.nodePack, + category: template.category, + }); + break; + } + } + } + } } - return sortNodeCommandItems(_items, searchTerm, pendingConnection, pendingConnectionContext); - }, [ - pendingConnection, - templatesArray, - pendingConnectionContext, - searchTerm, - currentImageFilterItem, - notesFilterItem, - ]); + // Sort exact title matches to the top when searching + if (searchTerm) { + const lowerSearch = searchTerm.toLowerCase(); + _items.sort((a, b) => { + const aExact = a.label.toLowerCase() === lowerSearch; + const bExact = b.label.toLowerCase() === lowerSearch; + if (aExact && !bExact) { + return -1; + } + if (!aExact && bExact) { + return 1; + } + return 0; + }); + } - const shouldPromoteForReturn = isForIterationOutputConnection(pendingConnection, pendingConnectionContext); + return _items; + }, [pendingConnection, templatesArray, searchTerm, currentImageFilterItem, notesFilterItem]); const groupedItems = useMemo(() => { const groups: Record = {}; @@ -671,8 +508,29 @@ const NodeCommandList = memo( } groups[cat].push(item); } - return sortNodeCommandItemGroups(Object.entries(groups), searchTerm, shouldPromoteForReturn); - }, [items, searchTerm, shouldPromoteForReturn]); + // Sort categories alphabetically, but put "other" last. + // When searching, prioritize categories that contain an exact title match. + const lowerSearch = searchTerm.toLowerCase(); + return Object.entries(groups).sort(([a, aItems], [b, bItems]) => { + if (searchTerm) { + const aHasExact = aItems.some((item) => item.label.toLowerCase() === lowerSearch); + const bHasExact = bItems.some((item) => item.label.toLowerCase() === lowerSearch); + if (aHasExact && !bHasExact) { + return -1; + } + if (!aHasExact && bHasExact) { + return 1; + } + } + if (a === 'other') { + return 1; + } + if (b === 'other') { + return -1; + } + return a.localeCompare(b); + }); + }, [items, searchTerm]); // When searching, auto-expand all categories; when not searching, use manual state const isSearching = searchTerm.length > 0; @@ -708,10 +566,7 @@ const NodeCommandList = memo( )} {groupedItems.map(([category, categoryItems]) => { - const isExpanded = - isSearching || - expandedCategories.has(category) || - (shouldPromoteForReturn && categoryItems.some((item) => item.value === 'for_return')); + const isExpanded = isSearching || expandedCategories.has(category); return ( diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx index daa7147dafb..697d1a1182d 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx @@ -48,10 +48,7 @@ import { selectNodes, selectNodesSlice, } from 'features/nodes/store/selectors'; -import { - getConnectorDeletionSpliceConnections, - getEdgesWithLoopLinkageAliases, -} from 'features/nodes/store/util/connectorTopology'; +import { getConnectorDeletionSpliceConnections } from 'features/nodes/store/util/connectorTopology'; import { connectionToEdge } from 'features/nodes/store/util/reactFlowUtil'; import { validateConnection } from 'features/nodes/store/util/validateConnection'; import { selectSelectionMode, selectShouldSnapToGrid } from 'features/nodes/store/workflowSettingsSlice'; @@ -68,8 +65,6 @@ import { PiPlugsConnectedBold, PiTrashBold } from 'react-icons/pi'; import CustomConnectionLine from './connectionLines/CustomConnectionLine'; import InvocationCollapsedEdge from './edges/InvocationCollapsedEdge'; import InvocationDefaultEdge from './edges/InvocationDefaultEdge'; -import InvocationLoopLinkageEdge from './edges/InvocationLoopLinkageEdge'; -import LoopBodyBoundaryOverlay from './LoopBodyBoundaryOverlay'; import ConnectorNode from './nodes/Connector/ConnectorNode'; import CurrentImageNode from './nodes/CurrentImage/CurrentImageNode'; import InvocationNodeWrapper from './nodes/Invocation/InvocationNodeWrapper'; @@ -81,7 +76,6 @@ import { isWorkflowHotkeyEnabled, shouldIgnoreWorkflowCopyHotkey } from './workf const edgeTypes = { collapsed: InvocationCollapsedEdge, default: InvocationDefaultEdge, - loop_linkage: InvocationLoopLinkageEdge, } as const; const nodeTypes = { @@ -294,7 +288,7 @@ export const Flow = memo(() => { const onEdgeDoubleClick = useCallback>( (event, edge) => { - if (edge.hidden || (edge.type !== 'default' && edge.type !== 'loop_linkage')) { + if (edge.type !== 'default' || edge.hidden) { return; } const flow = $flow.get(); @@ -377,7 +371,7 @@ export const Flow = memo(() => { const renderedNodes = useMemo(() => nodes, [nodes]); - const renderedEdges = useMemo(() => getEdgesWithLoopLinkageAliases(nodes, edges), [edges, nodes]); + const renderedEdges = useMemo(() => edges, [edges]); const contextMenuPosition = contextMenuState ? { x: contextMenuState.pageX, y: contextMenuState.pageY } : null; const contextMenuKey = contextMenuPosition ? `${contextMenuPosition.x}-${contextMenuPosition.y}` : 'closed'; @@ -388,7 +382,6 @@ export const Flow = memo(() => { viewport={viewport} renderedNodes={renderedNodes} renderedEdges={renderedEdges} - boundaryEdges={edges} onInit={onInit} onMouseMove={onMouseMove} onNodesChange={onNodesChange} @@ -440,7 +433,6 @@ type FlowSurfaceProps = { viewport: ReactFlowProps['defaultViewport']; renderedNodes: AnyNode[]; renderedEdges: AnyEdge[]; - boundaryEdges: AnyEdge[]; onInit: OnInit; onMouseMove: (event: MouseEvent) => void; onNodesChange: OnNodesChange; @@ -466,7 +458,6 @@ const FlowSurface = memo((props: FlowSurfaceProps) => { viewport, renderedNodes, renderedEdges, - boundaryEdges, onInit, onMouseMove, onNodesChange, @@ -532,7 +523,6 @@ const FlowSurface = memo((props: FlowSurfaceProps) => { noPanClassName={NO_PAN_CLASS} > - ); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/LoopBodyBoundaryOverlay.test.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/LoopBodyBoundaryOverlay.test.tsx deleted file mode 100644 index e0eadcc6ad2..00000000000 --- a/invokeai/frontend/web/src/features/nodes/components/flow/LoopBodyBoundaryOverlay.test.tsx +++ /dev/null @@ -1,129 +0,0 @@ -// @vitest-environment happy-dom -import { buildEdge, buildLoopLinkageEdge, buildNode, for_loop, for_return } from 'features/nodes/store/util/testUtils'; -import type { AnyEdge, AnyNode } from 'features/nodes/types/invocation'; -import type { ReactNode } from 'react'; -import { act } from 'react'; -import type { Root } from 'react-dom/client'; -import { createRoot } from 'react-dom/client'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import LoopBodyBoundaryOverlay from './LoopBodyBoundaryOverlay'; - -const flowMocks = vi.hoisted(() => ({ - nodes: [] as AnyNode[], - getNodesBounds: vi.fn(() => ({ x: 10, y: 20, width: 100, height: 200 })), -})); - -vi.mock('@xyflow/react', () => ({ - useNodes: () => flowMocks.nodes, - useReactFlow: () => ({ getNodesBounds: flowMocks.getNodesBounds }), - ViewportPortal: ({ children }: { children: ReactNode }) => children, -})); - -vi.mock('@invoke-ai/ui-library', () => ({ - Box: ({ children, ...props }: { children: ReactNode; [key: string]: unknown }) => { - const domProps = Object.fromEntries( - Object.entries(props).filter(([key]) => key === 'title' || key.startsWith('aria-') || key.startsWith('data-')) - ); - return
{children}
; - }, - Text: ({ children }: { children: ReactNode }) => {children}, -})); - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => { - if (key === 'nodes.forLoopBodyBoundary') { - return 'For loop body'; - } - if (key === 'nodes.forLoopBodyBoundaryStatus.invalid_linkage') { - return 'invalid loop linkage'; - } - return key; - }, - }), -})); - -declare global { - var IS_REACT_ACT_ENVIRONMENT: boolean; -} -globalThis.IS_REACT_ACT_ENVIRONMENT = true; - -const setNodeId = (node: AnyNode, id: string): AnyNode => { - node.id = id; - node.data.id = id; - return node; -}; - -const edge = (source: string, sourceHandle: string, target: string, targetHandle: string): AnyEdge => - buildEdge(source, sourceHandle, target, targetHandle); - -describe('LoopBodyBoundaryOverlay', () => { - let container: HTMLDivElement; - let root: Root; - - beforeEach(() => { - flowMocks.nodes = []; - flowMocks.getNodesBounds.mockClear(); - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - }); - - afterEach(() => { - act(() => { - root.unmount(); - }); - container.remove(); - }); - - const renderBoundary = (withLinkage: boolean) => { - const forNode = setNodeId(buildNode(for_loop), 'for'); - const returnNode = setNodeId(buildNode(for_return), 'return'); - flowMocks.nodes = [forNode, returnNode]; - - act(() => { - root.render( - - ); - }); - }; - - it('renders the loop body label for a valid linkage', () => { - renderBoundary(true); - - const boundary = container.querySelector('[data-loop-body-boundary="for"]'); - expect(boundary?.getAttribute('aria-label')).toBe('For loop body'); - expect(boundary?.textContent).toBe('For loop body'); - expect(boundary?.getAttribute('data-loop-body-status')).toBe('complete'); - }); - - it('labels a body with missing linkage', () => { - renderBoundary(false); - - const boundary = container.querySelector('[data-loop-body-boundary="for"]'); - expect(boundary?.getAttribute('aria-label')).toBe( - 'For loop body - nodes.forLoopBodyBoundaryStatus.missing_linkage' - ); - expect(boundary?.getAttribute('data-loop-body-status')).toBe('missing_linkage'); - }); - - it('includes validation status for a detached linkage', () => { - const forNode = setNodeId(buildNode(for_loop), 'for'); - const returnNode = setNodeId(buildNode(for_return), 'return'); - flowMocks.nodes = [forNode, returnNode]; - - act(() => { - root.render(); - }); - - const boundary = container.querySelector('[data-loop-body-boundary="for"]'); - expect(boundary?.getAttribute('aria-label')).toBe('For loop body - invalid loop linkage'); - expect(boundary?.getAttribute('data-loop-body-status')).toBe('invalid_linkage'); - }); -}); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/edges/InvocationLoopLinkageEdge.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/edges/InvocationLoopLinkageEdge.tsx deleted file mode 100644 index 7e91962a500..00000000000 --- a/invokeai/frontend/web/src/features/nodes/components/flow/edges/InvocationLoopLinkageEdge.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import type { SystemStyleObject } from '@invoke-ai/ui-library'; -import { chakra } from '@invoke-ai/ui-library'; -import type { EdgeProps } from '@xyflow/react'; -import { BaseEdge, getBezierPath } from '@xyflow/react'; -import { useAppSelector } from 'app/store/storeHooks'; -import { buildSelectAreConnectedNodesSelected } from 'features/nodes/components/flow/edges/util/buildEdgeSelectors'; -import { selectShouldAnimateEdges } from 'features/nodes/store/workflowSettingsSlice'; -import type { LoopLinkageInvocationNodeEdge } from 'features/nodes/types/invocation'; -import { memo, useMemo } from 'react'; - -const ChakraBaseEdge = chakra(BaseEdge); - -const edgeSx: SystemStyleObject = { - strokeWidth: '2px !important', - stroke: 'green.500 !important', - strokeDasharray: '6 4', - opacity: '0.75 !important', - '&[data-selected="true"]': { - opacity: '1 !important', - }, - '&[data-should-animate-edges="true"]': { - animation: 'dashdraw 0.5s linear infinite !important', - }, -}; - -const InvocationLoopLinkageEdge = ({ - sourceX, - sourceY, - targetX, - targetY, - sourcePosition, - targetPosition, - markerEnd, - selected = false, - source, - target, -}: EdgeProps) => { - const shouldAnimateEdges = useAppSelector(selectShouldAnimateEdges); - const selectAreConnectedNodesSelected = useMemo( - () => buildSelectAreConnectedNodesSelected(source, target), - [source, target] - ); - const areConnectedNodesSelected = useAppSelector(selectAreConnectedNodesSelected); - const [edgePath] = getBezierPath({ - sourceX, - sourceY, - sourcePosition, - targetX, - targetY, - targetPosition, - }); - - return ( - - ); -}; - -export default memo(InvocationLoopLinkageEdge); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Connector/ConnectorNode.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Connector/ConnectorNode.tsx index 8f5a515b3e5..bc6867a9b9c 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Connector/ConnectorNode.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Connector/ConnectorNode.tsx @@ -120,15 +120,9 @@ const ConnectorNode = ({ id, selected }: NodeProps>) => justifyContent="center" borderRadius="full" bg={selected ? 'base.650' : 'base.700'} - data-connector-node-body="true" > - + { ); }); MissingFields.displayName = 'MissingFields'; + +const OutputFields = memo(({ nodeId }: { nodeId: string }) => { + const fieldNames = useOutputFieldNames(); + return ( + <> + {fieldNames.map((fieldName, i) => ( + + + + + + ))} + + ); +}); +OutputFields.displayName = 'OutputFields'; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeInfoIcon.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeInfoIcon.tsx index 9368073f19a..a257326f929 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeInfoIcon.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeInfoIcon.tsx @@ -15,7 +15,7 @@ interface Props { export const InvocationNodeInfoIcon = memo(({ nodeId }: Props) => { return ( } placement="top" shouldWrapChildren> - + ); }); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeStatusIndicator.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeStatusIndicator.tsx index 2786a127db3..a740a2ee3df 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeStatusIndicator.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeStatusIndicator.tsx @@ -30,14 +30,7 @@ const InvocationNodeStatusIndicator = ({ nodeId }: Props) => { return ( } placement="top"> - + diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/OutputFields.test.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/OutputFields.test.tsx deleted file mode 100644 index 54f8f467e69..00000000000 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/OutputFields.test.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import type { OutputFieldNamesByScope } from 'features/nodes/util/node/getOutputFieldNamesByScope'; -import type { ReactNode } from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { describe, expect, it, vi } from 'vitest'; - -import { OutputFields } from './OutputFields'; - -const mocks = vi.hoisted(() => ({ - fieldNames: { - all: ['item', 'output_collection'], - unscoped: [], - iteration: ['item'], - final: ['output_collection'], - } as OutputFieldNamesByScope, -})); - -vi.mock('@invoke-ai/ui-library', () => ({ - GridItem: ({ children }: { children: ReactNode }) =>
{children}
, - Text: ({ children }: { children: ReactNode }) => {children}, -})); - -vi.mock('features/nodes/hooks/useOutputFieldNames', () => ({ - useOutputFieldNamesByScope: () => mocks.fieldNames, -})); - -vi.mock('features/nodes/components/flow/nodes/Invocation/fields/OutputFieldGate', () => ({ - OutputFieldGate: ({ children }: { children: ReactNode }) => children, -})); - -vi.mock('features/nodes/components/flow/nodes/Invocation/fields/OutputFieldNodesEditorView', () => ({ - OutputFieldNodesEditorView: ({ fieldName }: { fieldName: string }) => {fieldName}, -})); - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ t: (key: string) => key }), -})); - -describe(OutputFields.name, () => { - it('renders scoped outputs under localized section headings', () => { - const html = renderToStaticMarkup(); - - expect(html).toContain('nodes.iterationOutputs'); - expect(html).toContain('nodes.finalOutputs'); - expect(html.indexOf('nodes.iterationOutputs')).toBeLessThan(html.indexOf('data-field="item"')); - expect(html.indexOf('nodes.finalOutputs')).toBeLessThan(html.indexOf('data-field="output_collection"')); - }); - - it('renders ordinary outputs without scope headings', () => { - mocks.fieldNames = { - all: ['value'], - unscoped: ['value'], - iteration: [], - final: [], - }; - - const html = renderToStaticMarkup(); - - expect(html).toContain('data-field="value"'); - expect(html).not.toContain('nodes.iterationOutputs'); - expect(html).not.toContain('nodes.finalOutputs'); - }); -}); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/OutputFields.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/OutputFields.tsx deleted file mode 100644 index a46dbf2a7ea..00000000000 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/OutputFields.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { GridItem, Text } from '@invoke-ai/ui-library'; -import { OutputFieldGate } from 'features/nodes/components/flow/nodes/Invocation/fields/OutputFieldGate'; -import { OutputFieldNodesEditorView } from 'features/nodes/components/flow/nodes/Invocation/fields/OutputFieldNodesEditorView'; -import { useOutputFieldNamesByScope } from 'features/nodes/hooks/useOutputFieldNames'; -import { getOutputFieldRows } from 'features/nodes/util/node/getOutputFieldRows'; -import { memo } from 'react'; -import { useTranslation } from 'react-i18next'; - -export const OutputFields = memo(({ nodeId }: { nodeId: string }) => { - const { t } = useTranslation(); - const fieldNames = useOutputFieldNamesByScope(); - const rows = getOutputFieldRows(fieldNames); - return ( - <> - {rows.map((row, i) => - row.type === 'header' ? ( - - - {row.scope === 'iteration' ? t('nodes.iterationOutputs') : t('nodes.finalOutputs')} - - - ) : ( - - - - - - ) - )} - - ); -}); -OutputFields.displayName = 'OutputFields'; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldTitle.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldTitle.tsx index 40c2db08ce3..396b05c2ac2 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldTitle.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldTitle.tsx @@ -100,7 +100,6 @@ export const InputFieldTitle = memo((props: Props) => { className={NO_FIT_ON_DOUBLE_CLICK_CLASS} sx={labelSx} noOfLines={1} - data-node-input-field-title="true" data-is-invalid={isInvalid} data-is-disabled={isDisabled} data-is-added-to-form={isAddedToForm} diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts b/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts index 90286610b70..eb8eeb052ab 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts @@ -83,12 +83,10 @@ export const useAutoLayout = (): (() => void) => { }); }); - let edgesToLayout: Edge[] = edges.filter((edge) => edge.type !== 'loop_linkage'); + let edgesToLayout: Edge[] = edges; if (isLayoutSelection) { const nodesToLayoutIds = new Set(nodesToLayout.map((n) => n.id)); - edgesToLayout = edges.filter( - (edge) => edge.type !== 'loop_linkage' && nodesToLayoutIds.has(edge.source) && nodesToLayoutIds.has(edge.target) - ); + edgesToLayout = edges.filter((edge) => nodesToLayoutIds.has(edge.source) && nodesToLayoutIds.has(edge.target)); } edgesToLayout.forEach((edge) => { diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useNodeCopyPaste.ts b/invokeai/frontend/web/src/features/nodes/hooks/useNodeCopyPaste.ts index 786724abb6f..5c86a0b8f08 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useNodeCopyPaste.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useNodeCopyPaste.ts @@ -16,9 +16,7 @@ import { import { selectNodesSlice } from 'features/nodes/store/selectors'; import { findUnoccupiedPosition } from 'features/nodes/store/util/findUnoccupiedPosition'; import { validateConnection } from 'features/nodes/store/util/validateConnection'; -import { LOOP_LINKAGE_FIELD } from 'features/nodes/types/constants'; import type { AnyEdge, AnyNode } from 'features/nodes/types/invocation'; -import { isInvocationNode } from 'features/nodes/types/invocation'; import { t } from 'i18next'; import { v4 as uuidv4 } from 'uuid'; @@ -136,39 +134,6 @@ const _pasteSelection = (withEdgesToCopiedNodes?: boolean) => { ); return; } - } else if (e.type === 'loop_linkage') { - const sourceNode = validationNodes.find((n) => n.id === e.source); - const targetNode = validationNodes.find((n) => n.id === e.target); - if ( - !sourceNode || - !targetNode || - !isInvocationNode(sourceNode) || - !isInvocationNode(targetNode) || - sourceNode.data.type !== 'for' || - targetNode.data.type !== 'for_return' || - e.sourceHandle !== LOOP_LINKAGE_FIELD || - e.targetHandle !== LOOP_LINKAGE_FIELD - ) { - log.warn( - { - edgeId: e.id, - source: e.source, - sourceHandle: e.sourceHandle, - target: e.target, - targetHandle: e.targetHandle, - }, - `Invalid loop linkage edge, cannot paste` - ); - return; - } - if ( - validationEdges.some( - (edge) => edge.type === 'loop_linkage' && (edge.source === e.source || edge.target === e.target) - ) - ) { - log.warn({ edgeId: e.id, source: e.source, target: e.target }, `Duplicate loop linkage edge, cannot paste`); - return; - } } else if (e.type === 'default') { const { type, source, sourceHandle, target, targetHandle } = e; @@ -200,7 +165,7 @@ const _pasteSelection = (withEdgesToCopiedNodes?: boolean) => { return; } } else { - // All our edges should be either "collapsed", "default", or "loop_linkage" type. + // All our edges should be either "collapsed" or "default" type, so if we get here, something is wrong const { type } = e; log.warn({ edge: { type } }, `Invalid edge type, cannot paste`); return; diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useOutputFieldNames.ts b/invokeai/frontend/web/src/features/nodes/hooks/useOutputFieldNames.ts index b58bbab0ac3..81e89b0fe7d 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useOutputFieldNames.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useOutputFieldNames.ts @@ -1,22 +1,17 @@ import { createSelector } from '@reduxjs/toolkit'; import { useAppSelector } from 'app/store/storeHooks'; import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context'; -import { - getOutputFieldNamesByScope, - type OutputFieldNamesByScope, -} from 'features/nodes/util/node/getOutputFieldNamesByScope'; +import { getSortedFilteredFieldNames } from 'features/nodes/util/node/getSortedFilteredFieldNames'; import { useMemo } from 'react'; -export const useOutputFieldNamesByScope = (): OutputFieldNamesByScope => { +export const useOutputFieldNames = (): string[] => { const ctx = useInvocationNodeContext(); const selector = useMemo( () => createSelector([ctx.selectNodeTemplateOrThrow], (template) => - getOutputFieldNamesByScope(Object.values(template.outputs)) + getSortedFilteredFieldNames(Object.values(template.outputs)) ), [ctx] ); return useAppSelector(selector); }; - -export const useOutputFieldNames = (): string[] => useOutputFieldNamesByScope().all; diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.test.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.test.ts index 5ceb678d131..6703317f2c0 100644 --- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.test.ts +++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.test.ts @@ -7,25 +7,14 @@ import { describe, expect, it } from 'vitest'; import { callSavedWorkflowDynamicFieldsChanged, connectorInserted, - edgesChanged, fieldIntegerValueChanged, fieldStringValueChanged, fieldValueReset, - nodeIsOpenChanged, nodesChanged, nodesSliceConfig, } from './nodesSlice'; import { CONNECTOR_INPUT_HANDLE, CONNECTOR_OUTPUT_HANDLE } from './util/connectorTopology'; -import { - add, - buildEdge, - buildLoopLinkageEdge, - buildNode, - for_loop, - for_return, - sub, - templates, -} from './util/testUtils'; +import { add, buildEdge, buildNode, sub, templates } from './util/testUtils'; const callSavedWorkflowTemplate = templates.call_saved_workflow; const addTemplate = templates.add; @@ -509,30 +498,6 @@ describe('nodesSlice connector actions', () => { ]); }); - it('splits a direct loop linkage into a connector alias when inserting a connector', () => { - const forNode = buildNode(for_loop); - const returnNode = buildNode(for_return); - const connector = buildFixedConnectorNode('connector-1'); - const directEdge = buildLoopLinkageEdge(forNode.id, returnNode.id); - - const initialState = deepClone(nodesSliceConfig.slice.reducer(undefined, { type: 'test/init' })); - initialState.nodes = [forNode, returnNode]; - initialState.edges = [directEdge]; - - const nextState = nodesSliceConfig.slice.reducer( - initialState, - connectorInserted({ - edgeId: directEdge.id, - connector, - }) - ); - - expect(nextState.edges).toEqual([ - buildEdge(forNode.id, 'loop_linkage', connector.id, CONNECTOR_INPUT_HANDLE), - buildEdge(connector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), - ]); - }); - it('splices connector outputs back to the resolved upstream source when removed', () => { const source = buildNode(add); const target = buildNode(sub); @@ -647,362 +612,3 @@ describe('nodesSlice connector actions', () => { expect(nextState.edges).toEqual([buildEdge(source.id, 'value', target.id, 'a')]); }); }); - -describe('nodesSlice loop boundary actions', () => { - it('stores loop linkage as an explicit edge', () => { - const forNode = buildNode(for_loop); - const returnNode = buildNode(for_return); - const initialState = deepClone(nodesSliceConfig.slice.reducer(undefined, { type: 'test/init' })); - initialState.nodes = [forNode, returnNode]; - - const nextState = nodesSliceConfig.slice.reducer( - initialState, - edgesChanged([{ type: 'add', item: buildLoopLinkageEdge(forNode.id, returnNode.id) }]) - ); - expect(nextState.edges).toEqual([ - expect.objectContaining({ - type: 'loop_linkage', - source: forNode.id, - sourceHandle: 'loop_linkage', - target: returnNode.id, - targetHandle: 'loop_linkage', - }), - ]); - }); - - it('removes loop linkage when either boundary node is removed', () => { - const oldForNode = buildNode(for_loop); - const returnNode = buildNode(for_return); - - const initialState = deepClone(nodesSliceConfig.slice.reducer(undefined, { type: 'test/init' })); - initialState.nodes = [oldForNode, returnNode]; - initialState.edges = [buildLoopLinkageEdge(oldForNode.id, returnNode.id)]; - - const nextState = nodesSliceConfig.slice.reducer( - initialState, - nodesChanged([{ type: 'remove', id: oldForNode.id }]) - ); - expect(nextState.edges).toEqual([]); - }); - - it('removes all connector alias edges when a For boundary is removed', () => { - const forNode = buildNode(for_loop); - const connector = buildFixedConnectorNode('connector-1'); - const returnNode = buildNode(for_return); - - const initialState = deepClone(nodesSliceConfig.slice.reducer(undefined, { type: 'test/init' })); - initialState.nodes = [forNode, connector, returnNode]; - initialState.edges = [ - buildEdge(forNode.id, 'loop_linkage', connector.id, CONNECTOR_INPUT_HANDLE), - buildEdge(connector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), - ]; - - const nextState = nodesSliceConfig.slice.reducer(initialState, nodesChanged([{ type: 'remove', id: forNode.id }])); - - expect(nextState.edges).toEqual([]); - }); - - it('removes all connector alias edges when a For boundary is replaced by another node type', () => { - const forNode = buildNode(for_loop); - const replacement = buildNode(add); - const connector = buildFixedConnectorNode('connector-1'); - const returnNode = buildNode(for_return); - replacement.id = forNode.id; - replacement.data.id = forNode.id; - - const initialState = deepClone(nodesSliceConfig.slice.reducer(undefined, { type: 'test/init' })); - initialState.nodes = [forNode, connector, returnNode]; - initialState.edges = [ - buildEdge(forNode.id, 'loop_linkage', connector.id, CONNECTOR_INPUT_HANDLE), - buildEdge(connector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), - ]; - - const nextState = nodesSliceConfig.slice.reducer( - initialState, - nodesChanged([ - { type: 'remove', id: forNode.id }, - { type: 'add', item: replacement }, - ]) - ); - - expect(nextState.edges).toEqual([]); - }); - - it('removes an incomplete connector alias when a For boundary is replaced by another node type', () => { - const forNode = buildNode(for_loop); - const replacement = buildNode(add); - const connector = buildFixedConnectorNode('connector-1'); - replacement.id = forNode.id; - replacement.data.id = forNode.id; - - const initialState = deepClone(nodesSliceConfig.slice.reducer(undefined, { type: 'test/init' })); - initialState.nodes = [forNode, connector]; - initialState.edges = [buildEdge(forNode.id, 'loop_linkage', connector.id, CONNECTOR_INPUT_HANDLE)]; - - const nextState = nodesSliceConfig.slice.reducer( - initialState, - nodesChanged([ - { type: 'remove', id: forNode.id }, - { type: 'add', item: replacement }, - ]) - ); - - expect(nextState.edges).toEqual([]); - }); - - it('splices a connector loop linkage alias into a direct edge when removed', () => { - const forNode = buildNode(for_loop); - const connector = buildFixedConnectorNode('connector-1'); - const returnNode = buildNode(for_return); - - const initialState = deepClone(nodesSliceConfig.slice.reducer(undefined, { type: 'test/init' })); - initialState.nodes = [forNode, connector, returnNode]; - initialState.edges = [ - buildEdge(forNode.id, 'loop_linkage', connector.id, CONNECTOR_INPUT_HANDLE), - buildEdge(connector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), - ]; - - const nextState = nodesSliceConfig.slice.reducer( - initialState, - nodesChanged([{ type: 'remove', id: connector.id }]) - ); - - expect(nextState.edges).toEqual([ - expect.objectContaining({ - type: 'loop_linkage', - source: forNode.id, - sourceHandle: 'loop_linkage', - target: returnNode.id, - targetHandle: 'loop_linkage', - }), - ]); - }); - - it('splices a removed terminal loop linkage connector to the preceding connector', () => { - const forNode = buildNode(for_loop); - const firstConnector = buildFixedConnectorNode('connector-a'); - const terminalConnector = buildFixedConnectorNode('connector-b'); - const returnNode = buildNode(for_return); - - const initialState = deepClone(nodesSliceConfig.slice.reducer(undefined, { type: 'test/init' })); - initialState.nodes = [forNode, firstConnector, terminalConnector, returnNode]; - initialState.edges = [ - buildEdge(forNode.id, 'loop_linkage', firstConnector.id, CONNECTOR_INPUT_HANDLE), - buildEdge(firstConnector.id, CONNECTOR_OUTPUT_HANDLE, terminalConnector.id, CONNECTOR_INPUT_HANDLE), - buildEdge(terminalConnector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), - ]; - - const nextState = nodesSliceConfig.slice.reducer( - initialState, - nodesChanged([{ type: 'remove', id: terminalConnector.id }]) - ); - - expect(nextState.nodes.map((node) => node.id)).toEqual([forNode.id, firstConnector.id, returnNode.id]); - expect(nextState.edges).toEqual([ - buildEdge(forNode.id, 'loop_linkage', firstConnector.id, CONNECTOR_INPUT_HANDLE), - expect.objectContaining({ - type: 'default', - source: firstConnector.id, - sourceHandle: CONNECTOR_OUTPUT_HANDLE, - target: returnNode.id, - targetHandle: 'loop_linkage', - }), - ]); - }); - - it('splices a removed interior loop linkage connector to the preceding connector', () => { - const forNode = buildNode(for_loop); - const firstConnector = buildFixedConnectorNode('connector-a'); - const removedConnector = buildFixedConnectorNode('connector-b'); - const terminalConnector = buildFixedConnectorNode('connector-c'); - const returnNode = buildNode(for_return); - - const initialState = deepClone(nodesSliceConfig.slice.reducer(undefined, { type: 'test/init' })); - initialState.nodes = [forNode, firstConnector, removedConnector, terminalConnector, returnNode]; - initialState.edges = [ - buildEdge(forNode.id, 'loop_linkage', firstConnector.id, CONNECTOR_INPUT_HANDLE), - buildEdge(firstConnector.id, CONNECTOR_OUTPUT_HANDLE, removedConnector.id, CONNECTOR_INPUT_HANDLE), - buildEdge(removedConnector.id, CONNECTOR_OUTPUT_HANDLE, terminalConnector.id, CONNECTOR_INPUT_HANDLE), - buildEdge(terminalConnector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), - ]; - - const nextState = nodesSliceConfig.slice.reducer( - initialState, - nodesChanged([{ type: 'remove', id: removedConnector.id }]) - ); - - expect(nextState.nodes.map((node) => node.id)).toEqual([ - forNode.id, - firstConnector.id, - terminalConnector.id, - returnNode.id, - ]); - expect(nextState.edges).toEqual( - expect.arrayContaining([ - buildEdge(forNode.id, 'loop_linkage', firstConnector.id, CONNECTOR_INPUT_HANDLE), - buildEdge(firstConnector.id, CONNECTOR_OUTPUT_HANDLE, terminalConnector.id, CONNECTOR_INPUT_HANDLE), - buildEdge(terminalConnector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), - ]) - ); - }); - - it('splices a chain when multiple loop linkage connectors are removed together', () => { - const forNode = buildNode(for_loop); - const firstConnector = buildFixedConnectorNode('connector-a'); - const secondConnector = buildFixedConnectorNode('connector-b'); - const returnNode = buildNode(for_return); - - const initialState = deepClone(nodesSliceConfig.slice.reducer(undefined, { type: 'test/init' })); - initialState.nodes = [forNode, firstConnector, secondConnector, returnNode]; - initialState.edges = [ - buildEdge(forNode.id, 'loop_linkage', firstConnector.id, CONNECTOR_INPUT_HANDLE), - buildEdge(firstConnector.id, CONNECTOR_OUTPUT_HANDLE, secondConnector.id, CONNECTOR_INPUT_HANDLE), - buildEdge(secondConnector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), - ]; - - const nextState = nodesSliceConfig.slice.reducer( - initialState, - nodesChanged([ - { type: 'remove', id: firstConnector.id }, - { type: 'remove', id: secondConnector.id }, - ]) - ); - - expect(nextState.nodes.map((node) => node.id)).toEqual([forNode.id, returnNode.id]); - expect(nextState.edges).toEqual([ - expect.objectContaining({ - type: 'loop_linkage', - source: forNode.id, - sourceHandle: 'loop_linkage', - target: returnNode.id, - targetHandle: 'loop_linkage', - }), - ]); - }); - - it('does not create invalid edges when removing a loop linkage connector with an ordinary fanout', () => { - const forNode = buildNode(for_loop); - const connector = buildFixedConnectorNode('connector'); - const returnNode = buildNode(for_return); - const ordinaryTarget = buildNode(sub); - - const initialState = deepClone(nodesSliceConfig.slice.reducer(undefined, { type: 'test/init' })); - initialState.nodes = [forNode, connector, returnNode, ordinaryTarget]; - initialState.edges = [ - buildEdge(forNode.id, 'loop_linkage', connector.id, CONNECTOR_INPUT_HANDLE), - buildEdge(connector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), - buildEdge(connector.id, CONNECTOR_OUTPUT_HANDLE, ordinaryTarget.id, 'a'), - ]; - - const nextState = nodesSliceConfig.slice.reducer( - initialState, - nodesChanged([{ type: 'remove', id: connector.id }]) - ); - - expect(nextState.nodes.map((node) => node.id)).toEqual([forNode.id, returnNode.id, ordinaryTarget.id]); - expect(nextState.edges).toEqual([]); - }); - - it('preserves linkage when a boundary is replaced with the same node id', () => { - const forNode = buildNode(for_loop); - const replacement = buildNode(for_loop); - const returnNode = buildNode(for_return); - replacement.id = forNode.id; - replacement.data.id = forNode.id; - - const initialState = deepClone(nodesSliceConfig.slice.reducer(undefined, { type: 'test/init' })); - initialState.nodes = [forNode, returnNode]; - initialState.edges = [buildLoopLinkageEdge(forNode.id, returnNode.id)]; - - const nextState = nodesSliceConfig.slice.reducer( - initialState, - nodesChanged([ - { type: 'remove', id: forNode.id }, - { type: 'add', item: replacement }, - ]) - ); - expect(nextState.edges).toEqual([buildLoopLinkageEdge(forNode.id, returnNode.id)]); - }); - - it('removes linkage when a boundary is replaced by a different node type', () => { - const forNode = buildNode(for_loop); - const replacement = buildNode(add); - const returnNode = buildNode(for_return); - replacement.id = forNode.id; - replacement.data.id = forNode.id; - - const initialState = deepClone(nodesSliceConfig.slice.reducer(undefined, { type: 'test/init' })); - initialState.nodes = [forNode, returnNode]; - initialState.edges = [buildLoopLinkageEdge(forNode.id, returnNode.id)]; - - const nextState = nodesSliceConfig.slice.reducer( - initialState, - nodesChanged([ - { type: 'remove', id: forNode.id }, - { type: 'add', item: replacement }, - ]) - ); - - expect(nextState.edges).toEqual([]); - }); - - it('removes linkage when its ForReturn is removed', () => { - const forNode = buildNode(for_loop); - const returnNode = buildNode(for_return); - - const initialState = deepClone(nodesSliceConfig.slice.reducer(undefined, { type: 'test/init' })); - initialState.nodes = [forNode, returnNode]; - initialState.edges = [buildLoopLinkageEdge(forNode.id, returnNode.id)]; - - const nextState = nodesSliceConfig.slice.reducer( - initialState, - nodesChanged([{ type: 'remove', id: returnNode.id }]) - ); - expect(nextState.edges).toEqual([]); - }); - - it('does not collapse loop linkage when both boundary nodes are closed', () => { - const forNode = buildNode(for_loop); - const returnNode = buildNode(for_return); - returnNode.data.isOpen = false; - - const initialState = deepClone(nodesSliceConfig.slice.reducer(undefined, { type: 'test/init' })); - initialState.nodes = [forNode, returnNode]; - initialState.edges = [buildLoopLinkageEdge(forNode.id, returnNode.id)]; - - const nextState = nodesSliceConfig.slice.reducer( - initialState, - nodeIsOpenChanged({ nodeId: forNode.id, isOpen: false }) - ); - - expect(nextState.edges).toEqual([buildLoopLinkageEdge(forNode.id, returnNode.id)]); - }); - - it('does not treat loop linkage as a hidden edge of a collapsed data-flow edge', () => { - const forNode = buildNode(for_loop); - const returnNode = buildNode(for_return); - returnNode.data.isOpen = false; - const loopLinkageEdge = buildLoopLinkageEdge(forNode.id, returnNode.id); - const dataFlowEdge = buildEdge(forNode.id, 'item', returnNode.id, 'output'); - - const initialState = deepClone(nodesSliceConfig.slice.reducer(undefined, { type: 'test/init' })); - initialState.nodes = [forNode, returnNode]; - initialState.edges = [dataFlowEdge, loopLinkageEdge]; - - const closedState = nodesSliceConfig.slice.reducer( - initialState, - nodeIsOpenChanged({ nodeId: forNode.id, isOpen: false }) - ); - const collapsedEdge = closedState.edges.find((edge) => edge.type === 'collapsed'); - if (!collapsedEdge) { - throw new Error('Expected collapsed edge'); - } - - const nextState = nodesSliceConfig.slice.reducer( - closedState, - edgesChanged([{ type: 'remove', id: collapsedEdge.id }]) - ); - - expect(nextState.edges).toEqual([loopLinkageEdge]); - }); -}); diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts index 7ca12180a6e..3faabbce8f0 100644 --- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts @@ -24,11 +24,11 @@ import { type NodesState, zNodesState } from 'features/nodes/store/types'; import { CONNECTOR_INPUT_HANDLE, CONNECTOR_OUTPUT_HANDLE, - getConnectorDeletionSpliceConnections, - getLoopLinkageAliasEdgeIdsForBoundary, + getConnectorOutputEdges, + resolveConnectorSource, } from 'features/nodes/store/util/connectorTopology'; -import { connectionToEdge, isLoopLinkageEdge } from 'features/nodes/store/util/reactFlowUtil'; -import { LOOP_LINKAGE_FIELD, SHARED_NODE_PROPERTIES } from 'features/nodes/types/constants'; +import { connectionToEdge } from 'features/nodes/store/util/reactFlowUtil'; +import { SHARED_NODE_PROPERTIES } from 'features/nodes/types/constants'; import type { BoardFieldValue, BooleanFieldValue, @@ -248,96 +248,37 @@ const removeCallSavedWorkflowDynamicFieldsFromForm = ( } }; -const isValidLoopLinkageEdge = (edge: AnyEdge, nodes: AnyNode[]): boolean => { - if (!isLoopLinkageEdge(edge)) { - return true; - } - - const sourceNode = nodes.find((node) => node.id === edge.source); - const targetNode = nodes.find((node) => node.id === edge.target); - return Boolean( - sourceNode && - targetNode && - isInvocationNode(sourceNode) && - sourceNode.data.type === 'for' && - isInvocationNode(targetNode) && - targetNode.data.type === 'for_return' && - edge.sourceHandle === LOOP_LINKAGE_FIELD && - edge.targetHandle === LOOP_LINKAGE_FIELD - ); -}; - const slice = createSlice({ name: 'nodes', initialState: getInitialState(), reducers: { nodesChanged: (state, action: PayloadAction[]>) => { - const replacementNodesById = new Map(); - for (const change of action.payload) { - if (change.type === 'add' || change.type === 'replace') { - replacementNodesById.set(change.item.id, change.item); - } - } - - const removedBoundaryAliasEdgeIds = new Set(); - for (const change of action.payload) { - if (change.type !== 'remove' && change.type !== 'replace') { - continue; - } - - const oldNode = state.nodes.find((candidate) => candidate.id === change.id); - if (!isInvocationNode(oldNode) || !['for', 'for_return'].includes(oldNode.data.type)) { - continue; - } - - const replacementNode = replacementNodesById.get(change.id); - if (isInvocationNode(replacementNode) && replacementNode.data.type === oldNode.data.type) { - continue; - } - - getLoopLinkageAliasEdgeIdsForBoundary(oldNode.id, state.nodes, state.edges).forEach((edgeId) => - removedBoundaryAliasEdgeIds.add(edgeId) - ); - } - - const replacementNodeIds = new Set( - action.payload.flatMap((change) => (change.type === 'add' || change.type === 'replace' ? [change.item.id] : [])) - ); - const removedConnectorIds = new Set( - action.payload - .flatMap((change) => (change.type === 'remove' && !replacementNodeIds.has(change.id) ? [change.id] : [])) - .filter((nodeId) => isConnectorNode(state.nodes.find((node) => node.id === nodeId))) - ); - const removedNodeIds = new Set( - action.payload.flatMap((change) => - change.type === 'remove' && !replacementNodeIds.has(change.id) ? [change.id] : [] - ) - ); - const removedConnectorSpliceEdgesById = new Map(); - for (const change of action.payload) { + const removedConnectorSpliceEdges: AnyEdge[] = action.payload.flatMap((change) => { if (change.type !== 'remove') { - continue; + return []; } const node = state.nodes.find((candidate) => candidate.id === change.id); - if (!isConnectorNode(node) || !removedConnectorIds.has(node.id)) { - continue; + if (!isConnectorNode(node)) { + return []; } - const spliceEdges = - getConnectorDeletionSpliceConnections( - node.id, - state.nodes, - state.edges, - undefined, - undefined, - removedConnectorIds - ) - ?.filter((connection) => !removedNodeIds.has(connection.source) && !removedNodeIds.has(connection.target)) - .map((connection) => connectionToEdge(connection)) ?? []; - spliceEdges.forEach((edge) => removedConnectorSpliceEdgesById.set(edge.id, edge)); - } - const removedConnectorSpliceEdges = [...removedConnectorSpliceEdgesById.values()]; + const resolvedSource = resolveConnectorSource(node.id, state.nodes, state.edges); + if (!resolvedSource) { + return []; + } + + return getConnectorOutputEdges(node.id, state.edges) + .filter((edge): edge is AnyEdge & { type: 'default'; targetHandle: string } => edge.type === 'default') + .map((edge) => + connectionToEdge({ + source: resolvedSource.nodeId, + sourceHandle: resolvedSource.fieldName, + target: edge.target, + targetHandle: edge.targetHandle, + }) + ); + }); // TODO(psyche): The below TS issue was recently fixed upstream. Need to upgrade @xyflow/react and then we // should be able to remove this cast. @@ -363,11 +304,7 @@ const slice = createSlice({ for (const e of state.edges) { const sourceExists = state.nodes.some((n) => n.id === e.source); const targetExists = state.nodes.some((n) => n.id === e.target); - if ( - !(sourceExists && targetExists) || - !isValidLoopLinkageEdge(e, state.nodes) || - removedBoundaryAliasEdgeIds.has(e.id) - ) { + if (!(sourceExists && targetExists)) { edgeChanges.push({ type: 'remove', id: e.id }); } } @@ -413,9 +350,7 @@ const slice = createSlice({ const edge = state.edges.find((e) => e.id === change.id); // If we deleted or selected a collapsed edge, we need to find its "hidden" edges and do the same to them if (edge && edge.type === 'collapsed') { - const hiddenEdges = state.edges.filter( - (e) => e.type === 'default' && e.source === edge.source && e.target === edge.target - ); + const hiddenEdges = state.edges.filter((e) => e.source === edge.source && e.target === edge.target); for (const { id } of hiddenEdges) { if (change.type === 'remove') { changes.push({ type: 'remove', id }); @@ -496,7 +431,6 @@ const slice = createSlice({ // - if the edge was just closed, we need to check all its edges and hide them if both nodes are closed const connectedEdges = getConnectedEdges([node], state.edges); - const executableConnectedEdges = connectedEdges.filter((edge) => edge.type !== 'loop_linkage'); if (isOpen) { // reset hidden status of all edges @@ -510,19 +444,18 @@ const slice = createSlice({ } }); } else { - const executableEdges = state.edges.filter((edge) => edge.type !== 'loop_linkage'); - const closedIncomers = getIncomers(node, state.nodes, executableEdges).filter( + const closedIncomers = getIncomers(node, state.nodes, state.edges).filter( (node) => isInvocationNode(node) && node.data.isOpen === false ); - const closedOutgoers = getOutgoers(node, state.nodes, executableEdges).filter( + const closedOutgoers = getOutgoers(node, state.nodes, state.edges).filter( (node) => isInvocationNode(node) && node.data.isOpen === false ); const collapsedEdgesToCreate: AnyEdge[] = []; // hide all edges - executableConnectedEdges.forEach((edge) => { + connectedEdges.forEach((edge) => { if (edge.target === nodeId && closedIncomers.find((node) => node.id === edge.source)) { edge.hidden = true; const collapsedEdge = collapsedEdgesToCreate.find( @@ -583,7 +516,7 @@ const slice = createSlice({ ) => { const { edgeId, connector } = action.payload; const edge = state.edges.find((candidate) => candidate.id === edgeId); - if (!edge || (edge.type !== 'default' && edge.type !== 'loop_linkage')) { + if (!edge || edge.type !== 'default') { return; } state.nodes.push({ ...SHARED_NODE_PROPERTIES, ...connector } as (typeof state.nodes)[number]); diff --git a/invokeai/frontend/web/src/features/nodes/store/util/connectorTopology.test.ts b/invokeai/frontend/web/src/features/nodes/store/util/connectorTopology.test.ts index 3a6e2030798..e87ebcde792 100644 --- a/invokeai/frontend/web/src/features/nodes/store/util/connectorTopology.test.ts +++ b/invokeai/frontend/web/src/features/nodes/store/util/connectorTopology.test.ts @@ -7,13 +7,10 @@ import { getConnectorDeletionSpliceConnections, getConnectorInputEdge, getConnectorOutputEdges, - getEdgesWithLoopLinkageAliases, resolveConnectorSource, resolveConnectorSourceFieldType, - resolveLoopLinkagePath, - resolvePendingConnectionSource, } from './connectorTopology'; -import { add, buildEdge, buildNode, for_loop, for_return, img_resize, sub, templates } from './testUtils'; +import { add, buildEdge, buildNode, img_resize, sub, templates } from './testUtils'; const buildConnectorNode = (id: string): ConnectorNode => ({ id, @@ -28,32 +25,6 @@ const buildConnectorNode = (id: string): ConnectorNode => ({ }); describe('connectorTopology', () => { - it('resolves pending connector source metadata, including output scope', () => { - const source = buildNode(for_loop); - const connector = buildConnectorNode('connector-1'); - const pendingConnection = { - nodeId: connector.id, - handleId: CONNECTOR_OUTPUT_HANDLE, - handleType: 'source' as const, - fieldTemplate: { - name: CONNECTOR_OUTPUT_HANDLE, - title: 'Connector Output', - description: '', - fieldKind: 'output' as const, - ui_hidden: false, - type: { name: 'AnyField', cardinality: 'SINGLE' as const, batch: false }, - }, - }; - const nodes: AnyNode[] = [source, connector]; - const edges = [buildEdge(source.id, 'item', connector.id, CONNECTOR_INPUT_HANDLE)]; - - expect(resolvePendingConnectionSource(pendingConnection, nodes, edges, { ...templates, for: for_loop })).toEqual({ - nodeId: source.id, - fieldName: 'item', - outputScope: 'iteration', - }); - }); - it('resolves the effective upstream source through one connector', () => { const source = buildNode(add); const connector = buildConnectorNode('connector-1'); @@ -87,92 +58,6 @@ describe('connectorTopology', () => { }); }); - it('resolves a one-to-one connector loop linkage path', () => { - const forNode = buildNode(for_loop); - const connector = buildConnectorNode('connector-1'); - const returnNode = buildNode(for_return); - const inputEdge = buildEdge(forNode.id, 'loop_linkage', connector.id, CONNECTOR_INPUT_HANDLE); - const outputEdge = buildEdge(connector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'); - const nodes: AnyNode[] = [forNode, connector, returnNode]; - - expect(resolveLoopLinkagePath(outputEdge, nodes, [inputEdge, outputEdge])).toEqual({ - forNodeId: forNode.id, - returnNodeId: returnNode.id, - edgeIds: [inputEdge.id, outputEdge.id], - connectorNodeIds: [connector.id], - }); - }); - - it('resolves a connector loop linkage path through a connector chain', () => { - const forNode = buildNode(for_loop); - const firstConnector = buildConnectorNode('connector-1'); - const secondConnector = buildConnectorNode('connector-2'); - const returnNode = buildNode(for_return); - const firstInputEdge = buildEdge(forNode.id, 'loop_linkage', firstConnector.id, CONNECTOR_INPUT_HANDLE); - const chainEdge = buildEdge(firstConnector.id, CONNECTOR_OUTPUT_HANDLE, secondConnector.id, CONNECTOR_INPUT_HANDLE); - const outputEdge = buildEdge(secondConnector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'); - const nodes: AnyNode[] = [forNode, firstConnector, secondConnector, returnNode]; - - expect(resolveLoopLinkagePath(outputEdge, nodes, [firstInputEdge, chainEdge, outputEdge])).toEqual({ - forNodeId: forNode.id, - returnNodeId: returnNode.id, - edgeIds: [firstInputEdge.id, chainEdge.id, outputEdge.id], - connectorNodeIds: [firstConnector.id, secondConnector.id], - }); - }); - - it('marks every complete connector loop linkage segment for dashed rendering', () => { - const forNode = buildNode(for_loop); - const connector = buildConnectorNode('connector-1'); - const returnNode = buildNode(for_return); - const inputEdge = buildEdge(forNode.id, 'loop_linkage', connector.id, CONNECTOR_INPUT_HANDLE); - const outputEdge = buildEdge(connector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'); - const edges = [inputEdge, outputEdge]; - - expect(getEdgesWithLoopLinkageAliases([forNode, connector, returnNode], edges)).toEqual([ - { ...inputEdge, type: 'loop_linkage' }, - { ...outputEdge, type: 'loop_linkage' }, - ]); - expect(edges).toEqual([inputEdge, outputEdge]); - }); - - it('marks every segment in a chained connector loop linkage for dashed rendering', () => { - const forNode = buildNode(for_loop); - const firstConnector = buildConnectorNode('connector-1'); - const secondConnector = buildConnectorNode('connector-2'); - const returnNode = buildNode(for_return); - const edges = [ - buildEdge(forNode.id, 'loop_linkage', firstConnector.id, CONNECTOR_INPUT_HANDLE), - buildEdge(firstConnector.id, CONNECTOR_OUTPUT_HANDLE, secondConnector.id, CONNECTOR_INPUT_HANDLE), - buildEdge(secondConnector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), - ]; - - expect(getEdgesWithLoopLinkageAliases([forNode, firstConnector, secondConnector, returnNode], edges)).toEqual( - edges.map((edge) => ({ ...edge, type: 'loop_linkage' })) - ); - }); - - it('leaves an incomplete connector loop linkage path as a default edge', () => { - const forNode = buildNode(for_loop); - const connector = buildConnectorNode('connector-1'); - const edges = [buildEdge(forNode.id, 'loop_linkage', connector.id, CONNECTOR_INPUT_HANDLE)]; - - expect(getEdgesWithLoopLinkageAliases([forNode, connector], edges)).toEqual(edges); - }); - - it('rejects a connector loop linkage path that fans out', () => { - const forNode = buildNode(for_loop); - const connector = buildConnectorNode('connector-1'); - const firstReturnNode = buildNode(for_return); - const secondReturnNode = buildNode(for_return); - const inputEdge = buildEdge(forNode.id, 'loop_linkage', connector.id, CONNECTOR_INPUT_HANDLE); - const firstOutputEdge = buildEdge(connector.id, CONNECTOR_OUTPUT_HANDLE, firstReturnNode.id, 'loop_linkage'); - const secondOutputEdge = buildEdge(connector.id, CONNECTOR_OUTPUT_HANDLE, secondReturnNode.id, 'loop_linkage'); - const nodes: AnyNode[] = [forNode, connector, firstReturnNode, secondReturnNode]; - - expect(resolveLoopLinkagePath(firstOutputEdge, nodes, [inputEdge, firstOutputEdge, secondOutputEdge])).toBe(null); - }); - it('returns no source or type for an unresolved connector chain', () => { const connectorA = buildConnectorNode('connector-a'); const connectorB = buildConnectorNode('connector-b'); @@ -230,54 +115,6 @@ describe('connectorTopology', () => { ]); }); - it('splices a terminal loop linkage connector to its immediate upstream connector', () => { - const forNode = buildNode(for_loop); - const firstConnector = buildConnectorNode('connector-a'); - const terminalConnector = buildConnectorNode('connector-b'); - const returnNode = buildNode(for_return); - const edges = [ - buildEdge(forNode.id, 'loop_linkage', firstConnector.id, CONNECTOR_INPUT_HANDLE), - buildEdge(firstConnector.id, CONNECTOR_OUTPUT_HANDLE, terminalConnector.id, CONNECTOR_INPUT_HANDLE), - buildEdge(terminalConnector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), - ]; - - expect( - getConnectorDeletionSpliceConnections( - terminalConnector.id, - [forNode, firstConnector, terminalConnector, returnNode], - edges, - { ...templates, for: for_loop, for_return } - ) - ).toEqual([ - { - source: firstConnector.id, - sourceHandle: CONNECTOR_OUTPUT_HANDLE, - target: returnNode.id, - targetHandle: 'loop_linkage', - }, - ]); - }); - - it('does not splice a loop linkage connector with an invalid ordinary fanout', () => { - const forNode = buildNode(for_loop); - const connector = buildConnectorNode('connector'); - const returnNode = buildNode(for_return); - const ordinaryTarget = buildNode(sub); - const edges = [ - buildEdge(forNode.id, 'loop_linkage', connector.id, CONNECTOR_INPUT_HANDLE), - buildEdge(connector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), - buildEdge(connector.id, CONNECTOR_OUTPUT_HANDLE, ordinaryTarget.id, 'a'), - ]; - - expect( - getConnectorDeletionSpliceConnections(connector.id, [forNode, connector, returnNode, ordinaryTarget], edges, { - ...templates, - for: for_loop, - for_return, - }) - ).toBe(null); - }); - it('returns no splice-through edges when a connector has downstream targets but no upstream source', () => { const connector = buildConnectorNode('connector-1'); const target = buildNode(sub); diff --git a/invokeai/frontend/web/src/features/nodes/store/util/connectorTopology.ts b/invokeai/frontend/web/src/features/nodes/store/util/connectorTopology.ts index 6d96d03d4be..e1267763d70 100644 --- a/invokeai/frontend/web/src/features/nodes/store/util/connectorTopology.ts +++ b/invokeai/frontend/web/src/features/nodes/store/util/connectorTopology.ts @@ -1,5 +1,4 @@ -import type { PendingConnection, Templates } from 'features/nodes/store/types'; -import { LOOP_LINKAGE_FIELD } from 'features/nodes/types/constants'; +import type { Templates } from 'features/nodes/store/types'; import type { FieldType } from 'features/nodes/types/field'; import type { AnyEdge, AnyNode } from 'features/nodes/types/invocation'; import { isConnectorNode, isInvocationNode } from 'features/nodes/types/invocation'; @@ -12,13 +11,6 @@ type ResolvedConnectorSource = { fieldName: string; }; -type ResolvedLoopLinkagePath = { - forNodeId: string; - returnNodeId: string; - edgeIds: string[]; - connectorNodeIds: string[]; -}; - type SpliceConnection = { source: string; sourceHandle: string; @@ -35,17 +27,6 @@ type SpliceConnectionValidator = ( strict?: boolean ) => string | null; -type ResolvedConnectorOutputEdges = { - edges: AnyEdge[]; - traversedEdgeIds: Set; -}; - -type ResolvedPendingConnectionSource = { - nodeId: string; - fieldName: string; - outputScope?: 'iteration' | 'final'; -}; - export const getConnectorInputEdge = (connectorId: string, edges: AnyEdge[]): AnyEdge | undefined => edges.find( (edge) => @@ -64,84 +45,6 @@ export const getConnectorOutputEdges = (connectorId: string, edges: AnyEdge[]): typeof edge.targetHandle === 'string' ); -const getConnectorDeletionOutputEdges = ( - connectorId: string, - nodes: AnyNode[], - edges: AnyEdge[], - removedConnectorIds: ReadonlySet -): ResolvedConnectorOutputEdges | null => { - const visitedConnectorIds = new Set(); - const traversedEdgeIds = new Set(); - const outputEdges: AnyEdge[] = []; - - const resolve = (currentConnectorId: string): boolean => { - if (visitedConnectorIds.has(currentConnectorId)) { - return false; - } - visitedConnectorIds.add(currentConnectorId); - - for (const edge of getConnectorOutputEdges(currentConnectorId, edges)) { - traversedEdgeIds.add(edge.id); - const targetNode = nodes.find((node) => node.id === edge.target); - if (removedConnectorIds.has(edge.target) && isConnectorNode(targetNode)) { - if (!resolve(targetNode.id)) { - return false; - } - } else { - outputEdges.push(edge); - } - } - return true; - }; - - return resolve(connectorId) ? { edges: outputEdges, traversedEdgeIds } : null; -}; - -const getConnectorDeletionInputEdgeIds = ( - connectorId: string, - nodes: AnyNode[], - edges: AnyEdge[], - removedConnectorIds: ReadonlySet -): Set => { - const inputEdgeIds = new Set(); - const visitedConnectorIds = new Set(); - let currentConnectorId: string | null = connectorId; - - while (currentConnectorId && !visitedConnectorIds.has(currentConnectorId)) { - visitedConnectorIds.add(currentConnectorId); - const inputEdge = getConnectorInputEdge(currentConnectorId, edges); - if (!inputEdge) { - break; - } - inputEdgeIds.add(inputEdge.id); - - const sourceNode = nodes.find((node) => node.id === inputEdge.source); - currentConnectorId = isConnectorNode(sourceNode) && removedConnectorIds.has(sourceNode.id) ? sourceNode.id : null; - } - - return inputEdgeIds; -}; - -const resolveSurvivingConnectorDeletionSource = ( - connectorId: string, - nodes: AnyNode[], - edges: AnyEdge[], - removedConnectorIds: ReadonlySet -): ResolvedConnectorSource | null => { - const visitedConnectorIds = new Set(); - let resolvedSource = resolveConnectorDeletionSource(connectorId, nodes, edges); - - while (resolvedSource && removedConnectorIds.has(resolvedSource.nodeId)) { - if (visitedConnectorIds.has(resolvedSource.nodeId)) { - return null; - } - visitedConnectorIds.add(resolvedSource.nodeId); - resolvedSource = resolveConnectorDeletionSource(resolvedSource.nodeId, nodes, edges); - } - - return resolvedSource; -}; - export const resolveConnectorSource = ( connectorId: string, nodes: AnyNode[], @@ -182,284 +85,6 @@ export const resolveConnectorSource = ( return resolve(connectorId); }; -/** - * Resolves a connector alias used for the visual loop linkage between a For and ForReturn. - * Every connector on this path must have exactly one input and one output, so the alias cannot - * branch or be reused as ordinary data flow. - */ -export const resolveLoopLinkagePath = ( - edge: AnyEdge, - nodes: AnyNode[], - edges: AnyEdge[] -): ResolvedLoopLinkagePath | null => { - if (edge.type !== 'default' || edge.targetHandle !== LOOP_LINKAGE_FIELD || typeof edge.sourceHandle !== 'string') { - return null; - } - - const returnNode = nodes.find((node) => node.id === edge.target); - if (!returnNode || !isInvocationNode(returnNode) || returnNode.data.type !== 'for_return') { - return null; - } - - const edgeIds = [edge.id]; - const connectorNodeIds: string[] = []; - const visitedConnectors = new Set(); - let currentEdge: AnyEdge = edge; - - while (true) { - const sourceNode = nodes.find((node) => node.id === currentEdge.source); - if (!sourceNode || typeof currentEdge.sourceHandle !== 'string') { - return null; - } - - if (isInvocationNode(sourceNode)) { - if (sourceNode.data.type !== 'for' || currentEdge.sourceHandle !== LOOP_LINKAGE_FIELD) { - return null; - } - return { - forNodeId: sourceNode.id, - returnNodeId: returnNode.id, - edgeIds: [...edgeIds].reverse(), - connectorNodeIds: [...connectorNodeIds].reverse(), - }; - } - - if (!isConnectorNode(sourceNode) || currentEdge.sourceHandle !== CONNECTOR_OUTPUT_HANDLE) { - return null; - } - if (visitedConnectors.has(sourceNode.id)) { - return null; - } - visitedConnectors.add(sourceNode.id); - connectorNodeIds.push(sourceNode.id); - - const inputEdges = edges.filter( - (candidate) => - candidate.type === 'default' && - candidate.target === sourceNode.id && - candidate.targetHandle === CONNECTOR_INPUT_HANDLE && - typeof candidate.sourceHandle === 'string' - ); - const outputEdges = getConnectorOutputEdges(sourceNode.id, edges); - if ( - inputEdges.length !== 1 || - outputEdges.length !== 1 || - (outputEdges[0] !== currentEdge && outputEdges[0]?.id !== currentEdge.id) - ) { - return null; - } - - const inputEdge = inputEdges[0]; - if (!inputEdge) { - return null; - } - edgeIds.push(inputEdge.id); - currentEdge = inputEdge; - } -}; - -const getResolvedLoopLinkagePathForConnector = ( - connectorId: string, - nodes: AnyNode[], - edges: AnyEdge[] -): ResolvedLoopLinkagePath | null => { - for (const edge of edges) { - if ( - edge.type !== 'default' || - edge.sourceHandle !== CONNECTOR_OUTPUT_HANDLE || - edge.targetHandle !== LOOP_LINKAGE_FIELD - ) { - continue; - } - - const path = resolveLoopLinkagePath(edge, nodes, edges); - if (path?.connectorNodeIds.includes(connectorId)) { - return path; - } - } - return null; -}; - -/** - * Finds the serialized connector edges attached to a boundary's loop-linkage alias, - * including an alias that has not reached its opposite boundary yet. - */ -export const getLoopLinkageAliasEdgeIdsForBoundary = ( - boundaryNodeId: string, - nodes: AnyNode[], - edges: AnyEdge[] -): Set => { - const edgeIds = new Set(); - const visitedForwardConnectorIds = new Set(); - const visitedBackwardConnectorIds = new Set(); - - const visitForward = (connectorId: string): void => { - if (visitedForwardConnectorIds.has(connectorId)) { - return; - } - visitedForwardConnectorIds.add(connectorId); - for (const edge of getConnectorOutputEdges(connectorId, edges)) { - edgeIds.add(edge.id); - const targetNode = nodes.find((node) => node.id === edge.target); - if (isConnectorNode(targetNode) && edge.targetHandle === CONNECTOR_INPUT_HANDLE) { - visitForward(targetNode.id); - } - } - }; - - const visitBackward = (connectorId: string): void => { - if (visitedBackwardConnectorIds.has(connectorId)) { - return; - } - visitedBackwardConnectorIds.add(connectorId); - const inputEdge = getConnectorInputEdge(connectorId, edges); - if (!inputEdge) { - return; - } - edgeIds.add(inputEdge.id); - const sourceNode = nodes.find((node) => node.id === inputEdge.source); - if (isConnectorNode(sourceNode) && inputEdge.sourceHandle === CONNECTOR_OUTPUT_HANDLE) { - visitBackward(sourceNode.id); - } - }; - - for (const edge of edges) { - if (edge.type !== 'default') { - continue; - } - - if ( - edge.source === boundaryNodeId && - edge.sourceHandle === LOOP_LINKAGE_FIELD && - edge.targetHandle === CONNECTOR_INPUT_HANDLE - ) { - const targetNode = nodes.find((node) => node.id === edge.target); - if (isConnectorNode(targetNode)) { - edgeIds.add(edge.id); - visitForward(targetNode.id); - } - } - - if ( - edge.target === boundaryNodeId && - edge.targetHandle === LOOP_LINKAGE_FIELD && - edge.sourceHandle === CONNECTOR_OUTPUT_HANDLE - ) { - const sourceNode = nodes.find((node) => node.id === edge.source); - if (isConnectorNode(sourceNode)) { - edgeIds.add(edge.id); - visitBackward(sourceNode.id); - } - } - } - - return edgeIds; -}; - -/** - * Builds the edge list used only for React Flow rendering. Complete connector - * aliases are presented as loop-linkage edges so they use the dashed green - * renderer; the supplied edge list is never mutated. - */ -export const getEdgesWithLoopLinkageAliases = (nodes: AnyNode[], edges: AnyEdge[]): AnyEdge[] => { - const loopLinkageEdgeIds = new Set(); - for (const edge of edges) { - if ( - edge.type !== 'default' || - edge.sourceHandle !== CONNECTOR_OUTPUT_HANDLE || - edge.targetHandle !== LOOP_LINKAGE_FIELD || - !isConnectorNode(nodes.find((node) => node.id === edge.source)) - ) { - continue; - } - - const path = resolveLoopLinkagePath(edge, nodes, edges); - path?.edgeIds.forEach((edgeId) => loopLinkageEdgeIds.add(edgeId)); - } - - return edges.map((edge) => (loopLinkageEdgeIds.has(edge.id) ? ({ ...edge, type: 'loop_linkage' } as AnyEdge) : edge)); -}; - -/** - * Resolves the source to use when removing a connector. Loop-linkage aliases - * preserve the remaining connector chain; ordinary data connectors keep - * splicing back to their invocation source. - */ -const resolveConnectorDeletionSource = ( - connectorId: string, - nodes: AnyNode[], - edges: AnyEdge[] -): ResolvedConnectorSource | null => { - const resolvedSource = resolveConnectorSource(connectorId, nodes, edges); - if (!resolvedSource) { - return null; - } - - const sourceNode = nodes.find((node) => node.id === resolvedSource.nodeId); - if ( - isInvocationNode(sourceNode) && - sourceNode.data.type === 'for' && - resolvedSource.fieldName === LOOP_LINKAGE_FIELD - ) { - const outputEdges = getConnectorOutputEdges(connectorId, edges); - if ( - outputEdges.length !== 1 || - outputEdges.some((edge) => { - const targetNode = nodes.find((node) => node.id === edge.target); - return !( - (isConnectorNode(targetNode) && edge.targetHandle === CONNECTOR_INPUT_HANDLE) || - (isInvocationNode(targetNode) && - targetNode.data.type === 'for_return' && - edge.targetHandle === LOOP_LINKAGE_FIELD) - ); - }) - ) { - return null; - } - } - - const linkagePath = getResolvedLoopLinkagePathForConnector(connectorId, nodes, edges); - const inputEdge = getConnectorInputEdge(connectorId, edges); - if (linkagePath && inputEdge && typeof inputEdge.sourceHandle === 'string') { - return { - nodeId: inputEdge.source, - fieldName: inputEdge.sourceHandle, - }; - } - - return resolvedSource; -}; - -export const resolvePendingConnectionSource = ( - pendingConnection: PendingConnection | null, - nodes: AnyNode[], - edges: AnyEdge[], - templates?: Templates -): ResolvedPendingConnectionSource | null => { - if (!pendingConnection || pendingConnection.handleType !== 'source') { - return null; - } - - const pendingNode = nodes.find((node) => node.id === pendingConnection.nodeId); - const resolvedSource = - pendingNode && isConnectorNode(pendingNode) - ? resolveConnectorSource(pendingNode.id, nodes, edges) - : isInvocationNode(pendingNode) - ? { nodeId: pendingNode.id, fieldName: pendingConnection.handleId } - : null; - if (!resolvedSource) { - return null; - } - - const sourceNode = nodes.find((node) => node.id === resolvedSource.nodeId); - const outputScope = - sourceNode && isInvocationNode(sourceNode) - ? (templates?.[sourceNode.data.type]?.outputs[resolvedSource.fieldName]?.output_scope ?? undefined) - : undefined; - - return { ...resolvedSource, outputScope }; -}; - export const resolveConnectorSourceFieldType = ( connectorId: string, nodes: AnyNode[], @@ -484,21 +109,15 @@ export const getConnectorDeletionSpliceConnections = ( connectorId: string, nodes: AnyNode[], edges: AnyEdge[], - templates?: Templates, - validateConnection?: SpliceConnectionValidator, - removedConnectorIds: ReadonlySet = new Set() + templates: Templates, + validateConnection?: SpliceConnectionValidator ): SpliceConnection[] | null => { - const resolvedSource = resolveSurvivingConnectorDeletionSource(connectorId, nodes, edges, removedConnectorIds); + const resolvedSource = resolveConnectorSource(connectorId, nodes, edges); if (!resolvedSource) { return null; } - const resolvedOutputEdges = getConnectorDeletionOutputEdges(connectorId, nodes, edges, removedConnectorIds); - if (!resolvedOutputEdges) { - return null; - } - const { edges: outputEdges, traversedEdgeIds } = resolvedOutputEdges; - const inputEdgeIds = getConnectorDeletionInputEdgeIds(connectorId, nodes, edges, removedConnectorIds); + const outputEdges = getConnectorOutputEdges(connectorId, edges); const spliceConnections = outputEdges .filter((edge): edge is AnyEdge & { type: 'default'; targetHandle: string } => edge.type === 'default') .map((edge) => ({ @@ -517,16 +136,13 @@ export const getConnectorDeletionSpliceConnections = ( deduped.add(key); } - if (!templates) { - return validateConnection ? null : spliceConnections; - } - if (!validateConnection) { const sourceType = resolveConnectorSourceFieldType(connectorId, nodes, edges, templates); if (!sourceType) { return null; } - const outputEdgeIds = traversedEdgeIds; + const inputEdgeId = getConnectorInputEdge(connectorId, edges)?.id; + const outputEdgeIds = new Set(outputEdges.map((edge) => edge.id)); for (const connection of spliceConnections) { const targetNode = nodes.find((node) => node.id === connection.target); @@ -557,7 +173,7 @@ export const getConnectorDeletionSpliceConnections = ( const existingTargetConflict = edges.some( (edge) => edge.type === 'default' && - !inputEdgeIds.has(edge.id) && + edge.id !== inputEdgeId && !outputEdgeIds.has(edge.id) && edge.target === connection.target && edge.targetHandle === connection.targetHandle @@ -580,7 +196,10 @@ export const getConnectorDeletionSpliceConnections = ( return spliceConnections; } - const ignoredEdgeIds = new Set([...inputEdgeIds, ...traversedEdgeIds]); + const ignoredEdgeIds = new Set([ + getConnectorInputEdge(connectorId, edges)?.id, + ...outputEdges.map((edge) => edge.id), + ]); const existingEdges = edges.filter((edge) => !ignoredEdgeIds.has(edge.id)); const stagedConnections: SpliceConnection[] = []; diff --git a/invokeai/frontend/web/src/features/nodes/store/util/getFirstValidConnection.test.ts b/invokeai/frontend/web/src/features/nodes/store/util/getFirstValidConnection.test.ts index 0f7487517a0..b4374a920c1 100644 --- a/invokeai/frontend/web/src/features/nodes/store/util/getFirstValidConnection.test.ts +++ b/invokeai/frontend/web/src/features/nodes/store/util/getFirstValidConnection.test.ts @@ -6,16 +6,7 @@ import { getSourceCandidateFields, getTargetCandidateFields, } from 'features/nodes/store/util/getFirstValidConnection'; -import { - add, - buildEdge, - buildNode, - for_loop, - for_return, - img_resize, - sub, - templates, -} from 'features/nodes/store/util/testUtils'; +import { add, buildEdge, buildNode, img_resize, sub, templates } from 'features/nodes/store/util/testUtils'; import { describe, expect, it } from 'vitest'; const buildConnectorNode = (id: string) => ({ @@ -169,77 +160,6 @@ describe('getFirstValidConnection', () => { targetHandle: 'width', }); }); - - it('should resolve a connector output candidate for a ForReturn linkage input', () => { - const forNode = buildNode(for_loop); - const connector = buildConnectorNode('connector-1'); - const returnNode = buildNode(for_return); - const edges = [buildEdge(forNode.id, 'loop_linkage', connector.id, CONNECTOR_INPUT_HANDLE)]; - const loopTemplates = { ...templates, for: for_loop, for_return }; - - expect( - getFirstValidConnection( - connector.id, - null, - returnNode.id, - 'loop_linkage', - [forNode, connector, returnNode], - edges, - loopTemplates, - null - ) - ).toEqual({ - source: connector.id, - sourceHandle: CONNECTOR_OUTPUT_HANDLE, - target: returnNode.id, - targetHandle: 'loop_linkage', - }); - }); - - it('should auto-wire a For iteration item output to the ForReturn output input', () => { - const forNode = buildNode(for_loop); - const returnNode = buildNode(for_return); - const loopTemplates = { for: for_loop, for_return }; - - expect( - getFirstValidConnection(forNode.id, 'item', returnNode.id, null, [forNode, returnNode], [], loopTemplates, null) - ).toEqual({ - source: forNode.id, - sourceHandle: 'item', - target: returnNode.id, - targetHandle: 'output', - }); - }); - - it('should auto-wire a For state output to the ForReturn state input', () => { - const forNode = buildNode(for_loop); - const returnNode = buildNode(for_return); - const loopTemplates = { for: for_loop, for_return }; - - expect( - getFirstValidConnection(forNode.id, 'state', returnNode.id, null, [forNode, returnNode], [], loopTemplates, null) - ).toEqual({ - source: forNode.id, - sourceHandle: 'state', - target: returnNode.id, - targetHandle: 'state', - }); - }); - - it('should auto-wire the exact For state output to a fixed ForReturn state input', () => { - const forNode = buildNode(for_loop); - const returnNode = buildNode(for_return); - const loopTemplates = { for: for_loop, for_return }; - - expect( - getFirstValidConnection(forNode.id, null, returnNode.id, 'state', [forNode, returnNode], [], loopTemplates, null) - ).toEqual({ - source: forNode.id, - sourceHandle: 'state', - target: returnNode.id, - targetHandle: 'state', - }); - }); }); describe('getTargetCandidateFields', () => { diff --git a/invokeai/frontend/web/src/features/nodes/store/util/getFirstValidConnection.ts b/invokeai/frontend/web/src/features/nodes/store/util/getFirstValidConnection.ts index 70b1fd5bef2..17b068ad0c5 100644 --- a/invokeai/frontend/web/src/features/nodes/store/util/getFirstValidConnection.ts +++ b/invokeai/frontend/web/src/features/nodes/store/util/getFirstValidConnection.ts @@ -1,31 +1,16 @@ import type { Connection } from '@xyflow/react'; import { map } from 'es-toolkit/compat'; import type { Templates } from 'features/nodes/store/types'; -import { areTypesEqual } from 'features/nodes/store/util/areTypesEqual'; import { CONNECTOR_INPUT_HANDLE, CONNECTOR_OUTPUT_HANDLE, resolveConnectorSourceFieldType, } from 'features/nodes/store/util/connectorTopology'; import { validateConnection } from 'features/nodes/store/util/validateConnection'; -import type { FieldInputTemplate, FieldOutputTemplate, FieldType } from 'features/nodes/types/field'; +import type { FieldInputTemplate, FieldOutputTemplate } from 'features/nodes/types/field'; import type { AnyEdge, AnyNode } from 'features/nodes/types/invocation'; import { getInvocationNodeInputTemplate, isConnectorNode, isInvocationNode } from 'features/nodes/types/invocation'; -const rankCandidateFieldsByType = ( - fields: T[], - type: FieldType | null | undefined -): T[] => { - if (!type) { - return fields; - } - - return [ - ...fields.filter((field) => areTypesEqual(type, field.type)), - ...fields.filter((field) => !areTypesEqual(type, field.type)), - ]; -}; - /** * * @param source The source (node id) @@ -142,10 +127,7 @@ export const getTargetCandidateFields = ( return []; } - let sourceFieldType: FieldType | null | undefined; - if (isConnectorNode(sourceNode)) { - sourceFieldType = resolveConnectorSourceFieldType(sourceNode.id, nodes, edges, templates); - } else { + if (!isConnectorNode(sourceNode)) { const sourceTemplate = templates[sourceNode.data.type]; if (!sourceTemplate) { return []; @@ -156,8 +138,6 @@ export const getTargetCandidateFields = ( if (!sourceField) { return []; } - - sourceFieldType = sourceField.type; } const targetCandidateFields = Object.entries(targetNode.data.inputs).flatMap(([fieldName, input]) => { @@ -170,7 +150,7 @@ export const getTargetCandidateFields = ( return connectionErrorTKey === null ? [field] : []; }); - return rankCandidateFieldsByType(targetCandidateFields, sourceFieldType); + return targetCandidateFields; }; export const getSourceCandidateFields = ( @@ -218,7 +198,6 @@ export const getSourceCandidateFields = ( return []; } - let targetFieldType: FieldType | undefined; if (!isConnectorNode(targetNode)) { if (!isInvocationNode(targetNode)) { return []; @@ -233,7 +212,6 @@ export const getSourceCandidateFields = ( if (!targetField) { return []; } - targetFieldType = targetField.type; } else if (targetHandle !== CONNECTOR_INPUT_HANDLE) { return []; } @@ -244,5 +222,5 @@ export const getSourceCandidateFields = ( return connectionErrorTKey === null; }); - return rankCandidateFieldsByType(sourceCandidateFields, targetFieldType); + return sourceCandidateFields; }; diff --git a/invokeai/frontend/web/src/features/nodes/store/util/getHasCycles.ts b/invokeai/frontend/web/src/features/nodes/store/util/getHasCycles.ts index 29b79bb18cd..9b0b99e48de 100644 --- a/invokeai/frontend/web/src/features/nodes/store/util/getHasCycles.ts +++ b/invokeai/frontend/web/src/features/nodes/store/util/getHasCycles.ts @@ -19,9 +19,7 @@ export const getHasCycles = (source: string, target: string, nodes: Node[], edge }); edges.forEach((e) => { - if (e.type === 'default') { - g.setEdge(e.source, e.target); - } + g.setEdge(e.source, e.target); }); // add the candidate edge diff --git a/invokeai/frontend/web/src/features/nodes/store/util/reactFlowUtil.test.ts b/invokeai/frontend/web/src/features/nodes/store/util/reactFlowUtil.test.ts index b0856dfab1a..b70eda4bdaa 100644 --- a/invokeai/frontend/web/src/features/nodes/store/util/reactFlowUtil.test.ts +++ b/invokeai/frontend/web/src/features/nodes/store/util/reactFlowUtil.test.ts @@ -20,22 +20,4 @@ describe('connectionToEdge', () => { id: 'reactflow__edge-source-nodevalue-target-nodea', }); }); - - it('creates a loop linkage edge when both handles are loop linkage handles', () => { - expect( - connectionToEdge({ - source: 'for-node', - sourceHandle: 'loop_linkage', - target: 'return-node', - targetHandle: 'loop_linkage', - }) - ).toEqual({ - type: 'loop_linkage', - source: 'for-node', - sourceHandle: 'loop_linkage', - target: 'return-node', - targetHandle: 'loop_linkage', - id: 'reactflow__edge-for-nodeloop_linkage-return-nodeloop_linkage', - }); - }); }); diff --git a/invokeai/frontend/web/src/features/nodes/store/util/reactFlowUtil.ts b/invokeai/frontend/web/src/features/nodes/store/util/reactFlowUtil.ts index 451d8ff384e..3eaece154fb 100644 --- a/invokeai/frontend/web/src/features/nodes/store/util/reactFlowUtil.ts +++ b/invokeai/frontend/web/src/features/nodes/store/util/reactFlowUtil.ts @@ -1,18 +1,7 @@ import type { Connection } from '@xyflow/react'; -import { LOOP_LINKAGE_FIELD } from 'features/nodes/types/constants'; import type { AnyEdge } from 'features/nodes/types/invocation'; import { assert } from 'tsafe'; -export const getEdgeTypeFromHandles = ( - sourceHandle: string | null | undefined, - targetHandle: string | null | undefined -): 'default' | 'loop_linkage' => - sourceHandle === LOOP_LINKAGE_FIELD && targetHandle === LOOP_LINKAGE_FIELD ? 'loop_linkage' : 'default'; - -export const isLoopLinkageEdge = (edge: Pick): boolean => - edge.type === 'loop_linkage' || - (edge.type === 'default' && getEdgeTypeFromHandles(edge.sourceHandle, edge.targetHandle) === 'loop_linkage'); - /** * Gets the edge id for a connection * Copied from: https://github.com/xyflow/xyflow/blob/v11/packages/core/src/utils/graph.ts#L44-L45 @@ -35,7 +24,7 @@ export const connectionToEdge = (connection: Connection): AnyEdge => { const { source, sourceHandle, target, targetHandle } = connection; assert(source && sourceHandle && target && targetHandle, 'Invalid connection'); return { - type: getEdgeTypeFromHandles(sourceHandle, targetHandle), + type: 'default', source, sourceHandle, target, diff --git a/invokeai/frontend/web/src/features/nodes/store/util/testUtils.ts b/invokeai/frontend/web/src/features/nodes/store/util/testUtils.ts index 6632e270486..67c477408f3 100644 --- a/invokeai/frontend/web/src/features/nodes/store/util/testUtils.ts +++ b/invokeai/frontend/web/src/features/nodes/store/util/testUtils.ts @@ -12,15 +12,6 @@ export const buildEdge = (source: string, sourceHandle: string, target: string, id: `reactflow__edge-${source}${sourceHandle}-${target}${targetHandle}`, }); -export const buildLoopLinkageEdge = (source: string, target: string): AnyEdge => ({ - source, - sourceHandle: 'loop_linkage', - target, - targetHandle: 'loop_linkage', - type: 'loop_linkage', - id: `reactflow__edge-${source}-loop_linkage-${target}-loop_linkage`, -}); - export const buildNode = (template: InvocationTemplate) => buildInvocationNode({ x: 0, y: 0 }, template); export const add: InvocationTemplate = { @@ -640,251 +631,6 @@ const iterate: InvocationTemplate = { category: 'collections', }; -export const for_loop: InvocationTemplate = { - title: 'For', - type: 'for', - version: '1.3.0', - tags: [], - description: '', - outputType: 'for_output', - inputs: { - collection: { - name: 'collection', - title: 'Collection', - required: false, - default: undefined, - description: 'The list of items to iterate over', - fieldKind: 'input', - input: 'connection', - ui_hidden: false, - ui_type: 'CollectionField', - type: { - name: 'CollectionField', - cardinality: 'COLLECTION', - batch: false, - }, - }, - state: { - name: 'state', - title: 'State', - required: false, - default: undefined, - description: 'Optional initial loop state', - fieldKind: 'input', - input: 'connection', - ui_hidden: false, - type: { - name: 'LoopState', - cardinality: 'SINGLE', - batch: false, - }, - }, - }, - outputs: { - loop_linkage: { - fieldKind: 'output', - name: 'loop_linkage', - title: 'Loop Linkage', - description: 'The loop linkage to the matching ForReturn', - type: { - name: 'AnyField', - cardinality: 'SINGLE', - batch: false, - }, - ui_hidden: false, - ui_type: 'AnyField', - }, - item: { - fieldKind: 'output', - name: 'item', - title: 'Collection Item', - description: 'The item for the current loop iteration, or None when the collection is empty', - type: { - name: 'CollectionItemField', - cardinality: 'SINGLE', - batch: false, - }, - ui_hidden: false, - ui_type: 'CollectionItemField', - output_scope: 'iteration', - }, - index: { - fieldKind: 'output', - name: 'index', - title: 'Index', - description: 'The index for the current loop iteration', - type: { - name: 'IntegerField', - cardinality: 'SINGLE', - batch: false, - }, - ui_hidden: false, - output_scope: 'iteration', - }, - total: { - fieldKind: 'output', - name: 'total', - title: 'Total', - description: 'The total number of items in the loop collection', - type: { - name: 'IntegerField', - cardinality: 'SINGLE', - batch: false, - }, - ui_hidden: false, - output_scope: 'iteration', - }, - state: { - fieldKind: 'output', - name: 'state', - title: 'State', - description: 'The state for the current loop iteration', - type: { - name: 'LoopState', - cardinality: 'SINGLE', - batch: false, - }, - ui_hidden: false, - output_scope: 'iteration', - }, - output_collection: { - fieldKind: 'output', - name: 'output_collection', - title: 'Output Collection', - description: 'The collected loop body outputs', - type: { - name: 'CollectionField', - cardinality: 'COLLECTION', - batch: false, - }, - ui_hidden: false, - ui_type: 'CollectionField', - output_scope: 'final', - }, - final_state: { - fieldKind: 'output', - name: 'final_state', - title: 'Final State', - description: 'The final loop state', - type: { - name: 'LoopState', - cardinality: 'SINGLE', - batch: false, - }, - ui_hidden: false, - output_scope: 'final', - }, - }, - useCache: true, - nodePack: 'invokeai', - classification: 'stable', - category: 'other', -}; - -export const for_return: InvocationTemplate = { - title: 'ForReturn', - type: 'for_return', - version: '1.3.2', - tags: [], - description: '', - outputType: 'for_return_output', - inputs: { - loop_linkage: { - name: 'loop_linkage', - title: 'Loop Linkage', - required: false, - default: undefined, - description: 'The loop linkage from the matching For', - fieldKind: 'input', - input: 'connection', - ui_hidden: false, - ui_type: 'AnyField', - type: { - name: 'AnyField', - cardinality: 'SINGLE', - batch: false, - }, - }, - output: { - name: 'output', - title: 'Output', - required: false, - default: undefined, - description: 'The output item to append to the loop output collection', - fieldKind: 'input', - input: 'connection', - ui_hidden: false, - ui_type: 'CollectionItemField', - type: { - name: 'CollectionItemField', - cardinality: 'SINGLE', - batch: false, - }, - }, - state: { - name: 'state', - title: 'State', - required: false, - default: undefined, - description: 'The state to pass to the next loop iteration', - fieldKind: 'input', - input: 'connection', - ui_hidden: false, - type: { - name: 'LoopState', - cardinality: 'SINGLE', - batch: false, - }, - }, - continue_condition: { - name: 'continue_condition', - title: 'Continue Condition', - required: false, - default: true, - description: 'Whether to schedule the next loop iteration; false finalizes the loop', - fieldKind: 'input', - input: 'any', - ui_hidden: false, - type: { - name: 'BooleanField', - cardinality: 'SINGLE', - batch: false, - }, - }, - }, - outputs: { - output: { - fieldKind: 'output', - name: 'output', - title: 'Output', - description: 'The output item to append to the loop output collection', - type: { - name: 'CollectionItemField', - cardinality: 'SINGLE', - batch: false, - }, - ui_hidden: true, - ui_type: 'CollectionItemField', - }, - state: { - fieldKind: 'output', - name: 'state', - title: 'State', - description: 'The state to pass to the next loop iteration', - type: { - name: 'LoopState', - cardinality: 'SINGLE', - batch: false, - }, - ui_hidden: true, - }, - }, - useCache: true, - nodePack: 'invokeai', - classification: 'stable', - category: 'other', -}; - export const templates: Templates = { add, call_saved_workflow, diff --git a/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.test.ts b/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.test.ts index f870084eeb9..1eef0794436 100644 --- a/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.test.ts +++ b/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.test.ts @@ -2,7 +2,7 @@ import { deepClone } from 'common/util/deepClone'; import { set } from 'es-toolkit/compat'; import { callSavedWorkflowDynamicFieldsChanged, nodesSliceConfig } from 'features/nodes/store/nodesSlice'; import type { IntegerFieldInputTemplate } from 'features/nodes/types/field'; -import type { AnyEdge, InvocationTemplate } from 'features/nodes/types/invocation'; +import type { InvocationTemplate } from 'features/nodes/types/invocation'; import { describe, expect, it } from 'vitest'; import { @@ -13,12 +13,9 @@ import { import { add, buildEdge, - buildLoopLinkageEdge, buildNode, call_saved_workflow, collect, - for_loop, - for_return, img_resize, main_model_loader, sub, @@ -104,18 +101,6 @@ const ifTemplate: InvocationTemplate = { classification: 'stable', }; -const buildConnectorNode = (id: string) => ({ - id, - type: 'connector' as const, - position: { x: 0, y: 0 }, - data: { - id, - type: 'connector' as const, - label: 'Connector', - isOpen: true, - }, -}); - const floatOutputTemplate: InvocationTemplate = { title: 'Float Output', type: 'float_output', @@ -234,6 +219,18 @@ const workflowReturnTemplate: InvocationTemplate = { classification: 'beta', }; +const buildConnectorNode = (id: string) => ({ + id, + type: 'connector' as const, + position: { x: 0, y: 0 }, + data: { + id, + type: 'connector' as const, + label: 'Connector', + isOpen: true, + }, +}); + describe(validateConnection.name, () => { it('should reject invalid connection to self', () => { const c = { source: 'add', sourceHandle: 'value', target: 'add', targetHandle: 'a' }; @@ -488,595 +485,6 @@ describe(validateConnection.name, () => { expect(r).toEqual('nodes.fieldTypesMustMatch'); }); - describe('loop linkage', () => { - it('accepts a For to ForReturn linkage connection', () => { - const forNode = buildNode(for_loop); - const returnNode = buildNode(for_return); - - expect( - validateConnection( - { - source: forNode.id, - sourceHandle: 'loop_linkage', - target: returnNode.id, - targetHandle: 'loop_linkage', - }, - [forNode, returnNode], - [], - templates, - null - ) - ).toBeNull(); - }); - - it('rejects a linkage connection with a non-linkage handle', () => { - const forNode = buildNode(for_loop); - const returnNode = buildNode(for_return); - - expect( - validateConnection( - { - source: forNode.id, - sourceHandle: 'item', - target: returnNode.id, - targetHandle: 'loop_linkage', - }, - [forNode, returnNode], - [], - templates, - null - ) - ).toBe('nodes.forLoopLinkageInvalid'); - }); - - it('accepts the For side of a connector linkage alias', () => { - const forNode = buildNode(for_loop); - const connector = buildConnectorNode('connector-1'); - - expect( - validateConnection( - { - source: forNode.id, - sourceHandle: 'loop_linkage', - target: connector.id, - targetHandle: CONNECTOR_INPUT_HANDLE, - }, - [forNode, connector], - [], - templates, - null - ) - ).toBeNull(); - }); - - it('rejects the For side of an alias that would reuse an already-linked ForReturn', () => { - const forNode = buildNode(for_loop); - const existingForNode = buildNode(for_loop); - const connector = buildConnectorNode('connector-1'); - const returnNode = buildNode(for_return); - const edges = [ - buildLoopLinkageEdge(existingForNode.id, returnNode.id), - buildEdge(connector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), - ]; - - expect( - validateConnection( - { - source: forNode.id, - sourceHandle: 'loop_linkage', - target: connector.id, - targetHandle: CONNECTOR_INPUT_HANDLE, - }, - [forNode, existingForNode, connector, returnNode], - edges, - { ...templates, for: for_loop, for_return }, - null - ) - ).toBe('nodes.forLoopLinkageDuplicate'); - }); - - it('rejects the For side of a connector linkage alias with an occupied connector input', () => { - const forNode = buildNode(for_loop); - const sourceNode = buildNode(sub); - const connector = buildConnectorNode('connector-1'); - const edges = [buildEdge(sourceNode.id, 'value', connector.id, CONNECTOR_INPUT_HANDLE)]; - - expect( - validateConnection( - { - source: forNode.id, - sourceHandle: 'loop_linkage', - target: connector.id, - targetHandle: CONNECTOR_INPUT_HANDLE, - }, - [forNode, sourceNode, connector], - edges, - { ...templates, for: for_loop }, - null - ) - ).toBe('nodes.inputMayOnlyHaveOneConnection'); - }); - - it('accepts the ForReturn side of a complete connector linkage alias', () => { - const forNode = buildNode(for_loop); - const connector = buildConnectorNode('connector-1'); - const returnNode = buildNode(for_return); - const edges = [buildEdge(forNode.id, 'loop_linkage', connector.id, CONNECTOR_INPUT_HANDLE)]; - - expect( - validateConnection( - { - source: connector.id, - sourceHandle: CONNECTOR_OUTPUT_HANDLE, - target: returnNode.id, - targetHandle: 'loop_linkage', - }, - [forNode, connector, returnNode], - edges, - templates, - null - ) - ).toBeNull(); - }); - - it('accepts a connector chain edge between an attached For and ForReturn', () => { - const forNode = buildNode(for_loop); - const firstConnector = buildConnectorNode('connector-1'); - const secondConnector = buildConnectorNode('connector-2'); - const returnNode = buildNode(for_return); - const edges = [ - buildEdge(forNode.id, 'loop_linkage', firstConnector.id, CONNECTOR_INPUT_HANDLE), - buildEdge(secondConnector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), - ]; - - expect( - validateConnection( - { - source: firstConnector.id, - sourceHandle: CONNECTOR_OUTPUT_HANDLE, - target: secondConnector.id, - targetHandle: CONNECTOR_INPUT_HANDLE, - }, - [forNode, firstConnector, secondConnector, returnNode], - edges, - templates, - null - ) - ).toBeNull(); - }); - - it('accepts a connector deletion splice that preserves a loop linkage chain', () => { - const forNode = buildNode(for_loop); - const firstConnector = buildConnectorNode('connector-1'); - const terminalConnector = buildConnectorNode('connector-2'); - const returnNode = buildNode(for_return); - const edges = [ - buildEdge(forNode.id, 'loop_linkage', firstConnector.id, CONNECTOR_INPUT_HANDLE), - buildEdge(firstConnector.id, CONNECTOR_OUTPUT_HANDLE, terminalConnector.id, CONNECTOR_INPUT_HANDLE), - buildEdge(terminalConnector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), - ]; - - expect( - getConnectorDeletionSpliceConnections( - terminalConnector.id, - [forNode, firstConnector, terminalConnector, returnNode], - edges, - templates, - validateConnection - ) - ).toEqual([ - { - source: firstConnector.id, - sourceHandle: CONNECTOR_OUTPUT_HANDLE, - target: returnNode.id, - targetHandle: 'loop_linkage', - }, - ]); - }); - - it('rejects a connector loop linkage chain that creates a cycle', () => { - const forNode = buildNode(for_loop); - const firstConnector = buildConnectorNode('connector-1'); - const secondConnector = buildConnectorNode('connector-2'); - const edges = [ - buildEdge(forNode.id, 'loop_linkage', firstConnector.id, CONNECTOR_INPUT_HANDLE), - buildEdge(secondConnector.id, CONNECTOR_OUTPUT_HANDLE, firstConnector.id, CONNECTOR_INPUT_HANDLE), - ]; - - expect( - validateConnection( - { - source: firstConnector.id, - sourceHandle: CONNECTOR_OUTPUT_HANDLE, - target: secondConnector.id, - targetHandle: CONNECTOR_INPUT_HANDLE, - }, - [forNode, firstConnector, secondConnector], - edges, - { ...templates, for: for_loop }, - null - ) - ).toBe('nodes.connectionWouldCreateCycle'); - }); - - it('rejects an unresolved connector linkage alias for an already-owned ForReturn', () => { - const connector = buildConnectorNode('connector-1'); - const forNode = buildNode(for_loop); - const returnNode = buildNode(for_return); - const existingForNode = buildNode(for_loop); - const existingReturnNode = buildNode(for_return); - const edges = [buildLoopLinkageEdge(existingForNode.id, returnNode.id)]; - - expect( - validateConnection( - { - source: connector.id, - sourceHandle: CONNECTOR_OUTPUT_HANDLE, - target: returnNode.id, - targetHandle: 'loop_linkage', - }, - [connector, forNode, returnNode, existingForNode, existingReturnNode], - edges, - templates, - null - ) - ).toBe('nodes.forLoopLinkageDuplicate'); - }); - - it('rejects a connector linkage alias reused by a second ForReturn', () => { - const forNode = buildNode(for_loop); - const connector = buildConnectorNode('connector-1'); - const firstReturnNode = buildNode(for_return); - const secondReturnNode = buildNode(for_return); - const edges = [ - buildEdge(forNode.id, 'loop_linkage', connector.id, CONNECTOR_INPUT_HANDLE), - buildEdge(connector.id, CONNECTOR_OUTPUT_HANDLE, firstReturnNode.id, 'loop_linkage'), - ]; - - expect( - validateConnection( - { - source: connector.id, - sourceHandle: CONNECTOR_OUTPUT_HANDLE, - target: secondReturnNode.id, - targetHandle: 'loop_linkage', - }, - [forNode, connector, firstReturnNode, secondReturnNode], - edges, - templates, - null - ) - ).toBe('nodes.forLoopLinkageInvalid'); - }); - - it('rejects a connector linkage alias that shares a ForReturn with another alias', () => { - const firstForNode = buildNode(for_loop); - const secondForNode = buildNode(for_loop); - const firstConnector = buildConnectorNode('connector-1'); - const secondConnector = buildConnectorNode('connector-2'); - const returnNode = buildNode(for_return); - const edges = [ - buildEdge(firstForNode.id, 'loop_linkage', firstConnector.id, CONNECTOR_INPUT_HANDLE), - buildEdge(firstConnector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), - buildEdge(secondConnector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), - ]; - - expect( - validateConnection( - { - source: secondForNode.id, - sourceHandle: 'loop_linkage', - target: secondConnector.id, - targetHandle: CONNECTOR_INPUT_HANDLE, - }, - [firstForNode, secondForNode, firstConnector, secondConnector, returnNode], - edges, - templates, - null - ) - ).toBe('nodes.forLoopLinkageDuplicate'); - }); - - it('rejects a loop linkage connector reused for ordinary data', () => { - const forNode = buildNode(for_loop); - const connector = buildConnectorNode('connector-1'); - const targetNode = buildNode(sub); - const edges = [buildEdge(forNode.id, 'loop_linkage', connector.id, CONNECTOR_INPUT_HANDLE)]; - - expect( - validateConnection( - { - source: connector.id, - sourceHandle: CONNECTOR_OUTPUT_HANDLE, - target: targetNode.id, - targetHandle: 'a', - }, - [forNode, connector, targetNode], - edges, - { ...templates, for: for_loop }, - null - ) - ).toBe('nodes.forLoopLinkageInvalid'); - }); - - it('rejects linkage connections that do not join For and ForReturn', () => { - const sourceNode = buildNode(add); - const returnNode = buildNode(for_return); - - expect( - validateConnection( - { - source: sourceNode.id, - sourceHandle: 'loop_linkage', - target: returnNode.id, - targetHandle: 'loop_linkage', - }, - [sourceNode, returnNode], - [], - templates, - null - ) - ).toBe('nodes.forLoopLinkageInvalid'); - }); - - it('rejects linkage connections that duplicate either endpoint', () => { - const firstForNode = buildNode(for_loop); - const secondForNode = buildNode(for_loop); - const firstReturnNode = buildNode(for_return); - const secondReturnNode = buildNode(for_return); - const existingEdge = buildLoopLinkageEdge(firstForNode.id, firstReturnNode.id); - const nodes = [firstForNode, secondForNode, firstReturnNode, secondReturnNode]; - - expect( - validateConnection( - { - source: firstForNode.id, - sourceHandle: 'loop_linkage', - target: secondReturnNode.id, - targetHandle: 'loop_linkage', - }, - nodes, - [existingEdge], - templates, - null - ) - ).toBe('nodes.forLoopLinkageDuplicate'); - - expect( - validateConnection( - { - source: secondForNode.id, - sourceHandle: 'loop_linkage', - target: firstReturnNode.id, - targetHandle: 'loop_linkage', - }, - nodes, - [existingEdge], - templates, - null - ) - ).toBe('nodes.forLoopLinkageDuplicate'); - }); - }); - - describe('loop output scopes', () => { - const loopTemplates = { for: for_loop, for_return }; - const loopSinkTemplate: InvocationTemplate = { - ...for_return, - title: 'Loop Sink', - type: 'loop_sink', - outputType: 'loop_sink_output', - outputs: {}, - }; - - it('rejects a final-scoped output connected into an existing iteration body', () => { - const forNode = buildNode(for_loop); - const returnNode = buildNode(for_return); - const nodes = [forNode, returnNode]; - const edges = [buildEdge(forNode.id, 'item', returnNode.id, 'output')]; - const connection = { - source: forNode.id, - sourceHandle: 'final_state', - target: returnNode.id, - targetHandle: 'state', - }; - - expect(validateConnection(connection, nodes, edges, loopTemplates, null)).toEqual( - 'nodes.loopOutputScopeConflict' - ); - }); - - it('rejects an iteration-scoped output that makes an existing final output part of the body', () => { - const forNode = buildNode(for_loop); - const returnNode = buildNode(for_return); - const nodes = [forNode, returnNode]; - const edges = [buildEdge(forNode.id, 'final_state', returnNode.id, 'state')]; - const connection = { - source: forNode.id, - sourceHandle: 'item', - target: returnNode.id, - targetHandle: 'output', - }; - - expect(validateConnection(connection, nodes, edges, loopTemplates, null)).toEqual( - 'nodes.loopOutputScopeConflict' - ); - }); - - it('rejects a final-scoped output connected to a descendant of an iteration body node', () => { - const forNode = buildNode(for_loop); - const bodyNode = buildNode(ifTemplate); - const returnNode = buildNode(for_return); - const nodes = [forNode, bodyNode, returnNode]; - const edges = [ - buildEdge(forNode.id, 'item', bodyNode.id, 'true_input'), - buildEdge(bodyNode.id, 'value', returnNode.id, 'output'), - ]; - const connection = { - source: forNode.id, - sourceHandle: 'final_state', - target: returnNode.id, - targetHandle: 'state', - }; - - expect(validateConnection(connection, nodes, edges, { ...loopTemplates, if: ifTemplate }, null)).toEqual( - 'nodes.loopOutputScopeConflict' - ); - }); - - it('rejects a final-scoped output routed into the iteration body through a connector', () => { - const forNode = buildNode(for_loop); - const connectorNode = buildConnectorNode('connector'); - const returnNode = buildNode(for_return); - const nodes = [forNode, connectorNode, returnNode]; - const edges = [ - buildEdge(forNode.id, 'item', returnNode.id, 'output'), - buildEdge(forNode.id, 'final_state', connectorNode.id, CONNECTOR_INPUT_HANDLE), - ]; - const connection = { - source: connectorNode.id, - sourceHandle: CONNECTOR_OUTPUT_HANDLE, - target: returnNode.id, - targetHandle: 'state', - }; - - expect(validateConnection(connection, nodes, edges, loopTemplates, null)).toEqual( - 'nodes.loopOutputScopeConflict' - ); - }); - - it('rejects a final-scoped output that reaches the iteration body through downstream nodes', () => { - const forNode = buildNode(for_loop); - const bodyNode = buildNode(ifTemplate); - const downstreamNode = buildNode(ifTemplate); - const downstreamNode2 = buildNode(ifTemplate); - const returnNode = buildNode(for_return); - const nodes = [forNode, bodyNode, downstreamNode, downstreamNode2, returnNode]; - const edges = [ - buildEdge(forNode.id, 'item', bodyNode.id, 'true_input'), - buildEdge(bodyNode.id, 'value', returnNode.id, 'output'), - buildEdge(downstreamNode.id, 'value', downstreamNode2.id, 'true_input'), - buildEdge(downstreamNode2.id, 'value', returnNode.id, 'state'), - ]; - const connection = { - source: forNode.id, - sourceHandle: 'final_state', - target: downstreamNode.id, - targetHandle: 'true_input', - }; - - expect(validateConnection(connection, nodes, edges, { ...loopTemplates, if: ifTemplate }, null)).toEqual( - 'nodes.loopOutputScopeConflict' - ); - }); - - it('accepts iteration-scoped outputs within the body', () => { - const forNode = buildNode(for_loop); - const returnNode = buildNode(for_return); - const nodes = [forNode, returnNode]; - const edges = [buildEdge(forNode.id, 'item', returnNode.id, 'output')]; - const connection = { - source: forNode.id, - sourceHandle: 'state', - target: returnNode.id, - targetHandle: 'state', - }; - - expect(validateConnection(connection, nodes, edges, loopTemplates, null)).toBeNull(); - }); - - it('accepts final-scoped outputs outside the iteration body', () => { - const forNode = buildNode(for_loop); - const bodyReturnNode = buildNode(for_return); - const afterLoopNode = buildNode(for_loop); - const nodes = [forNode, bodyReturnNode, afterLoopNode]; - const edges = [buildEdge(forNode.id, 'item', bodyReturnNode.id, 'output')]; - const connection = { - source: forNode.id, - sourceHandle: 'final_state', - target: afterLoopNode.id, - targetHandle: 'state', - }; - - expect(validateConnection(connection, nodes, edges, loopTemplates, null)).toBeNull(); - }); - - it.each([ - ['iteration', 'final', 'extension'], - ['final', 'iteration', 'extension'], - ['extension', 'final', 'iteration'], - ] as const)('rejects scope overlap regardless of incremental edge order: %s, %s, %s', (...order) => { - const forNode = buildNode(for_loop); - const bodyNode = buildNode(ifTemplate); - const afterLoopNode = buildNode(loopSinkTemplate); - const nodes = [forNode, bodyNode, afterLoopNode]; - const edgeByName = { - iteration: buildEdge(forNode.id, 'item', bodyNode.id, 'true_input'), - final: buildEdge(forNode.id, 'final_state', afterLoopNode.id, 'state'), - extension: buildEdge(bodyNode.id, 'value', afterLoopNode.id, 'output'), - }; - const templates = { - ...loopTemplates, - if: ifTemplate, - loop_sink: loopSinkTemplate, - }; - const acceptedEdges: AnyEdge[] = []; - const results = order.map((name) => { - const edge = edgeByName[name]; - if (edge.type !== 'default' || !edge.sourceHandle || !edge.targetHandle) { - throw new Error('Expected a default edge with field handles'); - } - const result = validateConnection( - { - source: edge.source, - sourceHandle: edge.sourceHandle, - target: edge.target, - targetHandle: edge.targetHandle, - }, - nodes, - acceptedEdges, - templates, - null - ); - if (result === null) { - acceptedEdges.push(edge); - } - return result; - }); - - expect(results).toContain('nodes.loopOutputScopeConflict'); - }); - - it('allows an unrelated connection when the graph already contains a scope conflict', () => { - const forNode = buildNode(for_loop); - const bodyNode = buildNode(ifTemplate); - const afterLoopNode = buildNode(loopSinkTemplate); - const addNode = buildNode(add); - const subNode = buildNode(sub); - const nodes = [forNode, bodyNode, afterLoopNode, addNode, subNode]; - const edges = [ - buildEdge(forNode.id, 'item', bodyNode.id, 'true_input'), - buildEdge(forNode.id, 'final_state', afterLoopNode.id, 'state'), - buildEdge(bodyNode.id, 'value', afterLoopNode.id, 'output'), - ]; - const templates = { - ...loopTemplates, - add, - sub, - if: ifTemplate, - loop_sink: loopSinkTemplate, - }; - const connection = { - source: addNode.id, - sourceHandle: 'value', - target: subNode.id, - targetHandle: 'a', - }; - - expect(validateConnection(connection, nodes, edges, templates, null)).toBeNull(); - }); - }); - it('should reject mismatched types between if node branch inputs', () => { const n1 = buildNode(add); const n2 = buildNode(img_resize); diff --git a/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.ts b/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.ts index 5d2a88fd0b9..710e49de7cf 100644 --- a/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.ts +++ b/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.ts @@ -5,19 +5,15 @@ import { CONNECTOR_INPUT_HANDLE, CONNECTOR_OUTPUT_HANDLE, resolveConnectorSource, - resolveLoopLinkagePath, } from 'features/nodes/store/util/connectorTopology'; import { getCollectItemType } from 'features/nodes/store/util/getCollectItemType'; import { getHasCycles } from 'features/nodes/store/util/getHasCycles'; import { validateConnectionTypes } from 'features/nodes/store/util/validateConnectionTypes'; -import { LOOP_LINKAGE_FIELD } from 'features/nodes/types/constants'; import type { FieldType } from 'features/nodes/types/field'; import type { AnyEdge, AnyNode, InvocationNode } from 'features/nodes/types/invocation'; import { getInvocationNodeInputTemplate, isConnectorNode, isInvocationNode } from 'features/nodes/types/invocation'; import type { SetNonNullable } from 'type-fest'; -import { isLoopLinkageEdge } from './reactFlowUtil'; - type Connection = SetNonNullable; type ValidateConnectionFunc = ( @@ -199,182 +195,6 @@ const getEffectiveSourceForEdge = ( return getEffectiveSource(edge.source, edge.sourceHandle, nodes, edges, templates); }; -const getOutputScopeConflicts = (nodes: AnyNode[], edges: AnyEdge[], templates: Templates) => { - const nodeById = new Map(nodes.map((node) => [node.id, node])); - const connectorInputById = new Map(); - const scopedOutputByEndpoint = new Map(); - - for (const node of nodes) { - if (!isInvocationNode(node)) { - continue; - } - const template = templates[node.data.type]; - if (!template) { - continue; - } - for (const output of Object.values(template.outputs)) { - if (output.output_scope) { - scopedOutputByEndpoint.set(`${node.id}\0${output.name}`, { - nodeId: node.id, - scope: output.output_scope, - }); - } - } - } - - if (scopedOutputByEndpoint.size === 0) { - return new Set(); - } - - for (const edge of edges) { - if ( - edge.type === 'default' && - edge.targetHandle === CONNECTOR_INPUT_HANDLE && - !connectorInputById.has(edge.target) - ) { - connectorInputById.set(edge.target, edge); - } - } - - type ScopedOutput = { nodeId: string; scope: 'iteration' | 'final' }; - const scopedOutputCache = new Map(); - const resolveScopedOutput = ( - sourceId: string, - sourceHandle: string, - visited = new Set() - ): ScopedOutput | null => { - const endpoint = `${sourceId}\0${sourceHandle}`; - const cached = scopedOutputCache.get(endpoint); - if (cached !== undefined) { - return cached; - } - - const directScopedOutput = scopedOutputByEndpoint.get(endpoint); - if (directScopedOutput) { - scopedOutputCache.set(endpoint, directScopedOutput); - return directScopedOutput; - } - - const sourceNode = nodeById.get(sourceId); - if ( - !sourceNode || - !isConnectorNode(sourceNode) || - sourceHandle !== CONNECTOR_OUTPUT_HANDLE || - visited.has(sourceId) - ) { - scopedOutputCache.set(endpoint, null); - return null; - } - - visited.add(sourceId); - const connectorInput = connectorInputById.get(sourceId); - if (connectorInput?.type !== 'default' || typeof connectorInput.sourceHandle !== 'string') { - scopedOutputCache.set(endpoint, null); - return null; - } - - const scopedOutput = resolveScopedOutput(connectorInput.source, connectorInput.sourceHandle, visited); - scopedOutputCache.set(endpoint, scopedOutput); - return scopedOutput; - }; - - const targetsByScopeByNode = new Map; finalTargets: Set }>(); - const targetsBySource = new Map>(); - for (const edge of edges) { - if (edge.type !== 'default' || typeof edge.sourceHandle !== 'string') { - continue; - } - - const targets = targetsBySource.get(edge.source) ?? new Set(); - targets.add(edge.target); - targetsBySource.set(edge.source, targets); - - const scopedOutput = resolveScopedOutput(edge.source, edge.sourceHandle); - if (!scopedOutput) { - continue; - } - const targetsByScope = targetsByScopeByNode.get(scopedOutput.nodeId) ?? { - iterationTargets: new Set(), - finalTargets: new Set(), - }; - if (scopedOutput.scope === 'iteration') { - targetsByScope.iterationTargets.add(edge.target); - } else { - targetsByScope.finalTargets.add(edge.target); - } - targetsByScopeByNode.set(scopedOutput.nodeId, targetsByScope); - } - - const conflicts = new Set(); - for (const [nodeId, { iterationTargets, finalTargets }] of targetsByScopeByNode) { - const reachableBodyNodes = new Set(iterationTargets); - const pendingBodyNodes = [...iterationTargets]; - while (pendingBodyNodes.length > 0) { - const currentNodeId = pendingBodyNodes.pop(); - if (!currentNodeId) { - continue; - } - - for (const targetNodeId of targetsBySource.get(currentNodeId) ?? []) { - if (reachableBodyNodes.has(targetNodeId)) { - continue; - } - reachableBodyNodes.add(targetNodeId); - pendingBodyNodes.push(targetNodeId); - } - } - - const reachableFinalNodes = new Set(finalTargets); - const pendingFinalNodes = [...finalTargets]; - while (pendingFinalNodes.length > 0) { - const currentNodeId = pendingFinalNodes.pop(); - if (!currentNodeId) { - continue; - } - - for (const targetNodeId of targetsBySource.get(currentNodeId) ?? []) { - if (reachableFinalNodes.has(targetNodeId)) { - continue; - } - reachableFinalNodes.add(targetNodeId); - pendingFinalNodes.push(targetNodeId); - } - } - - for (const targetId of reachableFinalNodes) { - if (reachableBodyNodes.has(targetId)) { - conflicts.add(`${nodeId}\0${targetId}`); - } - } - } - - return conflicts; -}; - -const hasOutputScopeConflict = (connection: Connection, nodes: AnyNode[], edges: AnyEdge[], templates: Templates) => { - const existingConflicts = getOutputScopeConflicts(nodes, edges, templates); - const candidateEdge: AnyEdge = { - ...connection, - id: '__candidate_connection__', - type: 'default', - }; - const stagedConflicts = getOutputScopeConflicts(nodes, [...edges, candidateEdge], templates); - return [...stagedConflicts].some((conflict) => !existingConflicts.has(conflict)); -}; - -const getResolvedLoopLinkages = (nodes: AnyNode[], edges: AnyEdge[]) => - edges.flatMap((edge) => { - if ( - edge.type !== 'default' || - edge.targetHandle !== LOOP_LINKAGE_FIELD || - !isConnectorNode(nodes.find((node) => node.id === edge.source)) - ) { - return []; - } - const path = resolveLoopLinkagePath(edge, nodes, edges); - return path ? [path] : []; - }); - /** * Validates a connection between two fields * @returns A translation key for an error if the connection is invalid, otherwise null @@ -387,204 +207,6 @@ export const validateConnection: ValidateConnectionFunc = ( ignoreEdge, strict = true ): string | null => { - const sourceNode = nodes.find((node) => node.id === c.source); - const targetNode = nodes.find((node) => node.id === c.target); - const filteredEdges = edges.filter((edge) => edge.id !== ignoreEdge?.id); - const resolvedConnectorSource = - sourceNode && isConnectorNode(sourceNode) && c.sourceHandle === CONNECTOR_OUTPUT_HANDLE - ? resolveConnectorSource(sourceNode.id, nodes, filteredEdges) - : null; - const hasConnectorLoopLinkage = - c.sourceHandle === CONNECTOR_OUTPUT_HANDLE && - c.targetHandle === CONNECTOR_INPUT_HANDLE && - isConnectorNode(sourceNode) && - isConnectorNode(targetNode) && - resolvedConnectorSource?.fieldName === LOOP_LINKAGE_FIELD; - const hasLoopLinkageHandle = - c.sourceHandle === LOOP_LINKAGE_FIELD || c.targetHandle === LOOP_LINKAGE_FIELD || hasConnectorLoopLinkage; - if (hasLoopLinkageHandle) { - if (!sourceNode || !targetNode) { - return 'nodes.missingNode'; - } - - if ( - c.sourceHandle === LOOP_LINKAGE_FIELD && - isInvocationNode(sourceNode) && - sourceNode.data.type === 'for' && - isConnectorNode(targetNode) && - c.targetHandle === CONNECTOR_INPUT_HANDLE - ) { - if ( - filteredEdges.some( - (edge) => - edge.type === 'default' && edge.target === targetNode.id && edge.targetHandle === CONNECTOR_INPUT_HANDLE - ) - ) { - return 'nodes.inputMayOnlyHaveOneConnection'; - } - if ( - filteredEdges.some( - (edge) => - edge.source === c.source && - ((edge.type === 'loop_linkage' && edge.sourceHandle === LOOP_LINKAGE_FIELD) || - (edge.type === 'default' && edge.sourceHandle === LOOP_LINKAGE_FIELD)) - ) - ) { - return 'nodes.forLoopLinkageDuplicate'; - } - - const candidateEdge = { ...c, id: '__candidate_loop_linkage__', type: 'default' } satisfies AnyEdge; - const stagedEdges = [...filteredEdges, candidateEdge]; - if (getHasCycles(c.source, c.target, nodes, stagedEdges)) { - return 'nodes.connectionWouldCreateCycle'; - } - const terminalTargetEdges = getConnectorTerminalTargetEdges(targetNode.id, nodes, stagedEdges); - for (const terminalTargetEdge of terminalTargetEdges) { - const terminalNode = nodes.find((node) => node.id === terminalTargetEdge.target); - if ( - !terminalNode || - !isInvocationNode(terminalNode) || - terminalNode.data.type !== 'for_return' || - terminalTargetEdge.targetHandle !== LOOP_LINKAGE_FIELD || - !resolveLoopLinkagePath(terminalTargetEdge, nodes, stagedEdges) - ) { - return 'nodes.forLoopLinkageInvalid'; - } - } - - const candidatePaths = getResolvedLoopLinkages(nodes, stagedEdges).filter((path) => - path.edgeIds.includes(candidateEdge.id) - ); - if (candidatePaths.length > 1) { - return 'nodes.forLoopLinkageInvalid'; - } - const candidatePath = candidatePaths[0]; - if ( - candidatePath && - (filteredEdges.some( - (edge) => - isLoopLinkageEdge(edge) && - (edge.source === candidatePath.forNodeId || edge.target === candidatePath.returnNodeId) - ) || - getResolvedLoopLinkages(nodes, filteredEdges).some( - (existingPath) => - existingPath.forNodeId === candidatePath.forNodeId || - existingPath.returnNodeId === candidatePath.returnNodeId - )) - ) { - return 'nodes.forLoopLinkageDuplicate'; - } - return null; - } - - if (hasConnectorLoopLinkage) { - if (filteredEdges.some(getTargetEqualityPredicate(c))) { - return 'nodes.inputMayOnlyHaveOneConnection'; - } - - const candidateEdge = { ...c, id: '__candidate_loop_linkage__', type: 'default' } satisfies AnyEdge; - const stagedEdges = [...filteredEdges, candidateEdge]; - if (getHasCycles(c.source, c.target, nodes, stagedEdges)) { - return 'nodes.connectionWouldCreateCycle'; - } - const terminalTargetEdges = getConnectorTerminalTargetEdges(targetNode.id, nodes, stagedEdges); - for (const terminalTargetEdge of terminalTargetEdges) { - const terminalNode = nodes.find((node) => node.id === terminalTargetEdge.target); - if ( - !terminalNode || - !isInvocationNode(terminalNode) || - terminalNode.data.type !== 'for_return' || - terminalTargetEdge.targetHandle !== LOOP_LINKAGE_FIELD || - !resolveLoopLinkagePath(terminalTargetEdge, nodes, stagedEdges) - ) { - return 'nodes.forLoopLinkageInvalid'; - } - } - - const candidatePaths = getResolvedLoopLinkages(nodes, stagedEdges).filter((path) => - path.edgeIds.includes(candidateEdge.id) - ); - if (candidatePaths.length > 1) { - return 'nodes.forLoopLinkageInvalid'; - } - - const candidatePath = candidatePaths[0]; - if ( - candidatePath && - (filteredEdges.some( - (edge) => - isLoopLinkageEdge(edge) && - (edge.source === candidatePath.forNodeId || edge.target === candidatePath.returnNodeId) - ) || - getResolvedLoopLinkages(nodes, filteredEdges).some( - (existingPath) => - existingPath.forNodeId === candidatePath.forNodeId || - existingPath.returnNodeId === candidatePath.returnNodeId - )) - ) { - return 'nodes.forLoopLinkageDuplicate'; - } - return null; - } - - if ( - isConnectorNode(sourceNode) && - sourceNode && - c.sourceHandle === CONNECTOR_OUTPUT_HANDLE && - isInvocationNode(targetNode) && - targetNode.data.type === 'for_return' && - c.targetHandle === LOOP_LINKAGE_FIELD - ) { - if ( - filteredEdges.some((edge) => isLoopLinkageEdge(edge) && edge.target === targetNode.id) || - getResolvedLoopLinkages(nodes, filteredEdges).some((path) => path.returnNodeId === targetNode.id) - ) { - return 'nodes.forLoopLinkageDuplicate'; - } - const resolvedSource = resolveConnectorSource(sourceNode.id, nodes, filteredEdges); - if (resolvedSource && resolvedSource.fieldName !== LOOP_LINKAGE_FIELD) { - return 'nodes.forLoopLinkageInvalid'; - } - - const candidateEdge = { ...c, id: '__candidate_loop_linkage__', type: 'default' } satisfies AnyEdge; - const stagedEdges = [...filteredEdges, candidateEdge]; - const path = resolveLoopLinkagePath(candidateEdge, nodes, stagedEdges); - if (!path) { - // A connector can be wired to ForReturn before its upstream source is attached. - return resolvedSource ? 'nodes.forLoopLinkageInvalid' : null; - } - - const hasDuplicateLinkage = filteredEdges.some( - (edge) => isLoopLinkageEdge(edge) && (edge.source === path.forNodeId || edge.target === path.returnNodeId) - ); - const hasDuplicateConnectorPath = getResolvedLoopLinkages(nodes, filteredEdges).some( - (existingPath) => existingPath.forNodeId === path.forNodeId || existingPath.returnNodeId === path.returnNodeId - ); - if (hasDuplicateLinkage || hasDuplicateConnectorPath) { - return 'nodes.forLoopLinkageDuplicate'; - } - return null; - } - - if ( - c.sourceHandle === LOOP_LINKAGE_FIELD && - c.targetHandle === LOOP_LINKAGE_FIELD && - isInvocationNode(sourceNode) && - sourceNode.data.type === 'for' && - isInvocationNode(targetNode) && - targetNode.data.type === 'for_return' - ) { - if ( - filteredEdges.some((edge) => isLoopLinkageEdge(edge) && (edge.source === c.source || edge.target === c.target)) - ) { - return 'nodes.forLoopLinkageDuplicate'; - } - return null; - } - - return 'nodes.forLoopLinkageInvalid'; - } - if (c.source === c.target) { return 'nodes.cannotConnectToSelf'; } @@ -713,14 +335,6 @@ export const validateConnection: ValidateConnectionFunc = ( const { node: resolvedSourceNode, handle: sourceHandle, fieldTemplate: sourceFieldTemplate } = effectiveSource; - if (sourceHandle === LOOP_LINKAGE_FIELD) { - return 'nodes.forLoopLinkageInvalid'; - } - - if (hasOutputScopeConflict(c, nodes, filteredEdges, templates)) { - return 'nodes.loopOutputScopeConflict'; - } - if (targetNode.data.type === 'collect' && c.targetHandle === 'item') { // Collect nodes shouldn't mix and match field types. const collectItemType = getCollectItemType(templates, nodes, filteredEdges, targetNode.id); diff --git a/invokeai/frontend/web/src/features/nodes/store/util/validateConnectionTypes.test.ts b/invokeai/frontend/web/src/features/nodes/store/util/validateConnectionTypes.test.ts index 2bb7ca05f28..fc9ce27cb94 100644 --- a/invokeai/frontend/web/src/features/nodes/store/util/validateConnectionTypes.test.ts +++ b/invokeai/frontend/web/src/features/nodes/store/util/validateConnectionTypes.test.ts @@ -109,24 +109,6 @@ describe(validateConnectionTypes.name, () => { }); }); - describe('LoopState', () => { - it('should accept LoopState connections', () => { - const r = validateConnectionTypes( - { name: 'LoopState', cardinality: 'SINGLE', batch: false }, - { name: 'LoopState', cardinality: 'SINGLE', batch: false } - ); - expect(r).toBe(true); - }); - - it('should reject LoopState connections to other field types', () => { - const r = validateConnectionTypes( - { name: 'LoopState', cardinality: 'SINGLE', batch: false }, - { name: 'IntegerField', cardinality: 'SINGLE', batch: false } - ); - expect(r).toBe(false); - }); - }); - describe('SINGLE_OR_COLLECTION', () => { it('should accept any SINGLE of same type to SINGLE_OR_COLLECTION', () => { const r = validateConnectionTypes( diff --git a/invokeai/frontend/web/src/features/nodes/types/constants.ts b/invokeai/frontend/web/src/features/nodes/types/constants.ts index ebda317dafd..7f8b8891f40 100644 --- a/invokeai/frontend/web/src/features/nodes/types/constants.ts +++ b/invokeai/frontend/web/src/features/nodes/types/constants.ts @@ -5,9 +5,6 @@ import type { AnyNode } from 'features/nodes/types/invocation'; */ export const HANDLE_TOOLTIP_OPEN_DELAY = 500; -/** The non-executable association between a For and its matching ForReturn. */ -export const LOOP_LINKAGE_FIELD = 'loop_linkage'; - /** * The width of a node in the UI in pixels. */ diff --git a/invokeai/frontend/web/src/features/nodes/types/field.ts b/invokeai/frontend/web/src/features/nodes/types/field.ts index ca81335f7d9..abcba64496d 100644 --- a/invokeai/frontend/web/src/features/nodes/types/field.ts +++ b/invokeai/frontend/web/src/features/nodes/types/field.ts @@ -52,7 +52,6 @@ import { // #region Base schemas & misc const zFieldInput = z.enum(['connection', 'direct', 'any']); -const zFieldOutputScope = z.enum(['iteration', 'final']); const zFieldUIComponent = z.enum(['none', 'textarea', 'slider', 'video-frame-index']); const zFieldInputInstanceBase = z.object({ name: z.string().trim().min(1), @@ -82,7 +81,6 @@ const zFieldInputTemplateBase = zFieldTemplateBase.extend({ }); const zFieldOutputTemplateBase = zFieldTemplateBase.extend({ fieldKind: z.literal('output'), - output_scope: zFieldOutputScope.nullish(), }); const SINGLE = 'SINGLE' as const; diff --git a/invokeai/frontend/web/src/features/nodes/types/invocation.ts b/invokeai/frontend/web/src/features/nodes/types/invocation.ts index 05d6b45ed75..4013e2518cd 100644 --- a/invokeai/frontend/web/src/features/nodes/types/invocation.ts +++ b/invokeai/frontend/web/src/features/nodes/types/invocation.ts @@ -161,14 +161,6 @@ const zDefaultInvocationNodeEdge = z.custom, 'default ); export type DefaultInvocationNodeEdge = z.infer; -const zLoopLinkageInvocationNodeEdgeValidationSchema = z.looseObject({ - type: z.literal('loop_linkage'), -}); -const zLoopLinkageInvocationNodeEdge = z.custom, 'loop_linkage'>>( - (val) => zLoopLinkageInvocationNodeEdgeValidationSchema.safeParse(val).success -); -export type LoopLinkageInvocationNodeEdge = z.infer; - const zInvocationNodeEdgeCollapsedData = z.object({ count: z.number().int().min(1), }); @@ -182,11 +174,7 @@ const zCollapsedInvocationNodeEdge = z.custom zInvocationNodeEdgeCollapsedValidationSchema.safeParse(val).success ); export type CollapsedInvocationNodeEdge = z.infer; -export const zAnyEdge = z.union([ - zDefaultInvocationNodeEdge, - zLoopLinkageInvocationNodeEdge, - zCollapsedInvocationNodeEdge, -]); +export const zAnyEdge = z.union([zDefaultInvocationNodeEdge, zCollapsedInvocationNodeEdge]); export type AnyEdge = z.infer; // #endregion diff --git a/invokeai/frontend/web/src/features/nodes/types/openapi.ts b/invokeai/frontend/web/src/features/nodes/types/openapi.ts index a77b13a0b6a..5945f44ac1d 100644 --- a/invokeai/frontend/web/src/features/nodes/types/openapi.ts +++ b/invokeai/frontend/web/src/features/nodes/types/openapi.ts @@ -39,9 +39,7 @@ type InvocationOutputSchemaObject = Omit }; }; -export type InvocationInputFieldSchema = OpenAPIV3_1.SchemaObject & InputFieldJSONSchemaExtra; -export type InvocationOutputFieldSchema = OpenAPIV3_1.SchemaObject & OutputFieldJSONSchemaExtra; -export type InvocationFieldSchema = InvocationInputFieldSchema | InvocationOutputFieldSchema; +export type InvocationFieldSchema = OpenAPIV3_1.SchemaObject & InputFieldJSONSchemaExtra; export type OpenAPIV3_1SchemaOrRef = OpenAPIV3_1.ReferenceObject | OpenAPIV3_1.SchemaObject; @@ -79,11 +77,6 @@ export const isInvocationOutputSchemaObject = ( obj: OpenAPIV3_1.ReferenceObject | OpenAPIV3_1.SchemaObject | InvocationOutputSchemaObject ): obj is InvocationOutputSchemaObject => 'class' in obj && obj.class === 'output'; -export const isInvocationInputFieldSchema = ( +export const isInvocationFieldSchema = ( obj: OpenAPIV3_1.ReferenceObject | OpenAPIV3_1.SchemaObject -): obj is InvocationInputFieldSchema => - 'field_kind' in obj && ['input', 'internal', 'node_attribute'].includes(String(obj.field_kind)); - -export const isInvocationOutputFieldSchema = ( - obj: OpenAPIV3_1.ReferenceObject | OpenAPIV3_1.SchemaObject -): obj is InvocationOutputFieldSchema => 'field_kind' in obj && obj.field_kind === 'output'; +): obj is InvocationFieldSchema => 'field_kind' in obj; diff --git a/invokeai/frontend/web/src/features/nodes/types/workflow.ts b/invokeai/frontend/web/src/features/nodes/types/workflow.ts index 0b47b19b044..bfb7b92b18f 100644 --- a/invokeai/frontend/web/src/features/nodes/types/workflow.ts +++ b/invokeai/frontend/web/src/features/nodes/types/workflow.ts @@ -57,15 +57,10 @@ const zWorkflowEdgeDefault = zWorkflowEdgeBase.extend({ targetHandle: z.string().trim().min(1), hidden: z.boolean().optional(), }); -const zWorkflowEdgeLoopLinkage = zWorkflowEdgeBase.extend({ - type: z.literal('loop_linkage'), - sourceHandle: z.string().trim().min(1), - targetHandle: z.string().trim().min(1), -}); const zWorkflowEdgeCollapsed = zWorkflowEdgeBase.extend({ type: z.literal('collapsed'), }); -const zWorkflowEdge = z.union([zWorkflowEdgeDefault, zWorkflowEdgeLoopLinkage, zWorkflowEdgeCollapsed]); +const zWorkflowEdge = z.union([zWorkflowEdgeDefault, zWorkflowEdgeCollapsed]); // #endregion // #region Workflow Builder diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.test.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.test.ts index 1c92f04b30d..ea772a16bff 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.test.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.test.ts @@ -1,17 +1,7 @@ import { deepClone } from 'common/util/deepClone'; import { callSavedWorkflowDynamicFieldsChanged, nodesSliceConfig } from 'features/nodes/store/nodesSlice'; import { CONNECTOR_INPUT_HANDLE, CONNECTOR_OUTPUT_HANDLE } from 'features/nodes/store/util/connectorTopology'; -import { - add, - buildEdge, - buildLoopLinkageEdge, - buildNode, - for_loop, - for_return, - img_resize, - sub, - templates, -} from 'features/nodes/store/util/testUtils'; +import { add, buildEdge, buildNode, img_resize, sub, templates } from 'features/nodes/store/util/testUtils'; import type { IntegerFieldInputTemplate } from 'features/nodes/types/field'; import { zInvocationNodeData } from 'features/nodes/types/invocation'; import { describe, expect, it } from 'vitest'; @@ -83,188 +73,6 @@ const buildState = (nodes: unknown[], edges: unknown[]) => }) as unknown as Parameters[0]; describe('buildNodesGraph', () => { - it('rejects an invalid For loop before queue submission', () => { - const forNode = buildNode(for_loop); - const bodyNode = buildNode(add); - const state = buildState([forNode, bodyNode], [buildEdge(forNode.id, 'item', bodyNode.id, 'a')]); - - expect(() => buildNodesGraph(state, { ...templates, for: for_loop })).toThrow('nodes.forLoopLinkageMissing'); - }); - - it('preserves the explicit loop linkage in a simple For graph', () => { - const forNode = buildNode(for_loop); - const returnNode = buildNode(for_return); - const state = buildState( - [forNode, returnNode], - [buildEdge(forNode.id, 'item', returnNode.id, 'output'), buildLoopLinkageEdge(forNode.id, returnNode.id)] - ); - - const graph = buildNodesGraph(state, { ...templates, for: for_loop, for_return }); - - expect(graph.edges).toEqual([ - expect.objectContaining({ - type: 'default', - source: { node_id: forNode.id, field: 'item' }, - destination: { node_id: returnNode.id, field: 'output' }, - }), - expect.objectContaining({ - type: 'loop_linkage', - source: { node_id: forNode.id, field: 'loop_linkage' }, - destination: { node_id: returnNode.id, field: 'loop_linkage' }, - }), - ]); - }); - - it('canonicalizes connector loop linkage into one direct execution edge', () => { - const forNode = buildNode(for_loop); - const connector = buildConnectorNode('connector-1'); - const returnNode = buildNode(for_return); - const state = buildState( - [forNode, connector, returnNode], - [ - buildEdge(forNode.id, 'item', returnNode.id, 'output'), - buildEdge(forNode.id, 'loop_linkage', connector.id, CONNECTOR_INPUT_HANDLE), - buildEdge(connector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), - ] - ); - - const graph = buildNodesGraph(state, { ...templates, for: for_loop, for_return }); - - expect(graph.edges).toEqual([ - expect.objectContaining({ - type: 'default', - source: { node_id: forNode.id, field: 'item' }, - destination: { node_id: returnNode.id, field: 'output' }, - }), - expect.objectContaining({ - type: 'loop_linkage', - source: { node_id: forNode.id, field: 'loop_linkage' }, - destination: { node_id: returnNode.id, field: 'loop_linkage' }, - }), - ]); - }); - - it('canonicalizes a chained connector loop linkage into one direct execution edge', () => { - const forNode = buildNode(for_loop); - const firstConnector = buildConnectorNode('connector-1'); - const secondConnector = buildConnectorNode('connector-2'); - const returnNode = buildNode(for_return); - const state = buildState( - [forNode, firstConnector, secondConnector, returnNode], - [ - buildEdge(forNode.id, 'item', returnNode.id, 'output'), - buildEdge(forNode.id, 'loop_linkage', firstConnector.id, CONNECTOR_INPUT_HANDLE), - buildEdge(firstConnector.id, CONNECTOR_OUTPUT_HANDLE, secondConnector.id, CONNECTOR_INPUT_HANDLE), - buildEdge(secondConnector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), - ] - ); - - const graph = buildNodesGraph(state, { ...templates, for: for_loop, for_return }); - - expect(graph.edges).toContainEqual( - expect.objectContaining({ - type: 'loop_linkage', - source: { node_id: forNode.id, field: 'loop_linkage' }, - destination: { node_id: returnNode.id, field: 'loop_linkage' }, - }) - ); - expect(graph.edges).toHaveLength(2); - }); - - it('rejects a connector loop linkage that branches to multiple ForReturns', () => { - const forNode = buildNode(for_loop); - const connector = buildConnectorNode('connector-1'); - const firstReturnNode = buildNode(for_return); - const secondReturnNode = buildNode(for_return); - const state = buildState( - [forNode, connector, firstReturnNode, secondReturnNode], - [ - buildEdge(forNode.id, 'loop_linkage', connector.id, CONNECTOR_INPUT_HANDLE), - buildEdge(connector.id, CONNECTOR_OUTPUT_HANDLE, firstReturnNode.id, 'loop_linkage'), - buildEdge(connector.id, CONNECTOR_OUTPUT_HANDLE, secondReturnNode.id, 'loop_linkage'), - ] - ); - - expect(() => buildNodesGraph(state, { ...templates, for: for_loop, for_return })).toThrow( - 'nodes.forLoopLinkageInvalid' - ); - }); - - it('rejects a loop linkage connector reused for ordinary data before a ForReturn is attached', () => { - const forNode = buildNode(for_loop); - const connector = buildConnectorNode('connector-1'); - const targetNode = buildNode(sub); - const state = buildState( - [forNode, connector, targetNode], - [ - buildEdge(forNode.id, 'loop_linkage', connector.id, CONNECTOR_INPUT_HANDLE), - buildEdge(connector.id, CONNECTOR_OUTPUT_HANDLE, targetNode.id, 'a'), - ] - ); - - expect(() => buildNodesGraph(state, { ...templates, for: for_loop })).toThrow('nodes.forLoopLinkageInvalid'); - }); - - it('rejects connector loop linkages that share a ForReturn', () => { - const forNode = buildNode(for_loop); - const firstConnector = buildConnectorNode('connector-1'); - const secondConnector = buildConnectorNode('connector-2'); - const returnNode = buildNode(for_return); - const state = buildState( - [forNode, firstConnector, secondConnector, returnNode], - [ - buildEdge(forNode.id, 'loop_linkage', firstConnector.id, CONNECTOR_INPUT_HANDLE), - buildEdge(firstConnector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), - buildEdge(forNode.id, 'loop_linkage', secondConnector.id, CONNECTOR_INPUT_HANDLE), - buildEdge(secondConnector.id, CONNECTOR_OUTPUT_HANDLE, returnNode.id, 'loop_linkage'), - ] - ); - - expect(() => buildNodesGraph(state, { ...templates, for: for_loop, for_return })).toThrow( - 'nodes.forLoopLinkageDuplicate' - ); - }); - - it('normalizes loop linkage handles when an edge is missing its linkage type', () => { - const forNode = buildNode(for_loop); - const returnNode = buildNode(for_return); - const state = buildState( - [forNode, returnNode], - [ - buildEdge(forNode.id, 'item', returnNode.id, 'output'), - buildEdge(forNode.id, 'loop_linkage', returnNode.id, 'loop_linkage'), - ] - ); - - const graph = buildNodesGraph(state, { ...templates, for: for_loop, for_return }); - - expect(graph.edges).toEqual([ - expect.objectContaining({ - type: 'default', - source: { node_id: forNode.id, field: 'item' }, - destination: { node_id: returnNode.id, field: 'output' }, - }), - expect.objectContaining({ - type: 'loop_linkage', - source: { node_id: forNode.id, field: 'loop_linkage' }, - destination: { node_id: returnNode.id, field: 'loop_linkage' }, - }), - ]); - }); - - it('continues to omit collapsed edges while normalizing linkage edges', () => { - const sourceNode = buildNode(add); - const targetNode = buildNode(add); - const state = buildState( - [sourceNode, targetNode], - [{ ...buildEdge(sourceNode.id, 'value', targetNode.id, 'a'), type: 'collapsed', data: { count: 1 } }] - ); - - const graph = buildNodesGraph(state, templates); - - expect(graph.edges).toEqual([]); - }); - it('serializes dynamic saved workflow inputs into workflow_inputs', () => { const state = nodesSliceConfig.getInitialState(); const node = buildNode(callSavedWorkflowTemplate); @@ -353,7 +161,6 @@ describe('buildNodesGraph', () => { workflow_inputs: {}, }); expect(graph.edges).toContainEqual({ - type: 'default', source: { node_id: sourceNode.id, field: 'value' }, destination: { node_id: callNode.id, field: 'saved_workflow_input::node-1::a' }, }); @@ -416,7 +223,6 @@ describe('buildNodesGraph', () => { expect(graph.nodes).not.toHaveProperty(connector.id); expect(graph.edges).toEqual([ { - type: 'default', source: { node_id: source.id, field: 'value' }, destination: { node_id: target.id, field: 'a' }, }, @@ -441,7 +247,6 @@ describe('buildNodesGraph', () => { expect(graph.edges).toEqual([ { - type: 'default', source: { node_id: source.id, field: 'value' }, destination: { node_id: target.id, field: 'a' }, }, @@ -466,12 +271,10 @@ describe('buildNodesGraph', () => { expect(graph.edges).toEqual([ { - type: 'default', source: { node_id: source.id, field: 'value' }, destination: { node_id: targetA.id, field: 'a' }, }, { - type: 'default', source: { node_id: source.id, field: 'value' }, destination: { node_id: targetB.id, field: 'width' }, }, @@ -506,7 +309,6 @@ describe('buildNodesGraph', () => { expect(graph.edges).toEqual([ { - type: 'default', source: { node_id: source.id, field: 'value' }, destination: { node_id: target.id, field: 'a' }, }, @@ -535,7 +337,6 @@ describe('buildNodesGraph', () => { expect(graph.edges).toEqual([ { - type: 'default', source: { node_id: source.id, field: 'value' }, destination: { node_id: target.id, field: 'a' }, }, diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.ts index dde7ad80993..47e4d779c2d 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.ts @@ -4,15 +4,8 @@ import { omit, reduce } from 'es-toolkit/compat'; import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors'; import { selectNodesSlice } from 'features/nodes/store/selectors'; import type { Templates } from 'features/nodes/store/types'; -import { - CONNECTOR_INPUT_HANDLE, - CONNECTOR_OUTPUT_HANDLE, - resolveConnectorSource, - resolveLoopLinkagePath, -} from 'features/nodes/store/util/connectorTopology'; -import { isLoopLinkageEdge } from 'features/nodes/store/util/reactFlowUtil'; +import { resolveConnectorSource } from 'features/nodes/store/util/connectorTopology'; import type { BoardField } from 'features/nodes/types/common'; -import { LOOP_LINKAGE_FIELD } from 'features/nodes/types/constants'; import { nodeAcceptsExtraInputs } from 'features/nodes/types/extraInputs'; import type { BoardFieldInputInstance } from 'features/nodes/types/field'; import { isBoardFieldInputInstance, isBoardFieldInputTemplate } from 'features/nodes/types/field'; @@ -22,8 +15,6 @@ import { isExecutableNode, isInvocationNode, } from 'features/nodes/types/invocation'; -import { validateForLoopGraph } from 'features/nodes/util/graph/validateForLoopGraph'; -import { t } from 'i18next'; import type { AnyInvocation, Graph } from 'services/api/types'; import { v4 as uuidv4 } from 'uuid'; @@ -97,7 +88,6 @@ export const buildNodesGraph = (state: RootState, templates: Templates): Require } return inputsAccumulator; } - if (isBoardFieldInputTemplate(fieldTemplate) && isBoardFieldInputInstance(input)) { inputsAccumulator[name] = getBoardField(input, state); } else { @@ -134,57 +124,9 @@ export const buildNodesGraph = (state: RootState, templates: Templates): Require const filteredNodeIds = filteredNodes.map(({ id }) => id); - for (const edge of edges) { - if ( - edge.type !== 'default' || - edge.sourceHandle !== CONNECTOR_OUTPUT_HANDLE || - !isConnectorNode(nodes.find((node) => node.id === edge.source)) - ) { - continue; - } - - const resolvedSource = resolveConnectorSource(edge.source, nodes, edges); - if (!resolvedSource || resolvedSource.fieldName !== LOOP_LINKAGE_FIELD) { - continue; - } - - const targetNode = nodes.find((node) => node.id === edge.target); - if (isConnectorNode(targetNode) && edge.targetHandle === CONNECTOR_INPUT_HANDLE) { - continue; - } - if ( - isInvocationNode(targetNode) && - targetNode.data.type === 'for_return' && - edge.targetHandle === LOOP_LINKAGE_FIELD - ) { - continue; - } - throw new Error(t('nodes.forLoopLinkageInvalid') || 'nodes.forLoopLinkageInvalid'); - } - - const connectorLoopLinkagePaths = edges.flatMap((edge) => { - if ( - edge.type !== 'default' || - edge.sourceHandle !== CONNECTOR_OUTPUT_HANDLE || - edge.targetHandle !== LOOP_LINKAGE_FIELD || - !isConnectorNode(nodes.find((node) => node.id === edge.source)) - ) { - return []; - } - - const path = resolveLoopLinkagePath(edge, nodes, edges); - if (!path) { - throw new Error(t('nodes.forLoopLinkageInvalid') || 'nodes.forLoopLinkageInvalid'); - } - return [path]; - }); - const connectorLoopLinkageEdgeIds = new Set(connectorLoopLinkagePaths.flatMap((path) => path.edgeIds)); - // skip out the "dummy" edges between collapsed nodes const flattenedEdges = edges - .filter( - (edge) => edge.type !== 'collapsed' && !isLoopLinkageEdge(edge) && !connectorLoopLinkageEdgeIds.has(edge.id) - ) + .filter((edge) => edge.type === 'default') .flatMap((edge) => { const targetNode = nodes.find((node) => node.id === edge.target); if (!targetNode || !isInvocationNode(targetNode) || !isExecutableNode(targetNode)) { @@ -232,21 +174,6 @@ export const buildNodesGraph = (state: RootState, templates: Templates): Require ); }); - const loopLinkageEdges = edges.filter(isLoopLinkageEdge).filter((edge) => { - const sourceNode = nodes.find((node) => node.id === edge.source); - const targetNode = nodes.find((node) => node.id === edge.target); - return Boolean( - sourceNode && - targetNode && - isInvocationNode(sourceNode) && - isInvocationNode(targetNode) && - isExecutableNode(sourceNode) && - isExecutableNode(targetNode) && - filteredNodeIds.includes(sourceNode.id) && - filteredNodeIds.includes(targetNode.id) - ); - }); - // Reduce the node editor edges into invocation graph edges const parsedEdges = flattenedEdges.reduce>((edgesAccumulator, edge) => { const { source, target, sourceHandle, targetHandle } = edge; @@ -258,7 +185,6 @@ export const buildNodesGraph = (state: RootState, templates: Templates): Require // Format the edges and add to the edges array edgesAccumulator.push({ - type: 'default', source: { node_id: source, field: sourceHandle, @@ -272,47 +198,6 @@ export const buildNodesGraph = (state: RootState, templates: Templates): Require return edgesAccumulator; }, []); - loopLinkageEdges.forEach((edge) => { - if (!edge.sourceHandle || !edge.targetHandle) { - log.warn( - { - edgeId: edge.id, - source: edge.source, - sourceHandle: edge.sourceHandle, - target: edge.target, - targetHandle: edge.targetHandle, - }, - 'Missing source or target handle for loop linkage edge' - ); - return; - } - parsedEdges.push({ - type: 'loop_linkage', - source: { - node_id: edge.source, - field: edge.sourceHandle, - }, - destination: { - node_id: edge.target, - field: edge.targetHandle, - }, - }); - }); - - connectorLoopLinkagePaths.forEach(({ forNodeId, returnNodeId }) => { - parsedEdges.push({ - type: 'loop_linkage', - source: { - node_id: forNodeId, - field: LOOP_LINKAGE_FIELD, - }, - destination: { - node_id: returnNodeId, - field: LOOP_LINKAGE_FIELD, - }, - }); - }); - /** * Omit all inputs that have edges connected. * @@ -323,9 +208,6 @@ export const buildNodesGraph = (state: RootState, templates: Templates): Require * even though the actual value that will be used comes from the connection. */ parsedEdges.forEach((edge) => { - if (edge.type !== 'default') { - return; - } const destination_node = parsedNodes[edge.destination.node_id]; if (!destination_node) { return; @@ -351,10 +233,5 @@ export const buildNodesGraph = (state: RootState, templates: Templates): Require edges: parsedEdges, }; - const forLoopError = validateForLoopGraph(graph); - if (forLoopError !== null) { - throw new Error(t(forLoopError) || forLoopError); - } - return graph; }; diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/Graph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/Graph.ts index c912d0800cc..db94b946e8f 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/Graph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/Graph.ts @@ -125,7 +125,7 @@ export class Graph { }); } - Object.assign(node, changes as object); + Object.assign(node, changes); return node; } diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/loopBodyBoundary.test.ts b/invokeai/frontend/web/src/features/nodes/util/graph/loopBodyBoundary.test.ts deleted file mode 100644 index 1ba01ff1bf3..00000000000 --- a/invokeai/frontend/web/src/features/nodes/util/graph/loopBodyBoundary.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { CONNECTOR_INPUT_HANDLE, CONNECTOR_OUTPUT_HANDLE } from 'features/nodes/store/util/connectorTopology'; -import { - add, - buildEdge, - buildLoopLinkageEdge, - buildNode, - for_loop, - for_return, -} from 'features/nodes/store/util/testUtils'; -import type { AnyEdge, AnyNode } from 'features/nodes/types/invocation'; -import { describe, expect, it } from 'vitest'; - -import { getForLoopBodyBoundaries } from './loopBodyBoundary'; - -const setNodeId = (node: AnyNode, id: string): AnyNode => { - node.id = id; - node.data.id = id; - return node; -}; - -const edge = (source: string, sourceHandle: string, target: string, targetHandle: string): AnyEdge => - buildEdge(source, sourceHandle, target, targetHandle); - -const connector = (id: string): AnyNode => ({ - id, - type: 'connector', - position: { x: 0, y: 0 }, - data: { id, type: 'connector', label: 'Connector', isOpen: true }, -}); - -describe(getForLoopBodyBoundaries.name, () => { - it('resolves a body using its explicit loop linkage', () => { - const forNode = setNodeId(buildNode(for_loop), 'for'); - const bodyNode = setNodeId(buildNode(add), 'body'); - const returnNode = setNodeId(buildNode(for_return), 'return'); - - const boundaries = getForLoopBodyBoundaries( - [forNode, bodyNode, returnNode], - [ - edge('for', 'item', 'body', 'a'), - edge('body', 'value', 'return', 'output'), - buildLoopLinkageEdge('for', 'return'), - ] - ); - - expect(boundaries).toEqual([ - expect.objectContaining({ - forNodeId: 'for', - returnNodeId: 'return', - bodyNodeIds: ['for', 'body', 'return'], - status: 'complete', - }), - ]); - }); - - it('resolves a body using a connector loop linkage alias', () => { - const forNode = setNodeId(buildNode(for_loop), 'for'); - const bodyNode = setNodeId(buildNode(add), 'body'); - const connectorNode = connector('connector'); - const returnNode = setNodeId(buildNode(for_return), 'return'); - - const boundaries = getForLoopBodyBoundaries( - [forNode, bodyNode, connectorNode, returnNode], - [ - edge('for', 'item', 'body', 'a'), - edge('body', 'value', 'return', 'output'), - edge('for', 'loop_linkage', 'connector', CONNECTOR_INPUT_HANDLE), - edge('connector', CONNECTOR_OUTPUT_HANDLE, 'return', 'loop_linkage'), - ] - ); - - expect(boundaries).toEqual([ - expect.objectContaining({ - forNodeId: 'for', - returnNodeId: 'return', - bodyNodeIds: ['for', 'body', 'connector', 'return'], - status: 'complete', - }), - ]); - }); - - it('includes every connector in a loop linkage alias chain', () => { - const forNode = setNodeId(buildNode(for_loop), 'for'); - const firstConnector = connector('connector-a'); - const secondConnector = connector('connector-b'); - const returnNode = setNodeId(buildNode(for_return), 'return'); - - const boundaries = getForLoopBodyBoundaries( - [forNode, firstConnector, secondConnector, returnNode], - [ - edge('for', 'loop_linkage', 'connector-a', CONNECTOR_INPUT_HANDLE), - edge('connector-a', CONNECTOR_OUTPUT_HANDLE, 'connector-b', CONNECTOR_INPUT_HANDLE), - edge('connector-b', CONNECTOR_OUTPUT_HANDLE, 'return', 'loop_linkage'), - ] - ); - - expect(boundaries[0]?.bodyNodeIds).toEqual(['for', 'connector-a', 'connector-b', 'return']); - }); - - it('reports missing loop linkage even when the data path is complete', () => { - const forNode = setNodeId(buildNode(for_loop), 'for'); - const bodyNode = setNodeId(buildNode(add), 'body'); - const returnNode = setNodeId(buildNode(for_return), 'return'); - - const boundaries = getForLoopBodyBoundaries( - [forNode, bodyNode, returnNode], - [edge('for', 'item', 'body', 'a'), edge('body', 'value', 'return', 'output')] - ); - - expect(boundaries[0]).toEqual( - expect.objectContaining({ forNodeId: 'for', returnNodeId: 'return', status: 'missing_linkage' }) - ); - }); - - it('reports a linkage whose return is detached from the body', () => { - const forNode = setNodeId(buildNode(for_loop), 'for'); - const bodyNode = setNodeId(buildNode(add), 'body'); - const returnNode = setNodeId(buildNode(for_return), 'return'); - - const boundaries = getForLoopBodyBoundaries( - [forNode, bodyNode, returnNode], - [edge('for', 'item', 'body', 'a'), buildLoopLinkageEdge('for', 'return')] - ); - - expect(boundaries[0]).toEqual( - expect.objectContaining({ forNodeId: 'for', returnNodeId: 'return', status: 'invalid_linkage' }) - ); - expect(boundaries).toHaveLength(1); - }); - - it('reports duplicate linkage edges', () => { - const forNode = setNodeId(buildNode(for_loop), 'for'); - const returnNode = setNodeId(buildNode(for_return), 'return'); - - const boundaries = getForLoopBodyBoundaries( - [forNode, returnNode], - [ - edge('for', 'item', 'return', 'output'), - buildLoopLinkageEdge('for', 'return'), - { ...buildLoopLinkageEdge('for', 'return'), id: 'duplicate-linkage' }, - ] - ); - - expect(boundaries[0]).toEqual(expect.objectContaining({ status: 'duplicate_linkage' })); - }); - - it('reports duplicate linkage ownership from multiple For nodes', () => { - const firstForNode = setNodeId(buildNode(for_loop), 'first-for'); - const secondForNode = setNodeId(buildNode(for_loop), 'second-for'); - const returnNode = setNodeId(buildNode(for_return), 'return'); - - const boundaries = getForLoopBodyBoundaries( - [firstForNode, secondForNode, returnNode], - [ - edge('first-for', 'item', 'return', 'output'), - edge('second-for', 'item', 'return', 'output'), - buildLoopLinkageEdge('first-for', 'return'), - buildLoopLinkageEdge('second-for', 'return'), - ] - ); - - expect(boundaries).toEqual([ - expect.objectContaining({ forNodeId: 'first-for', status: 'duplicate_linkage' }), - expect.objectContaining({ forNodeId: 'second-for', status: 'duplicate_linkage' }), - ]); - }); - - it('does not include final-scoped For outputs in the body boundary', () => { - const forNode = setNodeId(buildNode(for_loop), 'for'); - const bodyNode = setNodeId(buildNode(add), 'body'); - const returnNode = setNodeId(buildNode(for_return), 'return'); - const afterNode = setNodeId(buildNode(add), 'after'); - - const boundaries = getForLoopBodyBoundaries( - [forNode, bodyNode, returnNode, afterNode], - [ - edge('for', 'item', 'body', 'a'), - edge('body', 'value', 'return', 'output'), - edge('for', 'output_collection', 'after', 'a'), - buildLoopLinkageEdge('for', 'return'), - ] - ); - - expect(boundaries[0]?.bodyNodeIds).toEqual(['for', 'body', 'return']); - }); - - it('ignores loop linkage when finding executable paths', () => { - const forNode = setNodeId(buildNode(for_loop), 'for'); - const bodyNode = setNodeId(buildNode(add), 'body'); - const returnNode = setNodeId(buildNode(for_return), 'return'); - - const boundaries = getForLoopBodyBoundaries( - [forNode, bodyNode, returnNode], - [buildLoopLinkageEdge('for', 'return')] - ); - - expect(boundaries[0]).toEqual(expect.objectContaining({ status: 'invalid_linkage' })); - }); - - it('reports an unlinked ForReturn as an orphan boundary', () => { - const returnNode = setNodeId(buildNode(for_return), 'return'); - - expect(getForLoopBodyBoundaries([returnNode], [])).toEqual([ - expect.objectContaining({ - forNodeId: undefined, - returnNodeId: 'return', - bodyNodeIds: ['return'], - status: 'orphan_return', - }), - ]); - }); -}); diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/loopBodyBoundary.ts b/invokeai/frontend/web/src/features/nodes/util/graph/loopBodyBoundary.ts deleted file mode 100644 index 660a59b8ba6..00000000000 --- a/invokeai/frontend/web/src/features/nodes/util/graph/loopBodyBoundary.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { CONNECTOR_OUTPUT_HANDLE, resolveLoopLinkagePath } from 'features/nodes/store/util/connectorTopology'; -import { isLoopLinkageEdge } from 'features/nodes/store/util/reactFlowUtil'; -import { LOOP_LINKAGE_FIELD } from 'features/nodes/types/constants'; -import type { AnyEdge, AnyNode } from 'features/nodes/types/invocation'; -import { isConnectorNode, isInvocationNode } from 'features/nodes/types/invocation'; - -const ITERATION_OUTPUT_FIELDS = new Set(['item', 'index', 'total', 'state']); - -export type LoopBodyBoundaryStatus = - | 'complete' - | 'missing_linkage' - | 'invalid_linkage' - | 'duplicate_linkage' - | 'missing_return' - | 'multiple_returns' - | 'orphan_return'; - -type LoopBodyBoundary = { - forNodeId?: string; - returnNodeId?: string; - bodyNodeIds: string[]; - status: LoopBodyBoundaryStatus; -}; - -const getReachableNodeIds = (startIds: string[], outgoing: Map): Set => { - const visited = new Set(); - const pending = [...startIds]; - while (pending.length > 0) { - const nodeId = pending.pop(); - if (nodeId === undefined || visited.has(nodeId)) { - continue; - } - visited.add(nodeId); - pending.push(...(outgoing.get(nodeId) ?? [])); - } - return visited; -}; - -const getBoundaryNodeIds = ( - nodes: AnyNode[], - reachableNodeIds: Set, - returnNodeId: string | undefined, - incoming: Map, - additionalNodeIds: Set = new Set() -): string[] => { - const bodyNodeIds = returnNodeId - ? new Set([...getReachableNodeIds([returnNodeId], incoming)].filter((nodeId) => reachableNodeIds.has(nodeId))) - : new Set(reachableNodeIds); - if (returnNodeId) { - bodyNodeIds.add(returnNodeId); - } - additionalNodeIds.forEach((nodeId) => bodyNodeIds.add(nodeId)); - return nodes.filter((node) => bodyNodeIds.has(node.id)).map((node) => node.id); -}; - -export const getForLoopBodyBoundaries = (nodes: AnyNode[], edges: AnyEdge[]): LoopBodyBoundary[] => { - const nodesById = new Map(nodes.map((node) => [node.id, node])); - const resolvedConnectorLinkages = edges.flatMap((edge) => { - if ( - edge.type !== 'default' || - edge.sourceHandle !== CONNECTOR_OUTPUT_HANDLE || - edge.targetHandle !== LOOP_LINKAGE_FIELD || - !isConnectorNode(nodes.find((node) => node.id === edge.source)) - ) { - return []; - } - const path = resolveLoopLinkagePath(edge, nodes, edges); - return path ? [path] : []; - }); - const resolvedConnectorLinkageEdgeIds = new Set(resolvedConnectorLinkages.flatMap((path) => path.edgeIds)); - const resolvedConnectorNodeIdsByForId = new Map(); - for (const path of resolvedConnectorLinkages) { - resolvedConnectorNodeIdsByForId.set(path.forNodeId, [ - ...(resolvedConnectorNodeIdsByForId.get(path.forNodeId) ?? []), - ...path.connectorNodeIds, - ]); - } - const executableEdges = edges.filter( - (edge) => !isLoopLinkageEdge(edge) && !resolvedConnectorLinkageEdgeIds.has(edge.id) - ); - const linkageEdges = [ - ...edges.filter(isLoopLinkageEdge), - ...resolvedConnectorLinkages.map( - ({ forNodeId, returnNodeId }) => - ({ - id: `resolved-loop-linkage-${forNodeId}-${returnNodeId}`, - type: 'loop_linkage' as const, - source: forNodeId, - sourceHandle: LOOP_LINKAGE_FIELD, - target: returnNodeId, - targetHandle: LOOP_LINKAGE_FIELD, - }) satisfies AnyEdge - ), - ]; - const outgoing = new Map(); - const incoming = new Map(); - - for (const edge of executableEdges) { - if (!nodesById.has(edge.source) || !nodesById.has(edge.target)) { - continue; - } - outgoing.set(edge.source, [...(outgoing.get(edge.source) ?? []), edge.target]); - incoming.set(edge.target, [...(incoming.get(edge.target) ?? []), edge.source]); - } - - const linkedReturnByForId = new Map(); - const linkedForByReturnId = new Map(); - const duplicateForIds = new Set(); - const duplicateReturnIds = new Set(); - const invalidForIds = new Set(); - const invalidReturnIds = new Set(); - - for (const edge of linkageEdges) { - const sourceNode = nodesById.get(edge.source); - const targetNode = nodesById.get(edge.target); - if ( - edge.sourceHandle !== LOOP_LINKAGE_FIELD || - edge.targetHandle !== LOOP_LINKAGE_FIELD || - !isInvocationNode(sourceNode) || - sourceNode.data.type !== 'for' || - !isInvocationNode(targetNode) || - targetNode.data.type !== 'for_return' - ) { - if (sourceNode?.type === 'invocation' && sourceNode.data.type === 'for') { - invalidForIds.add(sourceNode.id); - } - if (targetNode?.type === 'invocation' && targetNode.data.type === 'for_return') { - invalidReturnIds.add(targetNode.id); - } - continue; - } - if (linkedReturnByForId.has(sourceNode.id)) { - duplicateForIds.add(sourceNode.id); - } else { - linkedReturnByForId.set(sourceNode.id, targetNode.id); - } - if (linkedForByReturnId.has(targetNode.id)) { - duplicateReturnIds.add(targetNode.id); - } else { - linkedForByReturnId.set(targetNode.id, sourceNode.id); - } - } - - const reachableReturnIds = new Set(); - const forBoundaries = nodes - .filter((node) => isInvocationNode(node) && node.data.type === 'for') - .map((forNode) => { - const iterationTargets = executableEdges - .filter( - (edge) => - edge.source === forNode.id && - typeof edge.sourceHandle === 'string' && - ITERATION_OUTPUT_FIELDS.has(edge.sourceHandle) - ) - .map((edge) => edge.target); - const reachableNodeIds = getReachableNodeIds(iterationTargets, outgoing); - const reachableReturnNodes = nodes.filter( - (node) => reachableNodeIds.has(node.id) && isInvocationNode(node) && node.data.type === 'for_return' - ); - reachableReturnNodes.forEach((node) => reachableReturnIds.add(node.id)); - - const linkedReturnId = linkedReturnByForId.get(forNode.id); - const returnNodeId = - linkedReturnId ?? (reachableReturnNodes.length === 1 ? reachableReturnNodes[0]?.id : undefined); - let status: LoopBodyBoundaryStatus; - if (duplicateForIds.has(forNode.id) || (linkedReturnId !== undefined && duplicateReturnIds.has(linkedReturnId))) { - status = 'duplicate_linkage'; - } else if (invalidForIds.has(forNode.id)) { - status = 'invalid_linkage'; - } else if (linkedReturnId === undefined) { - status = 'missing_linkage'; - } else if (!reachableNodeIds.has(linkedReturnId)) { - status = 'invalid_linkage'; - } else if (reachableReturnNodes.length === 0) { - status = 'missing_return'; - } else if (reachableReturnNodes.length > 1) { - status = 'multiple_returns'; - } else { - status = 'complete'; - } - - return { - forNodeId: forNode.id, - ...(returnNodeId ? { returnNodeId } : {}), - bodyNodeIds: [ - forNode.id, - ...getBoundaryNodeIds( - nodes, - reachableNodeIds, - returnNodeId, - incoming, - new Set(resolvedConnectorNodeIdsByForId.get(forNode.id)) - ).filter((id) => id !== forNode.id), - ], - status, - }; - }); - - const orphanReturnBoundaries = nodes - .filter( - (node) => - isInvocationNode(node) && - node.data.type === 'for_return' && - !linkedForByReturnId.has(node.id) && - (!reachableReturnIds.has(node.id) || invalidReturnIds.has(node.id)) - ) - .map((returnNode) => ({ - forNodeId: linkedForByReturnId.get(returnNode.id), - returnNodeId: returnNode.id, - bodyNodeIds: [returnNode.id], - status: duplicateReturnIds.has(returnNode.id) - ? ('duplicate_linkage' as const) - : invalidReturnIds.has(returnNode.id) - ? ('invalid_linkage' as const) - : ('orphan_return' as const), - })); - - return [...forBoundaries, ...orphanReturnBoundaries]; -}; diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/validateForLoopGraph.test.ts b/invokeai/frontend/web/src/features/nodes/util/graph/validateForLoopGraph.test.ts deleted file mode 100644 index a42437da1dd..00000000000 --- a/invokeai/frontend/web/src/features/nodes/util/graph/validateForLoopGraph.test.ts +++ /dev/null @@ -1,604 +0,0 @@ -import type { Graph } from 'services/api/types'; -import { describe, expect, it } from 'vitest'; - -import { validateForLoopGraph } from './validateForLoopGraph'; - -type TestNode = { id: string; type: string }; -type TestEdge = { - type?: 'default' | 'loop_linkage'; - source: { node_id: string; field: string }; - destination: { node_id: string; field: string }; -}; - -const buildGraph = (nodes: TestNode[], edges: TestEdge[]): Graph => - ({ - id: 'graph', - nodes: Object.fromEntries(nodes.map((node) => [node.id, node])), - edges, - }) as unknown as Graph; - -const edge = (source: string, sourceField: string, destination: string, destinationField: string): TestEdge => ({ - type: 'default', - source: { node_id: source, field: sourceField }, - destination: { node_id: destination, field: destinationField }, -}); - -const linkage = (source: string, destination: string): TestEdge => ({ - type: 'loop_linkage', - source: { node_id: source, field: 'loop_linkage' }, - destination: { node_id: destination, field: 'loop_linkage' }, -}); - -describe(validateForLoopGraph.name, () => { - it('accepts a valid For body', () => { - const graph = buildGraph( - [ - { id: 'for', type: 'for' }, - { id: 'body', type: 'add' }, - { id: 'return', type: 'for_return' }, - { id: 'after', type: 'collect' }, - ], - [ - edge('for', 'item', 'body', 'a'), - edge('body', 'value', 'return', 'output'), - edge('for', 'output_collection', 'after', 'item'), - linkage('for', 'return'), - ] - ); - - expect(validateForLoopGraph(graph)).toBeNull(); - }); - - it('requires an explicit loop linkage', () => { - const graph = buildGraph( - [ - { id: 'for', type: 'for' }, - { id: 'body', type: 'add' }, - { id: 'return', type: 'for_return' }, - ], - [edge('for', 'item', 'body', 'a'), edge('body', 'value', 'return', 'output')] - ); - - expect(validateForLoopGraph(graph)).toBe('nodes.forLoopLinkageMissing'); - }); - - it('rejects a linkage with the wrong endpoint fields', () => { - const graph = buildGraph( - [ - { id: 'source', type: 'add' }, - { id: 'for', type: 'for' }, - { id: 'body', type: 'add' }, - { id: 'return', type: 'for_return' }, - ], - [ - edge('for', 'item', 'body', 'a'), - edge('body', 'value', 'return', 'output'), - { - type: 'loop_linkage', - source: { node_id: 'for', field: 'item' }, - destination: { node_id: 'return', field: 'loop_linkage' }, - }, - ] - ); - - expect(validateForLoopGraph(graph)).toBe('nodes.forLoopLinkageInvalid'); - }); - - it('rejects an internal Iterate predicate branch without scalar aggregation', () => { - const graph = buildGraph( - [ - { id: 'for', type: 'for' }, - { id: 'iterate', type: 'iterate' }, - { id: 'body', type: 'add' }, - { id: 'condition', type: 'add' }, - { id: 'collect', type: 'collect' }, - { id: 'return', type: 'for_return' }, - ], - [ - edge('for', 'item', 'iterate', 'collection'), - edge('iterate', 'item', 'body', 'a'), - edge('iterate', 'item', 'condition', 'value'), - edge('body', 'value', 'collect', 'item'), - edge('collect', 'collection', 'return', 'output'), - edge('condition', 'value', 'return', 'continue_condition'), - linkage('for', 'return'), - ] - ); - - expect(validateForLoopGraph(graph)).toBe('nodes.forLoopIterateUnsupported'); - }); - - it('accepts one nested For whose final collection closes the outer body', () => { - const graph = buildGraph( - [ - { id: 'outer', type: 'for' }, - { id: 'inner-collection', type: 'add' }, - { id: 'inner', type: 'for' }, - { id: 'inner-body', type: 'add' }, - { id: 'inner-condition', type: 'add' }, - { id: 'inner-return', type: 'for_return' }, - { id: 'outer-return', type: 'for_return' }, - ], - [ - edge('outer', 'item', 'inner-collection', 'value'), - edge('inner-collection', 'value', 'inner', 'collection'), - edge('inner', 'item', 'inner-body', 'value'), - edge('inner', 'item', 'inner-condition', 'value'), - edge('inner-body', 'value', 'inner-return', 'output'), - edge('inner-condition', 'value', 'inner-return', 'continue_condition'), - edge('inner', 'output_collection', 'outer-return', 'output'), - linkage('inner', 'inner-return'), - linkage('outer', 'outer-return'), - ] - ); - - expect(validateForLoopGraph(graph)).toBeNull(); - }); - - it('accepts nested ForReturn state produced by the inner body', () => { - const graph = buildGraph( - [ - { id: 'outer', type: 'for' }, - { id: 'inner-collection', type: 'add' }, - { id: 'inner', type: 'for' }, - { id: 'inner-body', type: 'add' }, - { id: 'inner-state', type: 'add' }, - { id: 'inner-return', type: 'for_return' }, - { id: 'outer-return', type: 'for_return' }, - ], - [ - edge('outer', 'item', 'inner-collection', 'value'), - edge('inner-collection', 'value', 'inner', 'collection'), - edge('inner', 'item', 'inner-body', 'value'), - edge('inner', 'state', 'inner-state', 'state'), - edge('inner', 'item', 'inner-state', 'value'), - edge('inner-body', 'value', 'inner-return', 'output'), - edge('inner-state', 'state', 'inner-return', 'state'), - edge('inner', 'output_collection', 'outer-return', 'output'), - linkage('inner', 'inner-return'), - linkage('outer', 'outer-return'), - ] - ); - - expect(validateForLoopGraph(graph)).toBeNull(); - }); - - it('rejects an outer nested ForReturn condition from an external scope', () => { - const graph = buildGraph( - [ - { id: 'outer', type: 'for' }, - { id: 'inner-collection', type: 'add' }, - { id: 'inner', type: 'for' }, - { id: 'inner-body', type: 'add' }, - { id: 'inner-return', type: 'for_return' }, - { id: 'outer-return', type: 'for_return' }, - { id: 'external-condition', type: 'add' }, - ], - [ - edge('outer', 'item', 'inner-collection', 'value'), - edge('inner-collection', 'value', 'inner', 'collection'), - edge('inner', 'item', 'inner-body', 'value'), - edge('inner-body', 'value', 'inner-return', 'output'), - edge('inner', 'output_collection', 'outer-return', 'output'), - linkage('inner', 'inner-return'), - linkage('outer', 'outer-return'), - edge('external-condition', 'value', 'outer-return', 'continue_condition'), - ] - ); - - expect(validateForLoopGraph(graph)).toBe('nodes.forLoopNestedUnsupported'); - }); - - it('accepts deeper nested For boundaries when each boundary has one child', () => { - const graph = buildGraph( - [ - { id: 'outer', type: 'for' }, - { id: 'outer-collection', type: 'add' }, - { id: 'inner', type: 'for' }, - { id: 'inner-collection', type: 'add' }, - { id: 'leaf', type: 'for' }, - { id: 'leaf-body', type: 'add' }, - { id: 'leaf-return', type: 'for_return' }, - { id: 'inner-return', type: 'for_return' }, - { id: 'outer-return', type: 'for_return' }, - ], - [ - edge('outer', 'item', 'outer-collection', 'value'), - edge('outer-collection', 'value', 'inner', 'collection'), - edge('inner', 'item', 'inner-collection', 'value'), - edge('inner-collection', 'value', 'leaf', 'collection'), - edge('leaf', 'item', 'leaf-body', 'value'), - edge('leaf-body', 'value', 'leaf-return', 'output'), - edge('leaf', 'output_collection', 'inner-return', 'output'), - edge('inner', 'output_collection', 'outer-return', 'output'), - linkage('leaf', 'leaf-return'), - linkage('inner', 'inner-return'), - linkage('outer', 'outer-return'), - ] - ); - - expect(validateForLoopGraph(graph)).toBeNull(); - }); - - it('accepts a nested For with an outer continuation after the inner final output', () => { - const graph = buildGraph( - [ - { id: 'outer', type: 'for' }, - { id: 'inner-collection', type: 'add' }, - { id: 'inner', type: 'for' }, - { id: 'inner-body', type: 'add' }, - { id: 'inner-return', type: 'for_return' }, - { id: 'continuation', type: 'add' }, - { id: 'continuation-tail', type: 'add' }, - { id: 'outer-return', type: 'for_return' }, - ], - [ - edge('outer', 'item', 'inner-collection', 'value'), - edge('inner-collection', 'value', 'inner', 'collection'), - edge('inner', 'item', 'inner-body', 'value'), - edge('inner-body', 'value', 'inner-return', 'output'), - edge('inner', 'output_collection', 'continuation', 'value'), - edge('continuation', 'value', 'continuation-tail', 'value'), - edge('continuation-tail', 'value', 'outer-return', 'output'), - edge('continuation-tail', 'value', 'outer-return', 'continue_condition'), - linkage('inner', 'inner-return'), - linkage('outer', 'outer-return'), - ] - ); - - expect(validateForLoopGraph(graph)).toBeNull(); - }); - - it('rejects a nested continuation branch that does not reach the outer ForReturn', () => { - const graph = buildGraph( - [ - { id: 'outer', type: 'for' }, - { id: 'inner-collection', type: 'add' }, - { id: 'inner', type: 'for' }, - { id: 'inner-body', type: 'add' }, - { id: 'inner-return', type: 'for_return' }, - { id: 'continuation', type: 'add' }, - { id: 'dead-branch', type: 'add' }, - { id: 'outer-return', type: 'for_return' }, - ], - [ - edge('outer', 'item', 'inner-collection', 'value'), - edge('inner-collection', 'value', 'inner', 'collection'), - edge('inner', 'item', 'inner-body', 'value'), - edge('inner-body', 'value', 'inner-return', 'output'), - edge('inner', 'output_collection', 'continuation', 'value'), - edge('continuation', 'value', 'outer-return', 'output'), - edge('continuation', 'value', 'dead-branch', 'value'), - linkage('inner', 'inner-return'), - linkage('outer', 'outer-return'), - ] - ); - - expect(validateForLoopGraph(graph)).toBe('nodes.forLoopNestedUnsupported'); - }); - - it('accepts independent nested For children with an explicit fan-in continuation', () => { - const graph = buildGraph( - [ - { id: 'outer', type: 'for' }, - { id: 'first', type: 'for' }, - { id: 'second', type: 'for' }, - { id: 'first-body', type: 'add' }, - { id: 'second-body', type: 'add' }, - { id: 'first-return', type: 'for_return' }, - { id: 'second-return', type: 'for_return' }, - { id: 'fan-in', type: 'add' }, - { id: 'outer-return', type: 'for_return' }, - ], - [ - edge('outer', 'item', 'first', 'collection'), - edge('outer', 'item', 'second', 'collection'), - edge('first', 'item', 'first-body', 'value'), - edge('first-body', 'value', 'first-return', 'output'), - edge('second', 'item', 'second-body', 'value'), - edge('second-body', 'value', 'second-return', 'output'), - edge('first', 'output_collection', 'fan-in', 'first'), - edge('second', 'output_collection', 'fan-in', 'second'), - edge('fan-in', 'value', 'outer-return', 'output'), - linkage('first', 'first-return'), - linkage('second', 'second-return'), - linkage('outer', 'outer-return'), - ] - ); - - expect(validateForLoopGraph(graph)).toBeNull(); - }); - - it.each([ - { - name: 'missing loop linkage', - nodes: [ - { id: 'for', type: 'for' }, - { id: 'return', type: 'for_return' }, - ], - edges: [edge('for', 'item', 'return', 'output')], - expected: 'nodes.forLoopLinkageMissing', - }, - { - name: 'duplicate loop linkage', - nodes: [ - { id: 'for', type: 'for' }, - { id: 'return', type: 'for_return' }, - ], - edges: [edge('for', 'item', 'return', 'output'), linkage('for', 'return'), linkage('for', 'return')], - expected: 'nodes.forLoopLinkageDuplicate', - }, - { - name: 'duplicate For collection inputs', - nodes: [ - { id: 'for', type: 'for' }, - { id: 'first', type: 'add' }, - { id: 'second', type: 'add' }, - { id: 'return', type: 'for_return' }, - ], - edges: [ - edge('first', 'value', 'for', 'collection'), - edge('second', 'value', 'for', 'collection'), - edge('for', 'item', 'return', 'output'), - linkage('for', 'return'), - ], - expected: 'nodes.forLoopInputCount', - }, - { - name: 'duplicate For state inputs', - nodes: [ - { id: 'for', type: 'for' }, - { id: 'first', type: 'add' }, - { id: 'second', type: 'add' }, - { id: 'return', type: 'for_return' }, - ], - edges: [ - edge('first', 'value', 'for', 'state'), - edge('second', 'value', 'for', 'state'), - edge('for', 'item', 'return', 'output'), - linkage('for', 'return'), - ], - expected: 'nodes.forLoopInputCount', - }, - { - name: 'duplicate ForReturn outputs', - nodes: [ - { id: 'for', type: 'for' }, - { id: 'first', type: 'add' }, - { id: 'second', type: 'add' }, - { id: 'return', type: 'for_return' }, - ], - edges: [ - edge('for', 'item', 'return', 'output'), - edge('first', 'value', 'return', 'output'), - edge('second', 'value', 'return', 'output'), - linkage('for', 'return'), - ], - expected: 'nodes.forReturnInputCount', - }, - { - name: 'duplicate ForReturn state inputs', - nodes: [ - { id: 'for', type: 'for' }, - { id: 'first', type: 'add' }, - { id: 'second', type: 'add' }, - { id: 'return', type: 'for_return' }, - ], - edges: [ - edge('for', 'item', 'return', 'output'), - edge('first', 'value', 'return', 'state'), - edge('second', 'value', 'return', 'state'), - linkage('for', 'return'), - ], - expected: 'nodes.forReturnInputCount', - }, - { - name: 'duplicate ForReturn continue conditions', - nodes: [ - { id: 'for', type: 'for' }, - { id: 'first', type: 'add' }, - { id: 'second', type: 'add' }, - { id: 'return', type: 'for_return' }, - ], - edges: [ - edge('for', 'item', 'return', 'output'), - edge('first', 'value', 'return', 'continue_condition'), - edge('second', 'value', 'return', 'continue_condition'), - linkage('for', 'return'), - ], - expected: 'nodes.forReturnInputCount', - }, - ])('rejects $name', ({ nodes, edges, expected }) => { - expect(validateForLoopGraph(buildGraph(nodes, edges))).toBe(expected); - }); - - it.each([ - { - name: 'missing iteration output', - nodes: [ - { id: 'for', type: 'for' }, - { id: 'return', type: 'for_return' }, - ], - edges: [linkage('for', 'return')], - expected: 'nodes.forLoopMissingIterationOutput', - }, - { - name: 'missing ForReturn', - nodes: [ - { id: 'for', type: 'for' }, - { id: 'body', type: 'add' }, - ], - edges: [edge('for', 'item', 'body', 'a')], - expected: 'nodes.forLoopLinkageMissing', - }, - { - name: 'multiple ForReturn nodes', - nodes: [ - { id: 'for', type: 'for' }, - { id: 'first', type: 'for_return' }, - { id: 'second', type: 'for_return' }, - ], - edges: [edge('for', 'item', 'first', 'output'), edge('for', 'state', 'second', 'state'), linkage('for', 'first')], - expected: 'nodes.forLoopLinkageMissing', - }, - { - name: 'unterminated body branch', - nodes: [ - { id: 'for', type: 'for' }, - { id: 'body', type: 'add' }, - { id: 'return', type: 'for_return' }, - { id: 'escape', type: 'add' }, - ], - edges: [ - edge('for', 'item', 'body', 'a'), - edge('body', 'value', 'return', 'output'), - edge('for', 'state', 'escape', 'a'), - linkage('for', 'return'), - ], - expected: 'nodes.forLoopUnterminatedBody', - }, - { - name: 'nested For', - nodes: [ - { id: 'for', type: 'for' }, - { id: 'nested', type: 'for' }, - { id: 'return', type: 'for_return' }, - ], - edges: [ - edge('for', 'item', 'nested', 'collection'), - edge('nested', 'item', 'return', 'output'), - linkage('nested', 'return'), - ], - expected: 'nodes.forLoopLinkageMissing', - }, - { - name: 'multiple direct nested For children', - nodes: [ - { id: 'outer', type: 'for' }, - { id: 'first', type: 'for' }, - { id: 'second', type: 'for' }, - { id: 'first-return', type: 'for_return' }, - { id: 'second-return', type: 'for_return' }, - { id: 'outer-return', type: 'for_return' }, - ], - edges: [ - edge('outer', 'item', 'first', 'collection'), - edge('outer', 'item', 'second', 'collection'), - edge('first', 'item', 'first-return', 'output'), - edge('second', 'item', 'second-return', 'output'), - edge('first', 'output_collection', 'outer-return', 'output'), - linkage('first', 'first-return'), - linkage('second', 'second-return'), - linkage('outer', 'outer-return'), - ], - expected: 'nodes.forLoopNestedUnsupported', - }, - { - name: 'mixed nested For and Iterate body', - nodes: [ - { id: 'outer', type: 'for' }, - { id: 'inner-collection', type: 'add' }, - { id: 'inner', type: 'for' }, - { id: 'iterate-collection', type: 'add' }, - { id: 'iterate', type: 'iterate' }, - { id: 'body', type: 'add' }, - { id: 'collect', type: 'collect' }, - { id: 'inner-return', type: 'for_return' }, - { id: 'outer-return', type: 'for_return' }, - ], - edges: [ - edge('outer', 'item', 'inner-collection', 'value'), - edge('inner-collection', 'value', 'inner', 'collection'), - edge('inner', 'item', 'iterate-collection', 'value'), - edge('iterate-collection', 'value', 'iterate', 'collection'), - edge('iterate', 'item', 'body', 'value'), - edge('body', 'value', 'collect', 'item'), - edge('collect', 'collection', 'inner-return', 'output'), - edge('inner', 'output_collection', 'outer-return', 'output'), - linkage('inner', 'inner-return'), - linkage('outer', 'outer-return'), - ], - expected: 'nodes.forLoopNestedUnsupported', - }, - { - name: 'body Iterate', - nodes: [ - { id: 'for', type: 'for' }, - { id: 'iterate', type: 'iterate' }, - { id: 'return', type: 'for_return' }, - ], - edges: [ - edge('for', 'item', 'iterate', 'collection'), - edge('iterate', 'item', 'return', 'output'), - linkage('for', 'return'), - ], - expected: 'nodes.forLoopIterateUnsupported', - }, - { - name: 'iterator-derived external body input', - nodes: [ - { id: 'collection', type: 'integer_collection' }, - { id: 'iterate', type: 'iterate' }, - { id: 'external', type: 'add' }, - { id: 'for', type: 'for' }, - { id: 'body', type: 'add' }, - { id: 'return', type: 'for_return' }, - ], - edges: [ - edge('collection', 'collection', 'iterate', 'collection'), - edge('iterate', 'item', 'external', 'a'), - edge('for', 'item', 'body', 'a'), - edge('external', 'value', 'body', 'b'), - edge('body', 'value', 'return', 'output'), - linkage('for', 'return'), - ], - expected: 'nodes.forLoopIteratorInputUnsupported', - }, - { - name: 'final output feeding body', - nodes: [ - { id: 'for', type: 'for' }, - { id: 'return', type: 'for_return' }, - ], - edges: [ - edge('for', 'item', 'return', 'output'), - edge('for', 'final_state', 'return', 'state'), - linkage('for', 'return'), - ], - expected: 'nodes.forLoopFinalOutputInBody', - }, - { - name: 'body output escaping before ForReturn', - nodes: [ - { id: 'for', type: 'for' }, - { id: 'body', type: 'add' }, - { id: 'return', type: 'for_return' }, - { id: 'escape', type: 'add' }, - ], - edges: [ - edge('for', 'item', 'body', 'a'), - edge('body', 'value', 'return', 'output'), - edge('body', 'value', 'escape', 'a'), - linkage('for', 'return'), - ], - expected: 'nodes.forLoopUnterminatedBody', - }, - { - name: 'ForReturn shared by two loops', - nodes: [ - { id: 'first', type: 'for' }, - { id: 'second', type: 'for' }, - { id: 'return', type: 'for_return' }, - ], - edges: [ - edge('first', 'item', 'return', 'output'), - edge('second', 'item', 'return', 'output'), - linkage('first', 'return'), - linkage('second', 'return'), - ], - expected: 'nodes.forLoopLinkageDuplicate', - }, - ])('rejects $name', ({ nodes, edges, expected }) => { - expect(validateForLoopGraph(buildGraph(nodes, edges))).toBe(expected); - }); -}); diff --git a/invokeai/frontend/web/src/features/nodes/util/node/getOutputFieldNamesByScope.test.ts b/invokeai/frontend/web/src/features/nodes/util/node/getOutputFieldNamesByScope.test.ts deleted file mode 100644 index 4cea157b14c..00000000000 --- a/invokeai/frontend/web/src/features/nodes/util/node/getOutputFieldNamesByScope.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { for_return } from 'features/nodes/store/util/testUtils'; -import type { FieldOutputTemplate } from 'features/nodes/types/field'; -import { getOutputFieldNamesByScope } from 'features/nodes/util/node/getOutputFieldNamesByScope'; -import { describe, expect, it } from 'vitest'; - -const buildOutput = ( - name: string, - output_scope: FieldOutputTemplate['output_scope'], - ui_order: number, - ui_hidden = false -): FieldOutputTemplate => ({ - fieldKind: 'output', - name, - title: name, - description: name, - type: { name: 'AnyField', cardinality: 'SINGLE', batch: false }, - ui_hidden, - ui_order, - output_scope, -}); - -describe(getOutputFieldNamesByScope.name, () => { - it('sorts visible output fields and partitions them by scope', () => { - const fields = [ - buildOutput('output_collection', 'final', 3), - buildOutput('hidden_iteration_value', 'iteration', 0, true), - buildOutput('value', null, 2), - buildOutput('item', 'iteration', 1), - ]; - - expect(getOutputFieldNamesByScope(fields)).toEqual({ - all: ['item', 'value', 'output_collection'], - unscoped: ['value'], - iteration: ['item'], - final: ['output_collection'], - }); - }); - - it('hides ForReturn scheduler outputs from the node UI', () => { - expect(getOutputFieldNamesByScope(Object.values(for_return.outputs))).toEqual({ - all: [], - unscoped: [], - iteration: [], - final: [], - }); - expect(for_return.inputs.output?.ui_hidden).toBe(false); - expect(for_return.inputs.state?.ui_hidden).toBe(false); - }); -}); diff --git a/invokeai/frontend/web/src/features/nodes/util/node/getOutputFieldNamesByScope.ts b/invokeai/frontend/web/src/features/nodes/util/node/getOutputFieldNamesByScope.ts deleted file mode 100644 index 7ac39707381..00000000000 --- a/invokeai/frontend/web/src/features/nodes/util/node/getOutputFieldNamesByScope.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { FieldOutputTemplate } from 'features/nodes/types/field'; -import { getSortedFilteredFieldNames } from 'features/nodes/util/node/getSortedFilteredFieldNames'; - -export type OutputFieldNamesByScope = { - all: string[]; - unscoped: string[]; - iteration: string[]; - final: string[]; -}; - -export const getOutputFieldNamesByScope = (fields: FieldOutputTemplate[]): OutputFieldNamesByScope => { - const all = getSortedFilteredFieldNames(fields); - const fieldsByName = new Map(fields.map((field) => [field.name, field])); - - return { - all, - unscoped: all.filter((name) => !fieldsByName.get(name)?.output_scope), - iteration: all.filter((name) => fieldsByName.get(name)?.output_scope === 'iteration'), - final: all.filter((name) => fieldsByName.get(name)?.output_scope === 'final'), - }; -}; diff --git a/invokeai/frontend/web/src/features/nodes/util/node/getOutputFieldRows.test.ts b/invokeai/frontend/web/src/features/nodes/util/node/getOutputFieldRows.test.ts deleted file mode 100644 index 619f590f770..00000000000 --- a/invokeai/frontend/web/src/features/nodes/util/node/getOutputFieldRows.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { getOutputFieldRows } from 'features/nodes/util/node/getOutputFieldRows'; -import { describe, expect, it } from 'vitest'; - -describe(getOutputFieldRows.name, () => { - it('returns ordinary outputs without section headers', () => { - expect( - getOutputFieldRows({ - all: ['value', 'metadata'], - unscoped: ['value', 'metadata'], - iteration: [], - final: [], - }) - ).toEqual([ - { type: 'field', fieldName: 'value' }, - { type: 'field', fieldName: 'metadata' }, - ]); - }); - - it('groups scoped outputs under iteration and final section headers', () => { - expect( - getOutputFieldRows({ - all: ['value', 'item', 'state', 'output_collection', 'final_state'], - unscoped: ['value'], - iteration: ['item', 'state'], - final: ['output_collection', 'final_state'], - }) - ).toEqual([ - { type: 'field', fieldName: 'value' }, - { type: 'header', scope: 'iteration' }, - { type: 'field', fieldName: 'item' }, - { type: 'field', fieldName: 'state' }, - { type: 'header', scope: 'final' }, - { type: 'field', fieldName: 'output_collection' }, - { type: 'field', fieldName: 'final_state' }, - ]); - }); -}); diff --git a/invokeai/frontend/web/src/features/nodes/util/node/getOutputFieldRows.ts b/invokeai/frontend/web/src/features/nodes/util/node/getOutputFieldRows.ts deleted file mode 100644 index c39d8e44ed4..00000000000 --- a/invokeai/frontend/web/src/features/nodes/util/node/getOutputFieldRows.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { OutputFieldNamesByScope } from 'features/nodes/util/node/getOutputFieldNamesByScope'; - -type OutputFieldRow = { type: 'field'; fieldName: string } | { type: 'header'; scope: 'iteration' | 'final' }; - -const getFieldRows = (fieldNames: string[]): OutputFieldRow[] => - fieldNames.map((fieldName) => ({ type: 'field', fieldName })); - -export const getOutputFieldRows = (fieldNames: OutputFieldNamesByScope): OutputFieldRow[] => { - if (fieldNames.iteration.length === 0 && fieldNames.final.length === 0) { - return getFieldRows(fieldNames.all); - } - - const rows = getFieldRows(fieldNames.unscoped); - if (fieldNames.iteration.length > 0) { - rows.push({ type: 'header', scope: 'iteration' }, ...getFieldRows(fieldNames.iteration)); - } - if (fieldNames.final.length > 0) { - rows.push({ type: 'header', scope: 'final' }, ...getFieldRows(fieldNames.final)); - } - return rows; -}; diff --git a/invokeai/frontend/web/src/features/nodes/util/node/nodeUpdate.ts b/invokeai/frontend/web/src/features/nodes/util/node/nodeUpdate.ts index e648b86f397..ca1b397a91d 100644 --- a/invokeai/frontend/web/src/features/nodes/util/node/nodeUpdate.ts +++ b/invokeai/frontend/web/src/features/nodes/util/node/nodeUpdate.ts @@ -18,7 +18,7 @@ type UpdateNodeOptions = { export const getConnectedInputNames = (nodeId: string, edges: ConnectedInputEdge[]): Set => new Set( edges.flatMap((edge) => - edge.type !== 'loop_linkage' && edge.target === nodeId && edge.targetHandle ? [edge.targetHandle] : [] + edge.type === 'default' && edge.target === nodeId && edge.targetHandle ? [edge.targetHandle] : [] ) ); diff --git a/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputTemplate.ts b/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputTemplate.ts index c5d660779ce..4c0e4130e76 100644 --- a/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputTemplate.ts +++ b/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputTemplate.ts @@ -46,17 +46,13 @@ import { isStatefulFieldType, isStringCollectionFieldType, } from 'features/nodes/types/field'; -import type { InvocationInputFieldSchema } from 'features/nodes/types/openapi'; +import type { InvocationFieldSchema } from 'features/nodes/types/openapi'; import { isSchemaObject } from 'features/nodes/types/openapi'; import { t } from 'i18next'; // eslint-disable-next-line @typescript-eslint/no-explicit-any type FieldInputTemplateBuilder = // valid `any`! - (arg: { - schemaObject: InvocationInputFieldSchema; - baseField: Omit; - fieldType: T['type']; - }) => T; + (arg: { schemaObject: InvocationFieldSchema; baseField: Omit; fieldType: T['type'] }) => T; const buildIntegerFieldInputTemplate: FieldInputTemplateBuilder = ({ schemaObject, @@ -629,7 +625,7 @@ const TEMPLATE_BUILDER_MAP: Record { diff --git a/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldOutputTemplate.test.ts b/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldOutputTemplate.test.ts deleted file mode 100644 index d3b8ea05cd9..00000000000 --- a/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldOutputTemplate.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { FieldType } from 'features/nodes/types/field'; -import type { InvocationOutputFieldSchema } from 'features/nodes/types/openapi'; -import { buildFieldOutputTemplate } from 'features/nodes/util/schema/buildFieldOutputTemplate'; -import { describe, expect, it } from 'vitest'; - -const fieldType: FieldType = { - name: 'StringField', - cardinality: 'SINGLE', - batch: false, -}; - -describe('buildFieldOutputTemplate', () => { - it('preserves output scope metadata', () => { - const fieldSchema = { - field_kind: 'output', - title: 'Item', - description: 'The current item', - ui_hidden: false, - output_scope: 'iteration', - } as InvocationOutputFieldSchema; - - const template = buildFieldOutputTemplate(fieldSchema, 'item', fieldType); - - expect(template.output_scope).toBe('iteration'); - }); -}); diff --git a/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldOutputTemplate.ts b/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldOutputTemplate.ts index 50ee9f161af..960af9395b2 100644 --- a/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldOutputTemplate.ts +++ b/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldOutputTemplate.ts @@ -1,13 +1,13 @@ import { startCase } from 'es-toolkit/compat'; import type { FieldOutputTemplate, FieldType } from 'features/nodes/types/field'; -import type { InvocationOutputFieldSchema } from 'features/nodes/types/openapi'; +import type { InvocationFieldSchema } from 'features/nodes/types/openapi'; export const buildFieldOutputTemplate = ( - fieldSchema: InvocationOutputFieldSchema, + fieldSchema: InvocationFieldSchema, fieldName: string, fieldType: FieldType ): FieldOutputTemplate => { - const { title, description, ui_hidden, ui_type, ui_order, output_scope } = fieldSchema; + const { title, description, ui_hidden, ui_type, ui_order } = fieldSchema; const template: FieldOutputTemplate = { fieldKind: 'output', @@ -18,7 +18,6 @@ export const buildFieldOutputTemplate = ( ui_hidden, ui_type, ui_order, - output_scope, }; return template; diff --git a/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.test.ts b/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.test.ts index eb31e5d0ac7..1317f973ddf 100644 --- a/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.test.ts +++ b/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.test.ts @@ -1,26 +1,9 @@ import { omit, pick } from 'es-toolkit/compat'; -import { - call_saved_workflow, - for_loop, - for_return, - schema, - templates, - workflow_return, -} from 'features/nodes/store/util/testUtils'; -import type { InvocationTemplate } from 'features/nodes/types/invocation'; +import { call_saved_workflow, schema, templates, workflow_return } from 'features/nodes/store/util/testUtils'; import { parseSchema } from 'features/nodes/util/schema/parseSchema'; -import type { OpenAPIV3_1 } from 'openapi-types'; import { describe, expect, it } from 'vitest'; -import generatedSchemaJSON from '../../../../../openapi.json?raw'; - const stripUndefinedDeep = (value: T): T => JSON.parse(JSON.stringify(value)) as T; -const normalizeInputUiHidden = (template: InvocationTemplate): InvocationTemplate => ({ - ...template, - inputs: Object.fromEntries( - Object.entries(template.inputs).map(([name, input]) => [name, { ...input, ui_hidden: input.ui_hidden ?? false }]) - ), -}); describe('parseSchema', () => { it('should parse the schema', () => { @@ -63,67 +46,4 @@ describe('parseSchema', () => { expect(collectionInput.type.name).toBe('CollectionField'); expect(collectionInput.ui_type).toBe('CollectionField'); }); - it('should keep the loop test templates aligned with the generated schema', () => { - const generatedSchema = JSON.parse(generatedSchemaJSON) as OpenAPIV3_1.Document; - const parsed = parseSchema(generatedSchema, ['for', 'for_return']); - - expect( - stripUndefinedDeep( - Object.fromEntries(Object.entries(parsed).map(([type, template]) => [type, normalizeInputUiHidden(template)])) - ) - ).toEqual( - stripUndefinedDeep({ - for: normalizeInputUiHidden(for_loop), - for_return: normalizeInputUiHidden(for_return), - }) - ); - - // Keep this explicit check so the generated schema and hand-maintained loop fixture cannot drift together. - expect(parsed.for_return?.version).toBe('1.3.2'); - expect(parsed.for_return?.inputs.continue_condition).toMatchObject({ - input: 'any', - required: false, - default: true, - type: { name: 'BooleanField' }, - }); - }); - it('should expose state_set.value as an AnyField connection input', () => { - const generatedSchema = JSON.parse(generatedSchemaJSON) as OpenAPIV3_1.Document; - const parsed = parseSchema(generatedSchema, ['state_set']); - const valueInput = parsed.state_set?.inputs.value; - - expect(valueInput).toMatchObject({ - input: 'connection', - ui_type: 'AnyField', - type: { name: 'AnyField', cardinality: 'SINGLE', batch: false }, - }); - expect(valueInput?.default).toBeUndefined(); - }); - it('should expose state_get.default and value as AnyField connections', () => { - const generatedSchema = JSON.parse(generatedSchemaJSON) as OpenAPIV3_1.Document; - const parsed = parseSchema(generatedSchema, ['state_get']); - const template = parsed.state_get; - - expect(template?.inputs.default).toMatchObject({ - input: 'connection', - ui_type: 'AnyField', - type: { name: 'AnyField', cardinality: 'SINGLE', batch: false }, - }); - expect(template?.inputs.default?.default).toBeUndefined(); - expect(template?.outputs.value).toMatchObject({ - ui_type: 'AnyField', - type: { name: 'AnyField', cardinality: 'SINGLE', batch: false }, - }); - }); - it('should expose state_merge.values as an AnyField connection input', () => { - const generatedSchema = JSON.parse(generatedSchemaJSON) as OpenAPIV3_1.Document; - const parsed = parseSchema(generatedSchema, ['state_merge']); - const valuesInput = parsed.state_merge?.inputs.values; - - expect(valuesInput).toMatchObject({ - input: 'connection', - ui_type: 'AnyField', - type: { name: 'AnyField', cardinality: 'SINGLE', batch: false }, - }); - }); }); diff --git a/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.ts b/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.ts index 659ad823892..47be2c62ec7 100644 --- a/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.ts +++ b/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.ts @@ -14,8 +14,7 @@ import { import type { InvocationTemplate } from 'features/nodes/types/invocation'; import type { InvocationFieldSchema, InvocationSchemaObject } from 'features/nodes/types/openapi'; import { - isInvocationInputFieldSchema, - isInvocationOutputFieldSchema, + isInvocationFieldSchema, isInvocationOutputSchemaObject, isInvocationSchemaObject, } from 'features/nodes/types/openapi'; @@ -43,9 +42,6 @@ const isReservedInputField = (nodeType: string, fieldName: string) => { if (nodeType === 'iterate' && fieldName === 'index') { return true; } - if (nodeType === 'for' && fieldName === 'index') { - return true; - } return false; }; @@ -130,7 +126,7 @@ export const parseSchema = ( return inputsAccumulator; } - if (!isInvocationInputFieldSchema(property)) { + if (!isInvocationFieldSchema(property)) { log.warn({ node: type, field: propertyName, schema: parseify(property) }, 'Unhandled input property'); return inputsAccumulator; } @@ -207,7 +203,7 @@ export const parseSchema = ( return outputsAccumulator; } - if (!isInvocationOutputFieldSchema(property)) { + if (!isInvocationFieldSchema(property)) { log.warn({ node: type, field: propertyName, schema: parseify(property) }, 'Unhandled output property'); return outputsAccumulator; } diff --git a/invokeai/frontend/web/src/features/nodes/util/workflow/buildWorkflow.test.ts b/invokeai/frontend/web/src/features/nodes/util/workflow/buildWorkflow.test.ts index 42c8a329110..c7bdd80acea 100644 --- a/invokeai/frontend/web/src/features/nodes/util/workflow/buildWorkflow.test.ts +++ b/invokeai/frontend/web/src/features/nodes/util/workflow/buildWorkflow.test.ts @@ -1,40 +1,8 @@ import { getInitialWorkflow } from 'features/nodes/store/nodesSlice'; -import { buildEdge, buildNode, call_saved_workflow, for_loop, for_return } from 'features/nodes/store/util/testUtils'; +import { buildNode, call_saved_workflow } from 'features/nodes/store/util/testUtils'; import { describe, expect, it } from 'vitest'; describe('buildWorkflowFast', () => { - it('serializes loop linkage handles as loop_linkage edges even when the edge type is stale', async () => { - Object.assign(globalThis, { - window: { - location: { - origin: 'http://localhost', - }, - }, - }); - - const { buildWorkflowFast } = await import('features/nodes/util/workflow/buildWorkflow'); - const forNode = buildNode(for_loop); - const returnNode = buildNode(for_return); - - const workflow = buildWorkflowFast({ - _version: 1, - formFieldInitialValues: {}, - ...getInitialWorkflow(), - nodes: [forNode, returnNode], - edges: [buildEdge(forNode.id, 'loop_linkage', returnNode.id, 'loop_linkage')], - }); - - expect(workflow.edges).toEqual([ - expect.objectContaining({ - type: 'loop_linkage', - source: forNode.id, - sourceHandle: 'loop_linkage', - target: returnNode.id, - targetHandle: 'loop_linkage', - }), - ]); - }); - it('persists the selected workflow id for call_saved_workflow nodes', async () => { Object.assign(globalThis, { window: { diff --git a/invokeai/frontend/web/src/features/nodes/util/workflow/buildWorkflow.ts b/invokeai/frontend/web/src/features/nodes/util/workflow/buildWorkflow.ts index 2ed98d4475c..6d35fdf3f47 100644 --- a/invokeai/frontend/web/src/features/nodes/util/workflow/buildWorkflow.ts +++ b/invokeai/frontend/web/src/features/nodes/util/workflow/buildWorkflow.ts @@ -5,7 +5,6 @@ import { parseify } from 'common/util/serialize'; import { pick } from 'es-toolkit/compat'; import { selectNodesSlice } from 'features/nodes/store/selectors'; import type { NodesState } from 'features/nodes/store/types'; -import { getEdgeTypeFromHandles } from 'features/nodes/store/util/reactFlowUtil'; import { isConnectorNode, isInvocationNode, isNotesNode } from 'features/nodes/types/invocation'; import type { WorkflowV3 } from 'features/nodes/types/workflow'; import { zWorkflowV3 } from 'features/nodes/types/workflow'; @@ -53,14 +52,9 @@ export const buildWorkflowFast = (nodesState: NodesState): WorkflowV3 => { } for (const edge of edges) { - if ((edge.type === 'default' || edge.type === 'loop_linkage') && edge.sourceHandle && edge.targetHandle) { - const { id, source, target, sourceHandle, targetHandle, hidden } = edge; - const type = edge.type === 'loop_linkage' ? 'loop_linkage' : getEdgeTypeFromHandles(sourceHandle, targetHandle); - if (type === 'loop_linkage') { - newWorkflow.edges.push({ id, type, source, target, sourceHandle, targetHandle }); - } else { - newWorkflow.edges.push({ id, type, source, target, sourceHandle, targetHandle, hidden }); - } + if (edge.type === 'default' && edge.sourceHandle && edge.targetHandle) { + const { id, type, source, target, sourceHandle, targetHandle, hidden } = edge; + newWorkflow.edges.push({ id, type, source, target, sourceHandle, targetHandle, hidden }); } else if (edge.type === 'collapsed') { const { id, type, source, target } = edge; newWorkflow.edges.push({ id, type, source, target }); diff --git a/invokeai/frontend/web/src/features/nodes/util/workflow/graphToWorkflow.test.ts b/invokeai/frontend/web/src/features/nodes/util/workflow/graphToWorkflow.test.ts index 8837a1df0aa..18c30d486b2 100644 --- a/invokeai/frontend/web/src/features/nodes/util/workflow/graphToWorkflow.test.ts +++ b/invokeai/frontend/web/src/features/nodes/util/workflow/graphToWorkflow.test.ts @@ -1,14 +1,11 @@ import { $templates } from 'features/nodes/store/nodesSlice'; import type { Templates } from 'features/nodes/store/types'; -import { for_loop, for_return } from 'features/nodes/store/util/testUtils'; import type { InvocationTemplate } from 'features/nodes/types/invocation'; import { isWorkflowInvocationNode } from 'features/nodes/types/workflow'; -import { buildNodesGraph } from 'features/nodes/util/graph/buildNodesGraph'; -import { getOutputFieldNamesByScope } from 'features/nodes/util/node/getOutputFieldNamesByScope'; -import { graphToWorkflow } from 'features/nodes/util/workflow/graphToWorkflow'; import type { NonNullableGraph } from 'services/api/types'; import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { graphToWorkflow } from './graphToWorkflow'; import { parseAndMigrateWorkflow } from './migrations'; // Minimal templates needed to render the user's graph. We use the same shape @@ -236,10 +233,9 @@ const imageCollectionTemplate = { describe('graphToWorkflow', () => { const originalTemplates = $templates.get(); - const loopTemplates: Templates = { for: for_loop, for_return }; beforeEach(() => { - $templates.set({ image_collection: imageCollectionTemplate, ...loopTemplates }); + $templates.set({ image_collection: imageCollectionTemplate }); }); afterEach(() => { @@ -302,134 +298,4 @@ describe('graphToWorkflow', () => { expect(node.data.inputs.images?.value).toBeUndefined(); expect(node.data.inputs.collection?.value).toEqual(images); }); - - it('round-trips For and ForReturn nodes and resolves scoped outputs from their templates', () => { - const graph = { - id: 'graph', - nodes: { - for: { - id: 'for', - type: 'for', - collection: ['alpha', 'beta'], - state: null, - index: -1, - }, - return: { - id: 'return', - type: 'for_return', - output: null, - state: null, - continue_condition: null, - }, - }, - edges: [ - { - type: 'default', - source: { node_id: 'for', field: 'item' }, - destination: { node_id: 'return', field: 'output' }, - }, - { - type: 'loop_linkage', - source: { node_id: 'for', field: 'loop_linkage' }, - destination: { node_id: 'return', field: 'loop_linkage' }, - }, - ], - } satisfies NonNullableGraph; - - const workflow = graphToWorkflow(graph, false); - const forNode = workflow.nodes.find((node) => node.id === 'for'); - const returnNode = workflow.nodes.find((node) => node.id === 'return'); - if (!isWorkflowInvocationNode(forNode) || !isWorkflowInvocationNode(returnNode)) { - throw new Error('Expected For and ForReturn invocation nodes'); - } - - expect(forNode.data.inputs.collection?.value).toEqual(['alpha', 'beta']); - expect(forNode.data.inputs.state?.value).toBeNull(); - expect(forNode.data.inputs.index).toBeUndefined(); - expect(returnNode.data.inputs.continue_condition?.value).toBeNull(); - expect(returnNode.data.inputs.state?.value).toBeNull(); - expect(workflow.edges).toHaveLength(2); - expect(workflow.edges[0]).toMatchObject({ - type: 'default', - source: 'for', - sourceHandle: 'item', - target: 'return', - targetHandle: 'output', - }); - expect(workflow.edges[1]).toMatchObject({ - type: 'loop_linkage', - source: 'for', - sourceHandle: 'loop_linkage', - target: 'return', - targetHandle: 'loop_linkage', - }); - const resolvedForTemplate = loopTemplates[forNode.data.type]; - if (!resolvedForTemplate) { - throw new Error('Expected the round-tripped For node type to resolve its template'); - } - expect(getOutputFieldNamesByScope(Object.values(resolvedForTemplate.outputs))).toEqual({ - all: ['loop_linkage', 'item', 'index', 'total', 'state', 'output_collection', 'final_state'], - unscoped: ['loop_linkage'], - iteration: ['item', 'index', 'total', 'state'], - final: ['output_collection', 'final_state'], - }); - - const rootState = { - nodes: { - past: [], - future: [], - present: { - _version: 1, - formFieldInitialValues: {}, - ...workflow, - }, - }, - gallery: { - autoAddBoardId: 'none', - }, - } as never; - const rebuiltGraph = buildNodesGraph(rootState, loopTemplates); - - expect(rebuiltGraph.nodes.for).toMatchObject({ - type: 'for', - collection: ['alpha', 'beta'], - state: null, - }); - expect(rebuiltGraph.nodes.return).toMatchObject({ - type: 'for_return', - state: null, - continue_condition: null, - }); - expect(rebuiltGraph.edges).toEqual(graph.edges); - }); - - it('normalizes a default graph edge between loop linkage fields', () => { - const workflow = graphToWorkflow( - { - id: 'graph', - nodes: { - for: { id: 'for', type: 'for', collection: [], state: null }, - return: { id: 'return', type: 'for_return', output: null, state: null, continue_condition: true }, - }, - edges: [ - { - type: 'default', - source: { node_id: 'for', field: 'loop_linkage' }, - destination: { node_id: 'return', field: 'loop_linkage' }, - }, - ], - } satisfies NonNullableGraph, - false - ); - - expect(workflow.edges).toEqual([ - expect.objectContaining({ - type: 'loop_linkage', - source: 'for', - sourceHandle: 'loop_linkage', - target: 'return', - targetHandle: 'loop_linkage', - }), - ]); - }); }); diff --git a/invokeai/frontend/web/src/features/nodes/util/workflow/graphToWorkflow.ts b/invokeai/frontend/web/src/features/nodes/util/workflow/graphToWorkflow.ts index 037f464631c..a55e13e0942 100644 --- a/invokeai/frontend/web/src/features/nodes/util/workflow/graphToWorkflow.ts +++ b/invokeai/frontend/web/src/features/nodes/util/workflow/graphToWorkflow.ts @@ -2,7 +2,6 @@ import * as dagre from '@dagrejs/dagre'; import { logger } from 'app/logging/logger'; import { forEach } from 'es-toolkit/compat'; import { $templates } from 'features/nodes/store/nodesSlice'; -import { getEdgeTypeFromHandles } from 'features/nodes/store/util/reactFlowUtil'; import { NODE_WIDTH } from 'features/nodes/types/constants'; import { nodeAcceptsExtraInputs } from 'features/nodes/types/extraInputs'; import type { FieldInputInstance, FieldInputTemplate } from 'features/nodes/types/field'; @@ -123,10 +122,7 @@ export const graphToWorkflow = (graph: NonNullableGraph, autoLayout = true): Wor forEach(graph.edges, (edge) => { workflow.edges.push({ id: uuidv4(), // we don't have edge IDs in the graph - type: - edge.type === 'loop_linkage' - ? 'loop_linkage' - : getEdgeTypeFromHandles(edge.source.field, edge.destination.field), + type: 'default', source: edge.source.node_id, sourceHandle: edge.source.field, target: edge.destination.node_id, @@ -171,11 +167,9 @@ export const graphToWorkflow = (graph: NonNullableGraph, autoLayout = true): Wor dagreGraph.setNode(node.id, { width, height }); }); - graph.edges - .filter((edge) => edge.type !== 'loop_linkage') - .forEach((edge) => { - dagreGraph.setEdge(edge.source.node_id, edge.destination.node_id); - }); + graph.edges.forEach((edge) => { + dagreGraph.setEdge(edge.source.node_id, edge.destination.node_id); + }); // This does the magic dagre.layout(dagreGraph); diff --git a/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.test.ts b/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.test.ts index 0316f9cd76a..7d5abf34e5e 100644 --- a/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.test.ts +++ b/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.test.ts @@ -4,8 +4,6 @@ import { CONNECTOR_INPUT_HANDLE, CONNECTOR_OUTPUT_HANDLE } from 'features/nodes/ import { add, call_saved_workflow, - for_loop, - for_return, img_resize, main_model_loader, workflow_return, @@ -701,236 +699,4 @@ describe('validateWorkflow', () => { expect(validationResult.warnings.length).toBe(1); }); - - it('should remove the internal For index from a loaded workflow', async () => { - const forNode = buildInvocationNode({ x: 0, y: 0 }, for_loop); - forNode.data.inputs.index = { - name: 'index', - label: '', - description: '', - value: -1, - }; - const workflow: WorkflowV3 = { - name: '', - author: '', - description: '', - version: '', - contact: '', - tags: '', - notes: '', - exposedFields: [], - form: getDefaultForm(), - meta: { version: '4.0.0', category: 'user' }, - nodes: [forNode], - edges: [], - }; - - const validationResult = await validateWorkflow({ - workflow, - templates: { for: for_loop }, - checkImageAccess: resolveTrue, - checkVideoAccess: resolveTrue, - checkBoardAccess: resolveTrue, - checkModelAccess: resolveTrue, - }); - - expect(validationResult.warnings).toEqual([]); - expect(validationResult.workflow.nodes[0]?.type).toBe('invocation'); - if (validationResult.workflow.nodes[0]?.type !== 'invocation') { - throw new Error('expected an invocation node'); - } - expect(validationResult.workflow.nodes[0].data.inputs.index).toBeUndefined(); - }); - - it('should remove malformed loop linkage edges instead of treating them as data edges', async () => { - const forNode = buildInvocationNode({ x: 0, y: 0 }, for_loop); - const returnNode = buildInvocationNode({ x: 0, y: 0 }, for_return); - const workflow: WorkflowV3 = { - name: '', - author: '', - description: '', - version: '', - contact: '', - tags: '', - notes: '', - exposedFields: [], - form: getDefaultForm(), - meta: { version: '4.0.0', category: 'user' }, - nodes: [forNode, returnNode], - edges: [ - { - id: 'malformed-loop-linkage', - type: 'loop_linkage', - source: forNode.id, - sourceHandle: 'item', - target: returnNode.id, - targetHandle: 'output', - }, - ], - }; - - const validationResult = await validateWorkflow({ - workflow, - templates: { for: for_loop, for_return }, - checkImageAccess: resolveTrue, - checkVideoAccess: resolveTrue, - checkBoardAccess: resolveTrue, - checkModelAccess: resolveTrue, - }); - - expect(validationResult.workflow.edges).toEqual([]); - expect(validationResult.warnings).toHaveLength(1); - }); - - it('should normalize a stale default edge between loop linkage handles', async () => { - const forNode = buildInvocationNode({ x: 0, y: 0 }, for_loop); - const returnNode = buildInvocationNode({ x: 0, y: 0 }, for_return); - const workflow: WorkflowV3 = { - name: '', - author: '', - description: '', - version: '', - contact: '', - tags: '', - notes: '', - exposedFields: [], - form: getDefaultForm(), - meta: { version: '4.0.0', category: 'user' }, - nodes: [forNode, returnNode], - edges: [ - { - id: 'stale-loop-linkage', - type: 'default', - source: forNode.id, - sourceHandle: 'loop_linkage', - target: returnNode.id, - targetHandle: 'loop_linkage', - }, - ], - }; - - const validationResult = await validateWorkflow({ - workflow, - templates: { for: for_loop, for_return }, - checkImageAccess: resolveTrue, - checkVideoAccess: resolveTrue, - checkBoardAccess: resolveTrue, - checkModelAccess: resolveTrue, - }); - - expect(validationResult.warnings).toEqual([]); - expect(validationResult.workflow.edges).toEqual([ - expect.objectContaining({ - type: 'loop_linkage', - sourceHandle: 'loop_linkage', - targetHandle: 'loop_linkage', - }), - ]); - }); - - it('should preserve a connector loop linkage alias regardless of edge order', async () => { - const forNode = buildInvocationNode({ x: 0, y: 0 }, for_loop); - const connectorNode = buildConnectorNode('connector-1'); - const returnNode = buildInvocationNode({ x: 0, y: 0 }, for_return); - const workflow: WorkflowV3 = { - name: '', - author: '', - description: '', - version: '', - contact: '', - tags: '', - notes: '', - exposedFields: [], - form: getDefaultForm(), - meta: { version: '4.0.0', category: 'user' }, - nodes: [forNode, connectorNode, returnNode], - edges: [ - { - id: 'linkage-output', - type: 'default', - source: connectorNode.id, - sourceHandle: CONNECTOR_OUTPUT_HANDLE, - target: returnNode.id, - targetHandle: 'loop_linkage', - }, - { - id: 'linkage-input', - type: 'default', - source: forNode.id, - sourceHandle: 'loop_linkage', - target: connectorNode.id, - targetHandle: CONNECTOR_INPUT_HANDLE, - }, - ], - }; - - const validationResult = await validateWorkflow({ - workflow, - templates: { for: for_loop, for_return }, - checkImageAccess: resolveTrue, - checkVideoAccess: resolveTrue, - checkBoardAccess: resolveTrue, - checkModelAccess: resolveTrue, - }); - - expect(validationResult.warnings).toEqual([]); - expect(validationResult.workflow.edges).toEqual(workflow.edges); - }); - - it('should remove a connector alias that duplicates a direct linkage regardless of edge order', async () => { - const forNode = buildInvocationNode({ x: 0, y: 0 }, for_loop); - const connectorNode = buildConnectorNode('connector-1'); - const returnNode = buildInvocationNode({ x: 0, y: 0 }, for_return); - const directLinkage = { - id: 'direct-linkage', - type: 'loop_linkage' as const, - source: forNode.id, - sourceHandle: 'loop_linkage', - target: returnNode.id, - targetHandle: 'loop_linkage', - }; - const workflow: WorkflowV3 = { - name: '', - author: '', - description: '', - version: '', - contact: '', - tags: '', - notes: '', - exposedFields: [], - form: getDefaultForm(), - meta: { version: '4.0.0', category: 'user' }, - nodes: [forNode, connectorNode, returnNode], - edges: [ - { - id: 'linkage-output', - type: 'default', - source: connectorNode.id, - sourceHandle: CONNECTOR_OUTPUT_HANDLE, - target: returnNode.id, - targetHandle: 'loop_linkage', - }, - directLinkage, - { - id: 'linkage-input', - type: 'default', - source: forNode.id, - sourceHandle: 'loop_linkage', - target: connectorNode.id, - targetHandle: CONNECTOR_INPUT_HANDLE, - }, - ], - }; - - const validationResult = await validateWorkflow({ - workflow, - templates: { for: for_loop, for_return }, - checkImageAccess: resolveTrue, - checkVideoAccess: resolveTrue, - checkBoardAccess: resolveTrue, - checkModelAccess: resolveTrue, - }); - - expect(validationResult.workflow.edges).toEqual([directLinkage]); - }); }); diff --git a/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.ts b/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.ts index 53fc025d43b..4775b2cfc4d 100644 --- a/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.ts +++ b/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.ts @@ -3,14 +3,7 @@ import { getSavedWorkflowDynamicFields } from 'features/nodes/components/flow/no import { addElement, getIsFormEmpty } from 'features/nodes/components/sidePanel/builder/form-manipulation'; import { CALL_SAVED_WORKFLOW_DYNAMIC_FIELD_PREFIX } from 'features/nodes/store/nodesSlice'; import type { Templates } from 'features/nodes/store/types'; -import { - CONNECTOR_OUTPUT_HANDLE, - resolveConnectorSource, - resolveLoopLinkagePath, -} from 'features/nodes/store/util/connectorTopology'; -import { getEdgeTypeFromHandles, isLoopLinkageEdge } from 'features/nodes/store/util/reactFlowUtil'; import { validateConnection } from 'features/nodes/store/util/validateConnection'; -import { LOOP_LINKAGE_FIELD } from 'features/nodes/types/constants'; import { nodeAcceptsExtraInputs } from 'features/nodes/types/extraInputs'; import { isBoardFieldInputInstance, @@ -20,7 +13,7 @@ import { isModelIdentifierFieldInputInstance, isVideoFieldInputInstance, } from 'features/nodes/types/field'; -import { getInvocationNodeInputTemplate, isConnectorNode } from 'features/nodes/types/invocation'; +import { getInvocationNodeInputTemplate } from 'features/nodes/types/invocation'; import type { WorkflowV3 } from 'features/nodes/types/workflow'; import { buildNodeFieldElement, @@ -157,12 +150,7 @@ export const validateWorkflow = async (args: ValidateWorkflowArgs): Promise id === edge.source); const targetNode = nodes.find(({ id }) => id === edge.target); @@ -218,19 +206,6 @@ export const validateWorkflow = async (args: ValidateWorkflowArgs): Promise(); - const linkedForIds = new Set(validEdges.filter(isLoopLinkageEdge).map((edge) => edge.source)); - const linkedReturnIds = new Set(validEdges.filter(isLoopLinkageEdge).map((edge) => edge.target)); - for (const edge of edges) { - if ( - edge.type !== 'default' || - edge.sourceHandle !== CONNECTOR_OUTPUT_HANDLE || - edge.targetHandle !== LOOP_LINKAGE_FIELD || - !isConnectorNode(nodes.find((node) => node.id === edge.source)) - ) { - continue; - } - - const resolvedSource = resolveConnectorSource(edge.source, nodes, edges); - if (!resolvedSource) { - continue; - } - - const path = resolveLoopLinkagePath(edge, nodes, edges); - if (!path) { - invalidConnectorLinkageEdgeIds.add(edge.id); - warnings.push({ - message: t('nodes.deletedInvalidEdge', { - source: `${edge.source}.${edge.sourceHandle}`, - target: `${edge.target}.${edge.targetHandle}`, - }), - issues: [t('nodes.forLoopLinkageInvalid')], - data: edge, - }); - continue; - } - if (linkedForIds.has(path.forNodeId) || linkedReturnIds.has(path.returnNodeId)) { - path.edgeIds.forEach((edgeId) => invalidConnectorLinkageEdgeIds.add(edgeId)); - warnings.push({ - message: t('nodes.deletedInvalidEdge', { - source: `${edge.source}.${edge.sourceHandle}`, - target: `${edge.target}.${edge.targetHandle}`, - }), - issues: [t('nodes.forLoopLinkageDuplicate')], - data: edge, - }); - continue; - } - linkedForIds.add(path.forNodeId); - linkedReturnIds.add(path.returnNodeId); - } - - _workflow.edges = validEdges.filter((edge) => !invalidConnectorLinkageEdgeIds.has(edge.id)); + _workflow.edges = validEdges; for (const node of nodes) { if (!isWorkflowInvocationNode(node)) { @@ -355,12 +283,6 @@ export const validateWorkflow = async (args: ValidateWorkflowArgs): Promise ({ default: { t: (key: string) => key, }, - t: (key: string) => key, })); -import type { AppStore } from 'app/store/store'; import type { ParamsState, RefImagesState } from 'features/controlLayers/store/types'; import type { DynamicPromptsState } from 'features/dynamicPrompts/store/dynamicPromptsSlice'; -import type { NodesState } from 'features/nodes/store/types'; -import { add, buildEdge, buildNode, for_loop, templates } from 'features/nodes/store/util/testUtils'; -import type { WorkflowSettingsState } from 'features/nodes/store/workflowSettingsSlice'; import type { AnyModelConfig, MainModelConfig } from 'services/api/types'; -import { - getReasonsWhyCannotEnqueueCanvasTab, - getReasonsWhyCannotEnqueueGenerateTab, - getReasonsWhyCannotEnqueueWorkflowsTab, -} from './readiness'; +import { getReasonsWhyCannotEnqueueCanvasTab, getReasonsWhyCannotEnqueueGenerateTab } from './readiness'; // --- Fixtures --- @@ -253,58 +244,6 @@ describe('FLUX.2 Klein readiness checks – generate tab', () => { }); }); -describe('workflow readiness checks', () => { - it('blocks Invoke when the workflow graph has an invalid For topology', async () => { - const forNode = buildNode(for_loop); - const bodyNode = buildNode(add); - const nodesState = { - _version: 1, - nodes: [forNode, bodyNode], - edges: [buildEdge(forNode.id, 'item', bodyNode.id, 'a')], - formFieldInitialValues: {}, - id: undefined, - name: '', - author: '', - description: '', - version: '', - contact: '', - tags: '', - notes: '', - exposedFields: [], - meta: { version: '4.0.0', category: 'user' }, - form: { - rootElementId: 'root', - elements: { - root: { - id: 'root', - type: 'container', - data: { layout: 'column', children: [] }, - }, - }, - }, - } as unknown as NodesState; - const rootState = { - nodes: { present: nodesState }, - gallery: { autoAddBoardId: 'none', selection: [] }, - }; - const store = { - dispatch: vi.fn(), - getState: () => rootState, - } as unknown as AppStore; - - const reasons = await getReasonsWhyCannotEnqueueWorkflowsTab({ - dispatch: store.dispatch, - nodesState, - workflowSettingsState: { shouldValidateGraph: true } as WorkflowSettingsState, - isConnected: true, - templates: { ...templates, for: for_loop }, - store, - }); - - expect(reasons).toContainEqual({ content: 'nodes.forLoopLinkageMissing' }); - }); -}); - describe('FLUX.2 Klein SDNQ pipeline readiness checks', () => { it('generate: no errors for a full SDNQ pipeline (self-contained) with no component sources', () => { const reasons = getReasonsWhyCannotEnqueueGenerateTab(buildGenerateTabArg({ model: flux2SdnqPipelineModel })); diff --git a/invokeai/frontend/web/src/features/queue/store/readiness.ts b/invokeai/frontend/web/src/features/queue/store/readiness.ts index d8a9226baf5..6de630b7471 100644 --- a/invokeai/frontend/web/src/features/queue/store/readiness.ts +++ b/invokeai/frontend/web/src/features/queue/store/readiness.ts @@ -40,7 +40,6 @@ import { getInvocationNodeErrors } from 'features/nodes/store/util/fieldValidato import type { WorkflowSettingsState } from 'features/nodes/store/workflowSettingsSlice'; import { selectWorkflowSettingsSlice } from 'features/nodes/store/workflowSettingsSlice'; import { isBatchNode, isExecutableNode, isInvocationNode } from 'features/nodes/types/invocation'; -import { buildNodesGraph } from 'features/nodes/util/graph/buildNodesGraph'; import { resolveBatchValue } from 'features/nodes/util/node/resolveBatchValue'; import type { UpscaleState } from 'features/parameters/store/upscaleSlice'; import { selectUpscaleSlice } from 'features/parameters/store/upscaleSlice'; @@ -198,7 +197,6 @@ const debouncedUpdateReasons = debounce(async (arg: UpdateReasonsArg) => { workflowSettingsState: workflowSettings, isConnected, templates, - store, }); $reasonsWhyCannotEnqueue.set(reasons); } else if (tab === 'upscaling') { @@ -616,30 +614,20 @@ export const getReasonsWhyCannotEnqueueGenerateTab = (arg: { return reasons; }; -export const getReasonsWhyCannotEnqueueWorkflowsTab = async (arg: { +const getReasonsWhyCannotEnqueueWorkflowsTab = async (arg: { dispatch: AppDispatch; nodesState: NodesState; workflowSettingsState: WorkflowSettingsState; isConnected: boolean; templates: Templates; - store: AppStore; }): Promise => { - const { dispatch, nodesState, workflowSettingsState, isConnected, templates, store } = arg; + const { dispatch, nodesState, workflowSettingsState, isConnected, templates } = arg; const reasons: Reason[] = []; if (!isConnected) { reasons.push(disconnectedReason(i18n.t)); } - // Queue submission always builds and validates this graph, regardless of the optional field - // validation setting. Run the same validation here so an invalid loop cannot leave Invoke - // enabled only to fail inside the click handler. - try { - buildNodesGraph(store.getState(), templates); - } catch (error) { - reasons.push({ content: error instanceof Error ? error.message : String(error) }); - } - if (workflowSettingsState.shouldValidateGraph) { const { nodes, edges } = nodesState; const invocationNodes = nodes.filter(isInvocationNode); diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 72196157145..308287d9ece 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -7425,171 +7425,6 @@ export type components = { */ type: "collect_output"; }; - /** - * Cartesian Product of Collections - * @description Emits every pair formed by one item from each collection, up to 100,000 pairs. - */ - CollectionCartesianInvocation: { - /** - * Id - * @description The id of this instance of an invocation. Must be unique among all instances of invocations. - */ - id: string; - /** - * Is Intermediate - * @description Whether or not this is an intermediate invocation. - * @default false - */ - is_intermediate?: boolean; - /** - * Use Cache - * @description Whether or not to use the cache - * @default true - */ - use_cache?: boolean; - /** - * First - * @description The first collection - * @default [] - */ - first?: unknown[]; - /** - * Second - * @description The second collection - * @default [] - */ - second?: unknown[]; - /** - * type - * @default collection_cartesian - * @constant - */ - type: "collection_cartesian"; - }; - /** CollectionCartesianInvocationOutput */ - CollectionCartesianInvocationOutput: { - /** - * Collection - * @description The Cartesian product pairs - */ - collection: unknown[]; - /** - * type - * @default collection_cartesian_output - * @constant - */ - type: "collection_cartesian_output"; - }; - /** - * Concatenate Collections - * @description Concatenates two collections in left-to-right order. - */ - CollectionConcatInvocation: { - /** - * Id - * @description The id of this instance of an invocation. Must be unique among all instances of invocations. - */ - id: string; - /** - * Is Intermediate - * @description Whether or not this is an intermediate invocation. - * @default false - */ - is_intermediate?: boolean; - /** - * Use Cache - * @description Whether or not to use the cache - * @default true - */ - use_cache?: boolean; - /** - * First - * @description The first collection - * @default [] - */ - first?: unknown[]; - /** - * Second - * @description The second collection - * @default [] - */ - second?: unknown[]; - /** - * type - * @default collection_concat - * @constant - */ - type: "collection_concat"; - }; - /** CollectionConcatInvocationOutput */ - CollectionConcatInvocationOutput: { - /** - * Collection - * @description The concatenated collection - */ - collection: unknown[]; - /** - * type - * @default collection_concat_output - * @constant - */ - type: "collection_concat_output"; - }; - /** - * Zip Collections - * @description Pairs items at matching positions from two equally sized collections. - */ - CollectionZipInvocation: { - /** - * Id - * @description The id of this instance of an invocation. Must be unique among all instances of invocations. - */ - id: string; - /** - * Is Intermediate - * @description Whether or not this is an intermediate invocation. - * @default false - */ - is_intermediate?: boolean; - /** - * Use Cache - * @description Whether or not to use the cache - * @default true - */ - use_cache?: boolean; - /** - * First - * @description The first collection - * @default [] - */ - first?: unknown[]; - /** - * Second - * @description The second collection - * @default [] - */ - second?: unknown[]; - /** - * type - * @default collection_zip - * @constant - */ - type: "collection_zip"; - }; - /** CollectionZipInvocationOutput */ - CollectionZipInvocationOutput: { - /** - * Collection - * @description The positional pairs - */ - collection: unknown[]; - /** - * type - * @default collection_zip_output - * @constant - */ - type: "collection_zip_output"; - }; /** * ColorCollectionOutput * @description Base class for nodes that output a collection of colors @@ -10774,13 +10609,6 @@ export type components = { }; /** Edge */ Edge: { - /** - * Type - * @description The kind of relationship represented by this edge - * @default default - * @enum {string} - */ - type?: "default" | "loop_linkage"; /** @description The connection for the edge's from node and field */ source: components["schemas"]["EdgeConnection"]; /** @description The connection for the edge's to node and field */ @@ -14724,164 +14552,6 @@ export type components = { * @enum {string} */ FluxVariantType: "schnell" | "dev" | "dev_fill"; - /** ForInvocation */ - ForInvocation: { - /** - * Id - * @description The id of this instance of an invocation. Must be unique among all instances of invocations. - */ - id: string; - /** - * Is Intermediate - * @description Whether or not this is an intermediate invocation. - * @default false - */ - is_intermediate?: boolean; - /** - * Use Cache - * @description Whether or not to use the cache - * @default true - */ - use_cache?: boolean; - /** - * Collection - * @description The list of items to iterate over - * @default [] - */ - collection?: unknown[]; - /** - * @description Optional initial loop state - * @default null - */ - state?: components["schemas"]["LoopState"] | null; - /** - * Index - * @description The internal iteration index for a prepared For execution node - * @default -1 - */ - index?: number; - /** - * type - * @default for - * @constant - */ - type: "for"; - }; - /** ForInvocationOutput */ - ForInvocationOutput: { - /** - * Loop Linkage - * @description The loop linkage to the matching ForReturn - */ - loop_linkage: unknown; - /** - * Collection Item - * @description The item for the current loop iteration, or None when the collection is empty - * @default null - */ - item: unknown | null; - /** - * Index - * @description The index for the current loop iteration - */ - index: number; - /** - * Total - * @description The total number of items in the loop collection - */ - total: number; - /** - * State - * @description The state for the current loop iteration - */ - state: components["schemas"]["LoopState"]; - /** - * Output Collection - * @description The collected loop body outputs - */ - output_collection: unknown[]; - /** - * Final State - * @description The final loop state - */ - final_state: components["schemas"]["LoopState"]; - /** - * type - * @default for_output - * @constant - */ - type: "for_output"; - }; - /** ForReturnInvocation */ - ForReturnInvocation: { - /** - * Id - * @description The id of this instance of an invocation. Must be unique among all instances of invocations. - */ - id: string; - /** - * Is Intermediate - * @description Whether or not this is an intermediate invocation. - * @default false - */ - is_intermediate?: boolean; - /** - * Use Cache - * @description Whether or not to use the cache - * @default true - */ - use_cache?: boolean; - /** - * Loop Linkage - * @description The loop linkage from the matching For - * @default null - */ - loop_linkage?: unknown | null; - /** - * Output - * @description The output item to append to the loop output collection - * @default null - */ - output?: unknown | null; - /** - * @description The state to pass to the next loop iteration - * @default null - */ - state?: components["schemas"]["LoopState"] | null; - /** - * Continue Condition - * @description Whether to schedule the next loop iteration; false finalizes the loop - * @default true - */ - continue_condition?: boolean | null; - /** - * type - * @default for_return - * @constant - */ - type: "for_return"; - }; - /** ForReturnInvocationOutput */ - ForReturnInvocationOutput: { - /** - * Output - * @description The output item to append to the loop output collection - * @default null - */ - output: unknown | null; - /** - * State - * @description The state to pass to the next loop iteration - * @default null - */ - state: components["schemas"]["LoopState"] | null; - /** - * type - * @default for_return_output - * @constant - */ - type: "for_return_output"; - }; /** FoundModel */ FoundModel: { /** @@ -15589,7 +15259,7 @@ export type components = { * @description The nodes in this graph */ nodes?: { - [key: string]: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CollectionCartesianInvocation"] | components["schemas"]["CollectionConcatInvocation"] | components["schemas"]["CollectionZipInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["ForInvocation"] | components["schemas"]["ForReturnInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3ImageReferenceInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3ReferenceConditioningInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["MiniMaxH3VideoReferenceInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StateEmptyInvocation"] | components["schemas"]["StateGetInvocation"] | components["schemas"]["StateMergeInvocation"] | components["schemas"]["StateSetInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; + [key: string]: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3ImageReferenceInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3ReferenceConditioningInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["MiniMaxH3VideoReferenceInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; }; /** * Edges @@ -15626,7 +15296,7 @@ export type components = { * @description The results of node executions */ results: { - [key: string]: components["schemas"]["AnimaConditioningOutput"] | components["schemas"]["AnimaLLLiteOutput"] | components["schemas"]["AnimaLoRALoaderOutput"] | components["schemas"]["AnimaModelLoaderOutput"] | components["schemas"]["BooleanCollectionOutput"] | components["schemas"]["BooleanOutput"] | components["schemas"]["BoundingBoxCollectionOutput"] | components["schemas"]["BoundingBoxOutput"] | components["schemas"]["CLIPOutput"] | components["schemas"]["CLIPSkipInvocationOutput"] | components["schemas"]["CalculateImageTilesOutput"] | components["schemas"]["CogView4ConditioningOutput"] | components["schemas"]["CogView4ModelLoaderOutput"] | components["schemas"]["CollectInvocationOutput"] | components["schemas"]["CollectionCartesianInvocationOutput"] | components["schemas"]["CollectionConcatInvocationOutput"] | components["schemas"]["CollectionZipInvocationOutput"] | components["schemas"]["ColorCollectionOutput"] | components["schemas"]["ColorOutput"] | components["schemas"]["ConditioningCollectionOutput"] | components["schemas"]["ConditioningOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["DenoiseMaskOutput"] | components["schemas"]["ErnieImageConditioningOutput"] | components["schemas"]["ErnieImageModelLoaderOutput"] | components["schemas"]["ExtractVideoRangeOutput"] | components["schemas"]["FaceMaskOutput"] | components["schemas"]["FaceOffOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["FloatGeneratorOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["Flux2DevLoRALoaderOutput"] | components["schemas"]["Flux2DevModelLoaderOutput"] | components["schemas"]["Flux2KleinLoRALoaderOutput"] | components["schemas"]["Flux2KleinModelLoaderOutput"] | components["schemas"]["FluxConditioningCollectionOutput"] | components["schemas"]["FluxConditioningOutput"] | components["schemas"]["FluxControlLoRALoaderOutput"] | components["schemas"]["FluxControlNetOutput"] | components["schemas"]["FluxFillOutput"] | components["schemas"]["FluxKontextOutput"] | components["schemas"]["FluxLoRALoaderOutput"] | components["schemas"]["FluxModelLoaderOutput"] | components["schemas"]["FluxReduxOutput"] | components["schemas"]["ForInvocationOutput"] | components["schemas"]["ForReturnInvocationOutput"] | components["schemas"]["Gemma2EncoderOutput"] | components["schemas"]["GradientMaskOutput"] | components["schemas"]["IPAdapterOutput"] | components["schemas"]["IdealSizeOutput"] | components["schemas"]["Ideogram4ConditioningOutput"] | components["schemas"]["Ideogram4ModelLoaderOutput"] | components["schemas"]["IfInvocationOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ImageGeneratorOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["ImagePanelCoordinateOutput"] | components["schemas"]["IntegerCollectionOutput"] | components["schemas"]["IntegerGeneratorOutput"] | components["schemas"]["IntegerOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["Krea2ConditioningOutput"] | components["schemas"]["Krea2LoRALoaderOutput"] | components["schemas"]["Krea2ModelLoaderOutput"] | components["schemas"]["LatentsCollectionOutput"] | components["schemas"]["LatentsMetaOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["LoRALoaderOutput"] | components["schemas"]["LoRASelectorOutput"] | components["schemas"]["LoopStateOutput"] | components["schemas"]["LoopStateValueOutput"] | components["schemas"]["MDControlListOutput"] | components["schemas"]["MDIPAdapterListOutput"] | components["schemas"]["MDT2IAdapterListOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["MetadataItemOutput"] | components["schemas"]["MetadataOutput"] | components["schemas"]["MetadataToLorasCollectionOutput"] | components["schemas"]["MetadataToModelOutput"] | components["schemas"]["MetadataToSDXLModelOutput"] | components["schemas"]["MiniMaxH3ConditioningOutput"] | components["schemas"]["MiniMaxH3DenoiseOutput"] | components["schemas"]["MiniMaxH3FrameConditioningOutput"] | components["schemas"]["MiniMaxH3LoRACollectionLoaderOutput"] | components["schemas"]["MiniMaxH3LoRALoaderOutput"] | components["schemas"]["MiniMaxH3ModelLoaderOutput"] | components["schemas"]["MiniMaxH3ReferenceConditioningOutput"] | components["schemas"]["MiniMaxH3ReferenceMediaOutput"] | components["schemas"]["ModelIdentifierOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PBRMapsOutput"] | components["schemas"]["PairTileImageOutput"] | components["schemas"]["PiDDecoderOutput"] | components["schemas"]["PromptTemplateOutput"] | components["schemas"]["QwenImageConditioningOutput"] | components["schemas"]["QwenImageLoRALoaderOutput"] | components["schemas"]["QwenImageModelLoaderOutput"] | components["schemas"]["SD3ConditioningOutput"] | components["schemas"]["SDXLLoRALoaderOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["SchedulerOutput"] | components["schemas"]["Sd3ModelLoaderOutput"] | components["schemas"]["SeamlessModeOutput"] | components["schemas"]["String2Output"] | components["schemas"]["StringCollectionOutput"] | components["schemas"]["StringGeneratorOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["StringPosNegOutput"] | components["schemas"]["T2IAdapterOutput"] | components["schemas"]["TileToPropertiesOutput"] | components["schemas"]["UNetOutput"] | components["schemas"]["VAEOutput"] | components["schemas"]["VideoOutput"] | components["schemas"]["WanConditioningOutput"] | components["schemas"]["WanLoRALoaderOutput"] | components["schemas"]["WanModelLoaderOutput"] | components["schemas"]["WanRefImageOutput"] | components["schemas"]["WorkflowReturnGetOutput"] | components["schemas"]["WorkflowReturnOutput"] | components["schemas"]["WorkflowReturnValueOutput"] | components["schemas"]["ZImageConditioningOutput"] | components["schemas"]["ZImageControlOutput"] | components["schemas"]["ZImageLoRALoaderOutput"] | components["schemas"]["ZImageModelLoaderOutput"]; + [key: string]: components["schemas"]["AnimaConditioningOutput"] | components["schemas"]["AnimaLLLiteOutput"] | components["schemas"]["AnimaLoRALoaderOutput"] | components["schemas"]["AnimaModelLoaderOutput"] | components["schemas"]["BooleanCollectionOutput"] | components["schemas"]["BooleanOutput"] | components["schemas"]["BoundingBoxCollectionOutput"] | components["schemas"]["BoundingBoxOutput"] | components["schemas"]["CLIPOutput"] | components["schemas"]["CLIPSkipInvocationOutput"] | components["schemas"]["CalculateImageTilesOutput"] | components["schemas"]["CogView4ConditioningOutput"] | components["schemas"]["CogView4ModelLoaderOutput"] | components["schemas"]["CollectInvocationOutput"] | components["schemas"]["ColorCollectionOutput"] | components["schemas"]["ColorOutput"] | components["schemas"]["ConditioningCollectionOutput"] | components["schemas"]["ConditioningOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["DenoiseMaskOutput"] | components["schemas"]["ErnieImageConditioningOutput"] | components["schemas"]["ErnieImageModelLoaderOutput"] | components["schemas"]["ExtractVideoRangeOutput"] | components["schemas"]["FaceMaskOutput"] | components["schemas"]["FaceOffOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["FloatGeneratorOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["Flux2DevLoRALoaderOutput"] | components["schemas"]["Flux2DevModelLoaderOutput"] | components["schemas"]["Flux2KleinLoRALoaderOutput"] | components["schemas"]["Flux2KleinModelLoaderOutput"] | components["schemas"]["FluxConditioningCollectionOutput"] | components["schemas"]["FluxConditioningOutput"] | components["schemas"]["FluxControlLoRALoaderOutput"] | components["schemas"]["FluxControlNetOutput"] | components["schemas"]["FluxFillOutput"] | components["schemas"]["FluxKontextOutput"] | components["schemas"]["FluxLoRALoaderOutput"] | components["schemas"]["FluxModelLoaderOutput"] | components["schemas"]["FluxReduxOutput"] | components["schemas"]["Gemma2EncoderOutput"] | components["schemas"]["GradientMaskOutput"] | components["schemas"]["IPAdapterOutput"] | components["schemas"]["IdealSizeOutput"] | components["schemas"]["Ideogram4ConditioningOutput"] | components["schemas"]["Ideogram4ModelLoaderOutput"] | components["schemas"]["IfInvocationOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ImageGeneratorOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["ImagePanelCoordinateOutput"] | components["schemas"]["IntegerCollectionOutput"] | components["schemas"]["IntegerGeneratorOutput"] | components["schemas"]["IntegerOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["Krea2ConditioningOutput"] | components["schemas"]["Krea2LoRALoaderOutput"] | components["schemas"]["Krea2ModelLoaderOutput"] | components["schemas"]["LatentsCollectionOutput"] | components["schemas"]["LatentsMetaOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["LoRALoaderOutput"] | components["schemas"]["LoRASelectorOutput"] | components["schemas"]["MDControlListOutput"] | components["schemas"]["MDIPAdapterListOutput"] | components["schemas"]["MDT2IAdapterListOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["MetadataItemOutput"] | components["schemas"]["MetadataOutput"] | components["schemas"]["MetadataToLorasCollectionOutput"] | components["schemas"]["MetadataToModelOutput"] | components["schemas"]["MetadataToSDXLModelOutput"] | components["schemas"]["MiniMaxH3ConditioningOutput"] | components["schemas"]["MiniMaxH3DenoiseOutput"] | components["schemas"]["MiniMaxH3FrameConditioningOutput"] | components["schemas"]["MiniMaxH3LoRACollectionLoaderOutput"] | components["schemas"]["MiniMaxH3LoRALoaderOutput"] | components["schemas"]["MiniMaxH3ModelLoaderOutput"] | components["schemas"]["MiniMaxH3ReferenceConditioningOutput"] | components["schemas"]["MiniMaxH3ReferenceMediaOutput"] | components["schemas"]["ModelIdentifierOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PBRMapsOutput"] | components["schemas"]["PairTileImageOutput"] | components["schemas"]["PiDDecoderOutput"] | components["schemas"]["PromptTemplateOutput"] | components["schemas"]["QwenImageConditioningOutput"] | components["schemas"]["QwenImageLoRALoaderOutput"] | components["schemas"]["QwenImageModelLoaderOutput"] | components["schemas"]["SD3ConditioningOutput"] | components["schemas"]["SDXLLoRALoaderOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["SchedulerOutput"] | components["schemas"]["Sd3ModelLoaderOutput"] | components["schemas"]["SeamlessModeOutput"] | components["schemas"]["String2Output"] | components["schemas"]["StringCollectionOutput"] | components["schemas"]["StringGeneratorOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["StringPosNegOutput"] | components["schemas"]["T2IAdapterOutput"] | components["schemas"]["TileToPropertiesOutput"] | components["schemas"]["UNetOutput"] | components["schemas"]["VAEOutput"] | components["schemas"]["VideoOutput"] | components["schemas"]["WanConditioningOutput"] | components["schemas"]["WanLoRALoaderOutput"] | components["schemas"]["WanModelLoaderOutput"] | components["schemas"]["WanRefImageOutput"] | components["schemas"]["WorkflowReturnGetOutput"] | components["schemas"]["WorkflowReturnOutput"] | components["schemas"]["WorkflowReturnValueOutput"] | components["schemas"]["ZImageConditioningOutput"] | components["schemas"]["ZImageControlOutput"] | components["schemas"]["ZImageLoRALoaderOutput"] | components["schemas"]["ZImageModelLoaderOutput"]; }; /** * Errors @@ -15673,19 +15343,6 @@ export type components = { source_prepared_mapping: { [key: string]: string[]; }; - /** - * Finalized Loop Nodes - * @description Legacy set of top-level loop source nodes whose final outputs have been materialized - */ - finalized_loop_nodes: string[]; - /** - * Finalized Loop Contexts - * @description The finalized loop source and parent iteration contexts - */ - finalized_loop_contexts?: [ - string, - number[] - ][]; /** * Prepared Iteration Paths * @description The iteration coordinates of each prepared execution node @@ -19790,7 +19447,7 @@ export type components = { * Invocation * @description The ID of the invocation */ - invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CollectionCartesianInvocation"] | components["schemas"]["CollectionConcatInvocation"] | components["schemas"]["CollectionZipInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["ForInvocation"] | components["schemas"]["ForReturnInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3ImageReferenceInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3ReferenceConditioningInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["MiniMaxH3VideoReferenceInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StateEmptyInvocation"] | components["schemas"]["StateGetInvocation"] | components["schemas"]["StateMergeInvocation"] | components["schemas"]["StateSetInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; + invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3ImageReferenceInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3ReferenceConditioningInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["MiniMaxH3VideoReferenceInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; /** * Invocation Source Id * @description The ID of the prepared invocation's source node @@ -19800,7 +19457,7 @@ export type components = { * Result * @description The result of the invocation */ - result: components["schemas"]["AnimaConditioningOutput"] | components["schemas"]["AnimaLLLiteOutput"] | components["schemas"]["AnimaLoRALoaderOutput"] | components["schemas"]["AnimaModelLoaderOutput"] | components["schemas"]["BooleanCollectionOutput"] | components["schemas"]["BooleanOutput"] | components["schemas"]["BoundingBoxCollectionOutput"] | components["schemas"]["BoundingBoxOutput"] | components["schemas"]["CLIPOutput"] | components["schemas"]["CLIPSkipInvocationOutput"] | components["schemas"]["CalculateImageTilesOutput"] | components["schemas"]["CogView4ConditioningOutput"] | components["schemas"]["CogView4ModelLoaderOutput"] | components["schemas"]["CollectInvocationOutput"] | components["schemas"]["CollectionCartesianInvocationOutput"] | components["schemas"]["CollectionConcatInvocationOutput"] | components["schemas"]["CollectionZipInvocationOutput"] | components["schemas"]["ColorCollectionOutput"] | components["schemas"]["ColorOutput"] | components["schemas"]["ConditioningCollectionOutput"] | components["schemas"]["ConditioningOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["DenoiseMaskOutput"] | components["schemas"]["ErnieImageConditioningOutput"] | components["schemas"]["ErnieImageModelLoaderOutput"] | components["schemas"]["ExtractVideoRangeOutput"] | components["schemas"]["FaceMaskOutput"] | components["schemas"]["FaceOffOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["FloatGeneratorOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["Flux2DevLoRALoaderOutput"] | components["schemas"]["Flux2DevModelLoaderOutput"] | components["schemas"]["Flux2KleinLoRALoaderOutput"] | components["schemas"]["Flux2KleinModelLoaderOutput"] | components["schemas"]["FluxConditioningCollectionOutput"] | components["schemas"]["FluxConditioningOutput"] | components["schemas"]["FluxControlLoRALoaderOutput"] | components["schemas"]["FluxControlNetOutput"] | components["schemas"]["FluxFillOutput"] | components["schemas"]["FluxKontextOutput"] | components["schemas"]["FluxLoRALoaderOutput"] | components["schemas"]["FluxModelLoaderOutput"] | components["schemas"]["FluxReduxOutput"] | components["schemas"]["ForInvocationOutput"] | components["schemas"]["ForReturnInvocationOutput"] | components["schemas"]["Gemma2EncoderOutput"] | components["schemas"]["GradientMaskOutput"] | components["schemas"]["IPAdapterOutput"] | components["schemas"]["IdealSizeOutput"] | components["schemas"]["Ideogram4ConditioningOutput"] | components["schemas"]["Ideogram4ModelLoaderOutput"] | components["schemas"]["IfInvocationOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ImageGeneratorOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["ImagePanelCoordinateOutput"] | components["schemas"]["IntegerCollectionOutput"] | components["schemas"]["IntegerGeneratorOutput"] | components["schemas"]["IntegerOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["Krea2ConditioningOutput"] | components["schemas"]["Krea2LoRALoaderOutput"] | components["schemas"]["Krea2ModelLoaderOutput"] | components["schemas"]["LatentsCollectionOutput"] | components["schemas"]["LatentsMetaOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["LoRALoaderOutput"] | components["schemas"]["LoRASelectorOutput"] | components["schemas"]["LoopStateOutput"] | components["schemas"]["LoopStateValueOutput"] | components["schemas"]["MDControlListOutput"] | components["schemas"]["MDIPAdapterListOutput"] | components["schemas"]["MDT2IAdapterListOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["MetadataItemOutput"] | components["schemas"]["MetadataOutput"] | components["schemas"]["MetadataToLorasCollectionOutput"] | components["schemas"]["MetadataToModelOutput"] | components["schemas"]["MetadataToSDXLModelOutput"] | components["schemas"]["MiniMaxH3ConditioningOutput"] | components["schemas"]["MiniMaxH3DenoiseOutput"] | components["schemas"]["MiniMaxH3FrameConditioningOutput"] | components["schemas"]["MiniMaxH3LoRACollectionLoaderOutput"] | components["schemas"]["MiniMaxH3LoRALoaderOutput"] | components["schemas"]["MiniMaxH3ModelLoaderOutput"] | components["schemas"]["MiniMaxH3ReferenceConditioningOutput"] | components["schemas"]["MiniMaxH3ReferenceMediaOutput"] | components["schemas"]["ModelIdentifierOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PBRMapsOutput"] | components["schemas"]["PairTileImageOutput"] | components["schemas"]["PiDDecoderOutput"] | components["schemas"]["PromptTemplateOutput"] | components["schemas"]["QwenImageConditioningOutput"] | components["schemas"]["QwenImageLoRALoaderOutput"] | components["schemas"]["QwenImageModelLoaderOutput"] | components["schemas"]["SD3ConditioningOutput"] | components["schemas"]["SDXLLoRALoaderOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["SchedulerOutput"] | components["schemas"]["Sd3ModelLoaderOutput"] | components["schemas"]["SeamlessModeOutput"] | components["schemas"]["String2Output"] | components["schemas"]["StringCollectionOutput"] | components["schemas"]["StringGeneratorOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["StringPosNegOutput"] | components["schemas"]["T2IAdapterOutput"] | components["schemas"]["TileToPropertiesOutput"] | components["schemas"]["UNetOutput"] | components["schemas"]["VAEOutput"] | components["schemas"]["VideoOutput"] | components["schemas"]["WanConditioningOutput"] | components["schemas"]["WanLoRALoaderOutput"] | components["schemas"]["WanModelLoaderOutput"] | components["schemas"]["WanRefImageOutput"] | components["schemas"]["WorkflowReturnGetOutput"] | components["schemas"]["WorkflowReturnOutput"] | components["schemas"]["WorkflowReturnValueOutput"] | components["schemas"]["ZImageConditioningOutput"] | components["schemas"]["ZImageControlOutput"] | components["schemas"]["ZImageLoRALoaderOutput"] | components["schemas"]["ZImageModelLoaderOutput"]; + result: components["schemas"]["AnimaConditioningOutput"] | components["schemas"]["AnimaLLLiteOutput"] | components["schemas"]["AnimaLoRALoaderOutput"] | components["schemas"]["AnimaModelLoaderOutput"] | components["schemas"]["BooleanCollectionOutput"] | components["schemas"]["BooleanOutput"] | components["schemas"]["BoundingBoxCollectionOutput"] | components["schemas"]["BoundingBoxOutput"] | components["schemas"]["CLIPOutput"] | components["schemas"]["CLIPSkipInvocationOutput"] | components["schemas"]["CalculateImageTilesOutput"] | components["schemas"]["CogView4ConditioningOutput"] | components["schemas"]["CogView4ModelLoaderOutput"] | components["schemas"]["CollectInvocationOutput"] | components["schemas"]["ColorCollectionOutput"] | components["schemas"]["ColorOutput"] | components["schemas"]["ConditioningCollectionOutput"] | components["schemas"]["ConditioningOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["DenoiseMaskOutput"] | components["schemas"]["ErnieImageConditioningOutput"] | components["schemas"]["ErnieImageModelLoaderOutput"] | components["schemas"]["ExtractVideoRangeOutput"] | components["schemas"]["FaceMaskOutput"] | components["schemas"]["FaceOffOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["FloatGeneratorOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["Flux2DevLoRALoaderOutput"] | components["schemas"]["Flux2DevModelLoaderOutput"] | components["schemas"]["Flux2KleinLoRALoaderOutput"] | components["schemas"]["Flux2KleinModelLoaderOutput"] | components["schemas"]["FluxConditioningCollectionOutput"] | components["schemas"]["FluxConditioningOutput"] | components["schemas"]["FluxControlLoRALoaderOutput"] | components["schemas"]["FluxControlNetOutput"] | components["schemas"]["FluxFillOutput"] | components["schemas"]["FluxKontextOutput"] | components["schemas"]["FluxLoRALoaderOutput"] | components["schemas"]["FluxModelLoaderOutput"] | components["schemas"]["FluxReduxOutput"] | components["schemas"]["Gemma2EncoderOutput"] | components["schemas"]["GradientMaskOutput"] | components["schemas"]["IPAdapterOutput"] | components["schemas"]["IdealSizeOutput"] | components["schemas"]["Ideogram4ConditioningOutput"] | components["schemas"]["Ideogram4ModelLoaderOutput"] | components["schemas"]["IfInvocationOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ImageGeneratorOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["ImagePanelCoordinateOutput"] | components["schemas"]["IntegerCollectionOutput"] | components["schemas"]["IntegerGeneratorOutput"] | components["schemas"]["IntegerOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["Krea2ConditioningOutput"] | components["schemas"]["Krea2LoRALoaderOutput"] | components["schemas"]["Krea2ModelLoaderOutput"] | components["schemas"]["LatentsCollectionOutput"] | components["schemas"]["LatentsMetaOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["LoRALoaderOutput"] | components["schemas"]["LoRASelectorOutput"] | components["schemas"]["MDControlListOutput"] | components["schemas"]["MDIPAdapterListOutput"] | components["schemas"]["MDT2IAdapterListOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["MetadataItemOutput"] | components["schemas"]["MetadataOutput"] | components["schemas"]["MetadataToLorasCollectionOutput"] | components["schemas"]["MetadataToModelOutput"] | components["schemas"]["MetadataToSDXLModelOutput"] | components["schemas"]["MiniMaxH3ConditioningOutput"] | components["schemas"]["MiniMaxH3DenoiseOutput"] | components["schemas"]["MiniMaxH3FrameConditioningOutput"] | components["schemas"]["MiniMaxH3LoRACollectionLoaderOutput"] | components["schemas"]["MiniMaxH3LoRALoaderOutput"] | components["schemas"]["MiniMaxH3ModelLoaderOutput"] | components["schemas"]["MiniMaxH3ReferenceConditioningOutput"] | components["schemas"]["MiniMaxH3ReferenceMediaOutput"] | components["schemas"]["ModelIdentifierOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PBRMapsOutput"] | components["schemas"]["PairTileImageOutput"] | components["schemas"]["PiDDecoderOutput"] | components["schemas"]["PromptTemplateOutput"] | components["schemas"]["QwenImageConditioningOutput"] | components["schemas"]["QwenImageLoRALoaderOutput"] | components["schemas"]["QwenImageModelLoaderOutput"] | components["schemas"]["SD3ConditioningOutput"] | components["schemas"]["SDXLLoRALoaderOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["SchedulerOutput"] | components["schemas"]["Sd3ModelLoaderOutput"] | components["schemas"]["SeamlessModeOutput"] | components["schemas"]["String2Output"] | components["schemas"]["StringCollectionOutput"] | components["schemas"]["StringGeneratorOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["StringPosNegOutput"] | components["schemas"]["T2IAdapterOutput"] | components["schemas"]["TileToPropertiesOutput"] | components["schemas"]["UNetOutput"] | components["schemas"]["VAEOutput"] | components["schemas"]["VideoOutput"] | components["schemas"]["WanConditioningOutput"] | components["schemas"]["WanLoRALoaderOutput"] | components["schemas"]["WanModelLoaderOutput"] | components["schemas"]["WanRefImageOutput"] | components["schemas"]["WorkflowReturnGetOutput"] | components["schemas"]["WorkflowReturnOutput"] | components["schemas"]["WorkflowReturnValueOutput"] | components["schemas"]["ZImageConditioningOutput"] | components["schemas"]["ZImageControlOutput"] | components["schemas"]["ZImageLoRALoaderOutput"] | components["schemas"]["ZImageModelLoaderOutput"]; }; /** * InvocationErrorEvent @@ -19854,7 +19511,7 @@ export type components = { * Invocation * @description The ID of the invocation */ - invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CollectionCartesianInvocation"] | components["schemas"]["CollectionConcatInvocation"] | components["schemas"]["CollectionZipInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["ForInvocation"] | components["schemas"]["ForReturnInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3ImageReferenceInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3ReferenceConditioningInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["MiniMaxH3VideoReferenceInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StateEmptyInvocation"] | components["schemas"]["StateGetInvocation"] | components["schemas"]["StateMergeInvocation"] | components["schemas"]["StateSetInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; + invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3ImageReferenceInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3ReferenceConditioningInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["MiniMaxH3VideoReferenceInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; /** * Invocation Source Id * @description The ID of the prepared invocation's source node @@ -19910,9 +19567,6 @@ export type components = { cogview4_model_loader: components["schemas"]["CogView4ModelLoaderOutput"]; cogview4_text_encoder: components["schemas"]["CogView4ConditioningOutput"]; collect: components["schemas"]["CollectInvocationOutput"]; - collection_cartesian: components["schemas"]["CollectionCartesianInvocationOutput"]; - collection_concat: components["schemas"]["CollectionConcatInvocationOutput"]; - collection_zip: components["schemas"]["CollectionZipInvocationOutput"]; color: components["schemas"]["ColorOutput"]; color_correct: components["schemas"]["ImageOutput"]; color_map: components["schemas"]["ImageOutput"]; @@ -19980,8 +19634,6 @@ export type components = { flux_text_encoder: components["schemas"]["FluxConditioningOutput"]; flux_vae_decode: components["schemas"]["ImageOutput"]; flux_vae_encode: components["schemas"]["LatentsOutput"]; - for: components["schemas"]["ForInvocationOutput"]; - for_return: components["schemas"]["ForReturnInvocationOutput"]; freeu: components["schemas"]["UNetOutput"]; gemini_image_generation: components["schemas"]["ImageCollectionOutput"]; gemma2_encoder_loader: components["schemas"]["Gemma2EncoderOutput"]; @@ -20153,10 +19805,6 @@ export type components = { show_image: components["schemas"]["ImageOutput"]; spandrel_image_to_image: components["schemas"]["ImageOutput"]; spandrel_image_to_image_autoscale: components["schemas"]["ImageOutput"]; - state_empty: components["schemas"]["LoopStateOutput"]; - state_get: components["schemas"]["LoopStateValueOutput"]; - state_merge: components["schemas"]["LoopStateOutput"]; - state_set: components["schemas"]["LoopStateOutput"]; string: components["schemas"]["StringOutput"]; string_batch: components["schemas"]["StringOutput"]; string_collection: components["schemas"]["StringCollectionOutput"]; @@ -20259,7 +19907,7 @@ export type components = { * Invocation * @description The ID of the invocation */ - invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CollectionCartesianInvocation"] | components["schemas"]["CollectionConcatInvocation"] | components["schemas"]["CollectionZipInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["ForInvocation"] | components["schemas"]["ForReturnInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3ImageReferenceInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3ReferenceConditioningInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["MiniMaxH3VideoReferenceInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StateEmptyInvocation"] | components["schemas"]["StateGetInvocation"] | components["schemas"]["StateMergeInvocation"] | components["schemas"]["StateSetInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; + invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3ImageReferenceInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3ReferenceConditioningInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["MiniMaxH3VideoReferenceInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; /** * Invocation Source Id * @description The ID of the prepared invocation's source node @@ -20340,7 +19988,7 @@ export type components = { * Invocation * @description The ID of the invocation */ - invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CollectionCartesianInvocation"] | components["schemas"]["CollectionConcatInvocation"] | components["schemas"]["CollectionZipInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["ForInvocation"] | components["schemas"]["ForReturnInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3ImageReferenceInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3ReferenceConditioningInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["MiniMaxH3VideoReferenceInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StateEmptyInvocation"] | components["schemas"]["StateGetInvocation"] | components["schemas"]["StateMergeInvocation"] | components["schemas"]["StateSetInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; + invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3ImageReferenceInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3ReferenceConditioningInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["MiniMaxH3VideoReferenceInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; /** * Invocation Source Id * @description The ID of the prepared invocation's source node @@ -24540,39 +24188,6 @@ export type components = { */ success: boolean; }; - /** LoopState */ - LoopState: { - /** Values */ - values?: { - [key: string]: unknown; - }; - }; - /** LoopStateOutput */ - LoopStateOutput: { - /** @description The loop state */ - state: components["schemas"]["LoopState"]; - /** - * type - * @default loop_state_output - * @constant - */ - type: "loop_state_output"; - }; - /** LoopStateValueOutput */ - LoopStateValueOutput: { - /** - * Value - * @description The value read from the loop state, or None when the key is missing - * @default null - */ - value: unknown | null; - /** - * type - * @default loop_state_value_output - * @constant - */ - type: "loop_state_value_output"; - }; /** LoraModelDefaultSettings */ LoraModelDefaultSettings: { /** @@ -32795,17 +32410,7 @@ export type components = { ui_order: number | null; /** @default null */ ui_type: components["schemas"]["UIType"] | null; - /** @default null */ - output_scope: components["schemas"]["OutputScope"] | null; }; - /** - * OutputScope - * @description The execution scope for an output field. - * - `Iteration`: The field emits values for a loop body's current iteration. - * - `Final`: The field emits values after a loop boundary completes. - * @enum {string} - */ - OutputScope: "iteration" | "final"; /** * PBR Maps * @description Generate Normal, Displacement and Roughness Map from a given image @@ -38800,169 +38405,6 @@ export type components = { */ previous_names?: string[]; }; - /** - * Empty Loop State - * @description Creates an empty loop state. - */ - StateEmptyInvocation: { - /** - * Id - * @description The id of this instance of an invocation. Must be unique among all instances of invocations. - */ - id: string; - /** - * Is Intermediate - * @description Whether or not this is an intermediate invocation. - * @default false - */ - is_intermediate?: boolean; - /** - * Use Cache - * @description Whether or not to use the cache - * @default true - */ - use_cache?: boolean; - /** - * type - * @default state_empty - * @constant - */ - type: "state_empty"; - }; - /** - * Get Loop State Value - * @description Reads a value from loop state. - */ - StateGetInvocation: { - /** - * Id - * @description The id of this instance of an invocation. Must be unique among all instances of invocations. - */ - id: string; - /** - * Is Intermediate - * @description Whether or not this is an intermediate invocation. - * @default false - */ - is_intermediate?: boolean; - /** - * Use Cache - * @description Whether or not to use the cache - * @default true - */ - use_cache?: boolean; - /** - * @description The loop state to read - * @default null - */ - state?: components["schemas"]["LoopState"] | null; - /** - * Key - * @description The state key to read - * @default - */ - key?: string; - /** - * Default - * @description The value to return when the key is missing - * @default null - */ - default?: unknown | null; - /** - * type - * @default state_get - * @constant - */ - type: "state_get"; - }; - /** - * Merge Loop State Values - * @description Returns loop state with multiple values merged. - */ - StateMergeInvocation: { - /** - * Id - * @description The id of this instance of an invocation. Must be unique among all instances of invocations. - */ - id: string; - /** - * Is Intermediate - * @description Whether or not this is an intermediate invocation. - * @default false - */ - is_intermediate?: boolean; - /** - * Use Cache - * @description Whether or not to use the cache - * @default true - */ - use_cache?: boolean; - /** - * @description The loop state to update - * @default null - */ - state?: components["schemas"]["LoopState"] | null; - /** - * Values - * @description The values to merge into the loop state. Connect an output to this input. - * @default {} - */ - values?: { - [key: string]: unknown; - }; - /** - * type - * @default state_merge - * @constant - */ - type: "state_merge"; - }; - /** - * Set Loop State Value - * @description Returns loop state with one value set. - */ - StateSetInvocation: { - /** - * Id - * @description The id of this instance of an invocation. Must be unique among all instances of invocations. - */ - id: string; - /** - * Is Intermediate - * @description Whether or not this is an intermediate invocation. - * @default false - */ - is_intermediate?: boolean; - /** - * Use Cache - * @description Whether or not to use the cache - * @default true - */ - use_cache?: boolean; - /** - * @description The loop state to update - * @default null - */ - state?: components["schemas"]["LoopState"] | null; - /** - * Key - * @description The state key to set - * @default - */ - key?: string; - /** - * Value - * @description The value to set. Connect an output to this input. - * @default null - */ - value?: unknown | null; - /** - * type - * @default state_set - * @constant - */ - type: "state_set"; - }; /** * String2Output * @description Base class for invocations that output two strings @@ -41226,7 +40668,7 @@ export type components = { * * - Any Field * We cannot infer the usage of `typing.Any` via schema parsing, so you *must* use `ui_type=UIType.Any` to - * indicate that the field accepts any type. Use with caution. On inputs, this renders as a connection-only field. + * indicate that the field accepts any type. Use with caution. This cannot be used on outputs. * * - Scheduler Field * Special handling in the UI is needed for this field, which otherwise would be parsed as a plain enum field. diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index 5d4f2c4d0d6..7a6ea23367c 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -1315,6 +1315,17 @@ "nodeType": "Type: {{type}}", "nodeVersion": "Version: {{version}}", "nodeClassification": "Classification: {{classification}}", + "iterationOutputs": "Iteration outputs", + "finalOutputs": "Final outputs", + "forLoopBodyBoundary": "For loop body", + "forLoopBodyBoundaryStatus": { + "missing_linkage": "missing loop linkage", + "invalid_linkage": "invalid loop linkage", + "duplicate_linkage": "duplicate loop linkage", + "missing_return": "missing ForReturn", + "multiple_returns": "multiple ForReturns", + "orphan_return": "orphan ForReturn" + }, "nodeDetailsAria": "Details for {{title}}", "nodePackLabel": "Node pack: {{name}}", "nodesDirectory": "Nodes Directory", diff --git a/invokeai/frontend/webv2/src/features/generation/core/contracts.ts b/invokeai/frontend/webv2/src/features/generation/core/contracts.ts index 7eb00f48610..8508386d554 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/contracts.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/contracts.ts @@ -18,6 +18,7 @@ export interface BackendInvocationContract { } export interface BackendGraphEdgeContract { + type?: 'default' | 'loop_linkage'; source: { node_id: string; field: string }; destination: { node_id: string; field: string }; } @@ -40,6 +41,7 @@ export interface GraphEdgeContract { sourceField: string; targetNodeId: string; targetField: string; + type?: 'default' | 'loop_linkage'; } export interface GraphContract { diff --git a/invokeai/frontend/webv2/src/features/generation/core/graphBuilder.ts b/invokeai/frontend/webv2/src/features/generation/core/graphBuilder.ts index 2d3a6d531e3..40685d4f27a 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/graphBuilder.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/graphBuilder.ts @@ -111,6 +111,7 @@ export const toGraphContract = (backendGraph: BackendGraphContract, label: strin sourceNodeId: edge.source.node_id, targetField: edge.destination.field, targetNodeId: edge.destination.node_id, + ...(edge.type ? { type: edge.type } : {}), })), id: backendGraph.id, label, diff --git a/invokeai/frontend/webv2/src/features/generation/core/previewGraph.ts b/invokeai/frontend/webv2/src/features/generation/core/previewGraph.ts index 381af006d73..3d24d83f6d7 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/previewGraph.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/previewGraph.ts @@ -61,6 +61,7 @@ export const stabilizeBackendGraphIds = (graph: BackendGraphContract): BackendGr node_id: rename.get(edge.destination.node_id) ?? edge.destination.node_id, }, source: { field: edge.source.field, node_id: rename.get(edge.source.node_id) ?? edge.source.node_id }, + ...(edge.type ? { type: edge.type } : {}), })), id: 'generate-preview', nodes: Object.fromEntries( @@ -81,6 +82,7 @@ const toPreviewContract = (backendGraph: BackendGraphContract, label: string): G sourceNodeId: edge.source.node_id, targetField: edge.destination.field, targetNodeId: edge.destination.node_id, + ...(edge.type ? { type: edge.type } : {}), })), id: backendGraph.id, label, diff --git a/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.test.ts index 98b0e63b994..6623468000c 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.test.ts @@ -305,7 +305,7 @@ describe('compileProjectGraph', () => { // The connected input's direct value must not be sent alongside the edge. expect(backendGraph?.nodes[sinkId]).not.toHaveProperty('text'); expect(backendGraph?.edges).toEqual([ - { destination: { field: 'text', node_id: sinkId }, source: { field: 'out', node_id: sourceId } }, + { destination: { field: 'text', node_id: sinkId }, source: { field: 'out', node_id: sourceId }, type: 'default' }, ]); }); @@ -365,7 +365,100 @@ describe('compileProjectGraph', () => { }); expect(compileProjectGraph(doc, templates).backendGraph?.edges).toEqual([ - { destination: { field: 'text', node_id: sinkNode.id }, source: { field: 'out', node_id: sourceNode.id } }, + { + destination: { field: 'text', node_id: sinkNode.id }, + source: { field: 'out', node_id: sourceNode.id }, + type: 'default', + }, + ]); + }); + + it('preserves the direct loop_linkage edge type in the queue graph', () => { + const forTemplate: InvocationTemplate = { + ...template('for', { + collection: input('collection', { + default: [], + type: { batch: false, cardinality: 'COLLECTION', name: 'CollectionField' }, + }), + }), + outputs: { + item: { + description: '', + name: 'item', + outputScope: 'iteration', + title: 'Item', + type: { batch: false, cardinality: 'SINGLE', name: 'CollectionItemField' }, + }, + loop_linkage: { + description: '', + name: 'loop_linkage', + title: 'Loop linkage', + type: { batch: false, cardinality: 'SINGLE', name: 'AnyField' }, + }, + output_collection: { + description: '', + name: 'output_collection', + outputScope: 'final', + title: 'Output collection', + type: { batch: false, cardinality: 'COLLECTION', name: 'CollectionField' }, + }, + }, + }; + const returnTemplate: InvocationTemplate = { + ...template('for_return', { + loop_linkage: input('loop_linkage', { + input: 'connection', + type: { batch: false, cardinality: 'SINGLE', name: 'AnyField' }, + }), + output: input('output', { + input: 'any', + type: { batch: false, cardinality: 'SINGLE', name: 'CollectionItemField' }, + }), + }), + outputs: {}, + }; + const forNode = buildInvocationNode(forTemplate, { x: 0, y: 0 }); + const returnNode = buildInvocationNode(returnTemplate, { x: 100, y: 0 }); + let doc = createProjectGraph('loop-compile'); + + doc = projectGraphReducer(doc, { node: forNode, type: 'addNode' }); + doc = projectGraphReducer(doc, { node: returnNode, type: 'addNode' }); + doc = projectGraphReducer(doc, { + edge: { + id: 'item', + source: forNode.id, + sourceHandle: 'item', + target: returnNode.id, + targetHandle: 'output', + type: 'default', + }, + type: 'addEdge', + }); + doc = projectGraphReducer(doc, { + edge: { + id: 'linkage', + source: forNode.id, + sourceHandle: 'loop_linkage', + target: returnNode.id, + targetHandle: 'loop_linkage', + type: 'loop_linkage', + }, + type: 'addEdge', + }); + + const graph = compileProjectGraph(doc, { for: forTemplate, for_return: returnTemplate }).backendGraph; + + expect(graph.edges).toEqual([ + { + destination: { field: 'output', node_id: returnNode.id }, + source: { field: 'item', node_id: forNode.id }, + type: 'default', + }, + { + destination: { field: 'loop_linkage', node_id: returnNode.id }, + source: { field: 'loop_linkage', node_id: forNode.id }, + type: 'loop_linkage', + }, ]); }); }); diff --git a/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.ts b/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.ts index 611dd23fb91..682590c17e8 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.ts @@ -7,10 +7,9 @@ import type { WorkflowInvocationNode, } from './types'; -import { getResolvedWorkflowEdgesIndexed } from './connectors'; import { createWorkflowId } from './document'; import { getWorkflowFieldInvalidReason } from './fields'; -import { createWorkflowGraphIndex } from './graphIndex'; +import { getCanonicalWorkflowEdges, validateForLoopGraph } from './forLoops'; import { isInvocationNode } from './types'; import { hasAnyCycle } from './validation'; @@ -81,7 +80,7 @@ export const getProjectGraphReadiness = ( const templates = templatesSnapshot.templates; const executableNodes = getExecutableNodes(document); - const index = createWorkflowGraphIndex(document.nodes, document.edges); + const canonicalEdges = getCanonicalWorkflowEdges(document); if (executableNodes.length === 0) { return { canInvoke: false, reasons: ['The project graph has no nodes. Add nodes in the Workflow view.'] }; @@ -89,9 +88,9 @@ export const getProjectGraphReadiness = ( const reasons: string[] = []; const connectedInputs = new Set( - getResolvedWorkflowEdgesIndexed(document.edges, index, templates) - .filter((edge) => executableNodes.some((node) => node.id === edge.target)) - .map((edge) => `${edge.target}:${edge.targetHandle}`) + canonicalEdges + .filter((edge) => executableNodes.some((node) => node.id === edge.destination.node_id)) + .map((edge) => `${edge.destination.node_id}:${edge.destination.field}`) ); for (const node of executableNodes) { @@ -135,10 +134,28 @@ export const getProjectGraphReadiness = ( } } - if (hasAnyCycle(document.nodes, document.edges)) { + if ( + hasAnyCycle( + document.nodes, + canonicalEdges.map((edge) => ({ + id: edge.id, + source: edge.source.node_id, + sourceHandle: edge.source.field, + target: edge.destination.node_id, + targetHandle: edge.destination.field, + type: edge.type, + })) + ) + ) { reasons.push('The project graph contains a cycle.'); } + const forLoopError = validateForLoopGraph(document); + + if (forLoopError) { + reasons.push(`For loop validation failed: ${forLoopError}.`); + } + return { canInvoke: reasons.length === 0, reasons }; }; @@ -155,11 +172,16 @@ export const compileProjectGraph = ( document: ProjectGraphState, templates: InvocationTemplates ): CompiledWorkflowGraph => { + const forLoopError = validateForLoopGraph(document); + + if (forLoopError) { + throw new Error(`For loop validation failed: ${forLoopError}.`); + } + const executableNodes = getExecutableNodes(document).filter((node) => templates[node.data.type] !== undefined); const executableNodeIds = new Set(executableNodes.map((node) => node.id)); const backendGraph: WorkflowBackendGraph = { edges: [], id: createWorkflowId('workflow-graph'), nodes: {} }; - const index = createWorkflowGraphIndex(document.nodes, document.edges); - const resolvedEdges = getResolvedWorkflowEdgesIndexed(document.edges, index, templates); + const resolvedEdges = getCanonicalWorkflowEdges(document); for (const node of executableNodes) { const template = templates[node.data.type] as NonNullable<(typeof templates)[string]>; @@ -190,11 +212,11 @@ export const compileProjectGraph = ( const seenEdgeKeys = new Set(); for (const edge of resolvedEdges) { - if (!executableNodeIds.has(edge.source) || !executableNodeIds.has(edge.target)) { + if (!executableNodeIds.has(edge.source.node_id) || !executableNodeIds.has(edge.destination.node_id)) { continue; } - const key = `${edge.source}:${edge.sourceHandle}->${edge.target}:${edge.targetHandle}`; + const key = `${edge.type}:${edge.source.node_id}:${edge.source.field}->${edge.destination.node_id}:${edge.destination.field}`; if (seenEdgeKeys.has(key)) { continue; @@ -202,29 +224,31 @@ export const compileProjectGraph = ( seenEdgeKeys.add(key); backendGraph.edges.push({ - destination: { field: edge.targetHandle, node_id: edge.target }, - source: { field: edge.sourceHandle, node_id: edge.source }, + destination: edge.destination, + source: edge.source, + type: edge.type, }); // A connected input always wins over a stale direct value; sending both // would let pydantic reject the node on the ignored direct value. - const targetNode = backendGraph.nodes[edge.target]; + const targetNode = backendGraph.nodes[edge.destination.node_id]; if (targetNode) { - delete targetNode[edge.targetHandle]; + delete targetNode[edge.destination.field]; } } return { backendGraph, edges: resolvedEdges - .filter((edge) => executableNodeIds.has(edge.source) && executableNodeIds.has(edge.target)) + .filter((edge) => executableNodeIds.has(edge.source.node_id) && executableNodeIds.has(edge.destination.node_id)) .map((edge) => ({ id: edge.id, - sourceField: edge.sourceHandle, - sourceNodeId: edge.source, - targetField: edge.targetHandle, - targetNodeId: edge.target, + sourceField: edge.source.field, + sourceNodeId: edge.source.node_id, + targetField: edge.destination.field, + targetNodeId: edge.destination.node_id, + type: edge.type, })), id: backendGraph.id, label: document.name || 'Workflow', diff --git a/invokeai/frontend/webv2/src/features/workflow/core/connectors.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/connectors.test.ts new file mode 100644 index 00000000000..6214f6d0491 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/workflow/core/connectors.test.ts @@ -0,0 +1,280 @@ +import { describe, expect, it } from 'vitest'; + +import type { WorkflowInvocationNode } from './types'; + +import { getConnectorDeletionSpliceConnections } from './connectors'; +import { createProjectGraph, projectGraphReducer } from './document'; + +const node = (id: string, type: string): WorkflowInvocationNode => ({ + data: { + inputs: {}, + isIntermediate: true, + isOpen: true, + label: '', + nodePack: 'invokeai', + notes: '', + type, + useCache: true, + version: '1.0.0', + }, + id, + position: { x: 0, y: 0 }, + type: 'invocation', +}); + +describe('connector deletion', () => { + it('reconnects ordinary data through a removed connector', () => { + const source = node('source', 'source'); + const target = node('target', 'target'); + const connector = { + data: { label: '' }, + id: 'connector', + position: { x: 0, y: 0 }, + type: 'connector' as const, + }; + const document = { + ...createProjectGraph('connector-delete'), + nodes: [source, connector, target], + edges: [ + { + id: 'in', + source: 'source', + sourceHandle: 'out', + target: 'connector', + targetHandle: 'in', + type: 'default' as const, + }, + { + id: 'out', + source: 'connector', + sourceHandle: 'out', + target: 'target', + targetHandle: 'input', + type: 'default' as const, + }, + ], + }; + + expect(getConnectorDeletionSpliceConnections('connector', document.nodes, document.edges)).toEqual([ + { + id: 'splice-source-out-target-input', + source: 'source', + sourceHandle: 'out', + target: 'target', + targetHandle: 'input', + type: 'default', + }, + ]); + + const next = projectGraphReducer(document, { nodeIds: ['connector'], type: 'removeNodes' }); + expect(next.nodes.map((candidate) => candidate.id)).toEqual(['source', 'target']); + expect(next.edges).toEqual([ + { + id: 'splice-source-out-target-input', + source: 'source', + sourceHandle: 'out', + target: 'target', + targetHandle: 'input', + type: 'default', + }, + ]); + }); + + it('reconnects a removed connector alias as a direct loop_linkage edge', () => { + const forNode = node('for', 'for'); + const returnNode = node('return', 'for_return'); + const connector = { + data: { label: '' }, + id: 'connector', + position: { x: 0, y: 0 }, + type: 'connector' as const, + }; + const document = { + ...createProjectGraph('connector-loop-delete'), + nodes: [forNode, connector, returnNode], + edges: [ + { + id: 'in', + source: 'for', + sourceHandle: 'loop_linkage', + target: 'connector', + targetHandle: 'in', + type: 'default' as const, + }, + { + id: 'out', + source: 'connector', + sourceHandle: 'out', + target: 'return', + targetHandle: 'loop_linkage', + type: 'default' as const, + }, + ], + }; + + const next = projectGraphReducer(document, { nodeIds: ['connector'], type: 'removeNodes' }); + expect(next.edges).toEqual([ + { + id: 'splice-for-loop_linkage-return-loop_linkage', + source: 'for', + sourceHandle: 'loop_linkage', + target: 'return', + targetHandle: 'loop_linkage', + type: 'loop_linkage', + }, + ]); + }); + + it('keeps a surviving upstream connector when deleting a downstream connector', () => { + const source = node('source', 'source'); + const target = node('target', 'target'); + const upstream = { + data: { label: '' }, + id: 'upstream', + position: { x: 0, y: 0 }, + type: 'connector' as const, + }; + const downstream = { + data: { label: '' }, + id: 'downstream', + position: { x: 0, y: 0 }, + type: 'connector' as const, + }; + const document = { + ...createProjectGraph('connector-chain-delete'), + nodes: [source, upstream, downstream, target], + edges: [ + { + id: 'source-upstream', + source: 'source', + sourceHandle: 'out', + target: 'upstream', + targetHandle: 'in', + type: 'default' as const, + }, + { + id: 'upstream-downstream', + source: 'upstream', + sourceHandle: 'out', + target: 'downstream', + targetHandle: 'in', + type: 'default' as const, + }, + { + id: 'downstream-target', + source: 'downstream', + sourceHandle: 'out', + target: 'target', + targetHandle: 'input', + type: 'default' as const, + }, + ], + }; + + const next = projectGraphReducer(document, { nodeIds: ['downstream'], type: 'removeNodes' }); + expect(next.nodes.map((candidate) => candidate.id)).toEqual(['source', 'upstream', 'target']); + expect(next.edges).toEqual([ + { + id: 'source-upstream', + source: 'source', + sourceHandle: 'out', + target: 'upstream', + targetHandle: 'in', + type: 'default', + }, + { + id: 'splice-source-out-target-input', + source: 'source', + sourceHandle: 'out', + target: 'target', + targetHandle: 'input', + type: 'default', + }, + ]); + }); + + it('preserves the loop-linkage alias when deleting one connector from a chain', () => { + const forNode = node('for', 'for'); + const returnNode = node('return', 'for_return'); + const upstream = { + data: { label: '' }, + id: 'upstream', + position: { x: 0, y: 0 }, + type: 'connector' as const, + }; + const downstream = { + data: { label: '' }, + id: 'downstream', + position: { x: 0, y: 0 }, + type: 'connector' as const, + }; + const document = { + ...createProjectGraph('connector-loop-chain-delete'), + nodes: [forNode, upstream, downstream, returnNode], + edges: [ + { + id: 'for-upstream', + source: 'for', + sourceHandle: 'loop_linkage', + target: 'upstream', + targetHandle: 'in', + type: 'default' as const, + }, + { + id: 'upstream-downstream', + source: 'upstream', + sourceHandle: 'out', + target: 'downstream', + targetHandle: 'in', + type: 'default' as const, + }, + { + id: 'downstream-return', + source: 'downstream', + sourceHandle: 'out', + target: 'return', + targetHandle: 'loop_linkage', + type: 'default' as const, + }, + ], + }; + + expect(projectGraphReducer(document, { nodeIds: ['upstream'], type: 'removeNodes' }).edges).toEqual([ + { + id: 'downstream-return', + source: 'downstream', + sourceHandle: 'out', + target: 'return', + targetHandle: 'loop_linkage', + type: 'default', + }, + { + id: 'splice-for-loop_linkage-downstream-in', + source: 'for', + sourceHandle: 'loop_linkage', + target: 'downstream', + targetHandle: 'in', + type: 'default', + }, + ]); + + expect(projectGraphReducer(document, { nodeIds: ['downstream'], type: 'removeNodes' }).edges).toEqual([ + { + id: 'for-upstream', + source: 'for', + sourceHandle: 'loop_linkage', + target: 'upstream', + targetHandle: 'in', + type: 'default', + }, + { + id: 'splice-upstream-out-return-loop_linkage', + source: 'upstream', + sourceHandle: 'out', + target: 'return', + targetHandle: 'loop_linkage', + type: 'default', + }, + ]); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/workflow/core/connectors.ts b/invokeai/frontend/webv2/src/features/workflow/core/connectors.ts index c542800e2e0..468d3c56093 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/connectors.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/connectors.ts @@ -23,6 +23,17 @@ export interface ResolvedWorkflowEdge extends WorkflowEdge { sourceHandle: string; } +export interface ResolvedLoopLinkagePath { + forNodeId: string; + returnNodeId: string; + edgeIds: string[]; + connectorNodeIds: string[]; +} + +export interface ConnectorDeletionSpliceConnection extends WorkflowEdge { + type: 'default' | 'loop_linkage'; +} + export const getConnectorInputEdge = (connectorId: string, edges: WorkflowEdge[]): WorkflowEdge | undefined => edges.find( (edge) => edge.type === 'default' && edge.target === connectorId && edge.targetHandle === CONNECTOR_INPUT_HANDLE @@ -41,6 +52,260 @@ export const getConnectorOutputEdges = (connectorId: string, edges: WorkflowEdge export const getConnectorOutputEdgesIndexed = (connectorId: string, index: WorkflowGraphIndex): WorkflowEdge[] => index.connectorOutputsById.get(connectorId) ?? []; +/** Resolves the optional connector representation of a For-to-ForReturn linkage. */ +export const resolveLoopLinkagePath = ( + edge: WorkflowEdge, + nodes: WorkflowNode[], + edges: WorkflowEdge[] +): ResolvedLoopLinkagePath | null => { + if (edge.type !== 'default' || edge.targetHandle !== 'loop_linkage') { + return null; + } + + const returnNode = nodes.find((node) => node.id === edge.target); + + if (!returnNode || !isInvocationNode(returnNode) || returnNode.data.type !== 'for_return') { + return null; + } + + const edgeIds = [edge.id]; + const connectorNodeIds: string[] = []; + const visitedConnectors = new Set(); + let currentEdge = edge; + + while (true) { + const sourceNode = nodes.find((node) => node.id === currentEdge.source); + + if (!sourceNode) { + return null; + } + + if (sourceNode && isInvocationNode(sourceNode)) { + if (sourceNode.data.type !== 'for' || currentEdge.sourceHandle !== 'loop_linkage') { + return null; + } + + return { + connectorNodeIds: [...connectorNodeIds].reverse(), + edgeIds: [...edgeIds].reverse(), + forNodeId: sourceNode.id, + returnNodeId: returnNode.id, + }; + } + + if (!isConnectorNode(sourceNode) || currentEdge.sourceHandle !== CONNECTOR_OUTPUT_HANDLE) { + return null; + } + + if (visitedConnectors.has(sourceNode.id)) { + return null; + } + + visitedConnectors.add(sourceNode.id); + connectorNodeIds.push(sourceNode.id); + + const inputEdges = edges.filter( + (candidate) => + candidate.type === 'default' && + candidate.target === sourceNode.id && + candidate.targetHandle === CONNECTOR_INPUT_HANDLE + ); + const outputEdges = getConnectorOutputEdges(sourceNode.id, edges); + + if (inputEdges.length !== 1 || outputEdges.length !== 1 || outputEdges[0]?.id !== currentEdge.id) { + return null; + } + + const inputEdge = inputEdges[0]; + + if (!inputEdge) { + return null; + } + + edgeIds.push(inputEdge.id); + currentEdge = inputEdge; + } +}; + +/** Returns document edges with complete connector aliases styled as loop linkages. */ +export const getEdgesWithLoopLinkageAliases = (nodes: WorkflowNode[], edges: WorkflowEdge[]): WorkflowEdge[] => { + const linkageEdgeIds = new Set(); + + for (const edge of edges) { + const sourceNode = nodes.find((node) => node.id === edge.source); + + if ( + edge.type !== 'default' || + edge.sourceHandle !== CONNECTOR_OUTPUT_HANDLE || + edge.targetHandle !== 'loop_linkage' || + !sourceNode || + !isConnectorNode(sourceNode) + ) { + continue; + } + + resolveLoopLinkagePath(edge, nodes, edges)?.edgeIds.forEach((edgeId) => linkageEdgeIds.add(edgeId)); + } + + return edges.map((edge) => (linkageEdgeIds.has(edge.id) ? { ...edge, type: 'loop_linkage' } : edge)); +}; + +const resolveConnectorDeletionSource = ( + connectorId: string, + nodes: WorkflowNode[], + edges: WorkflowEdge[], + removedConnectorIds: ReadonlySet +): ResolvedConnectorSource | null => { + const visitedConnectorIds = new Set(); + let resolvedSource = resolveConnectorSource(connectorId, nodes, edges); + + while (resolvedSource && removedConnectorIds.has(resolvedSource.nodeId)) { + if (visitedConnectorIds.has(resolvedSource.nodeId)) { + return null; + } + visitedConnectorIds.add(resolvedSource.nodeId); + resolvedSource = resolveConnectorSource(resolvedSource.nodeId, nodes, edges); + } + + if (!resolvedSource) { + return null; + } + + const sourceNode = nodes.find((node) => node.id === resolvedSource.nodeId); + if ( + sourceNode && + isInvocationNode(sourceNode) && + sourceNode.data.type === 'for' && + resolvedSource.fieldName === 'loop_linkage' + ) { + const outputEdges = getConnectorOutputEdges(connectorId, edges); + if ( + outputEdges.length !== 1 || + outputEdges.some((edge) => { + const targetNode = nodes.find((node) => node.id === edge.target); + return !( + (targetNode && isConnectorNode(targetNode) && edge.targetHandle === CONNECTOR_INPUT_HANDLE) || + (targetNode && + isInvocationNode(targetNode) && + targetNode.data.type === 'for_return' && + edge.targetHandle === 'loop_linkage') + ); + }) + ) { + return null; + } + } + + const linkagePath = getResolvedLoopLinkagePathForConnector(connectorId, nodes, edges); + const inputEdge = getConnectorInputEdge(connectorId, edges); + if (linkagePath && inputEdge) { + return { fieldName: inputEdge.sourceHandle, nodeId: inputEdge.source, type: null }; + } + + return resolvedSource; +}; + +const getResolvedLoopLinkagePathForConnector = ( + connectorId: string, + nodes: WorkflowNode[], + edges: WorkflowEdge[] +): ResolvedLoopLinkagePath | null => { + for (const edge of edges) { + if ( + edge.type !== 'default' || + edge.sourceHandle !== CONNECTOR_OUTPUT_HANDLE || + edge.targetHandle !== 'loop_linkage' + ) { + continue; + } + + const path = resolveLoopLinkagePath(edge, nodes, edges); + if (path?.connectorNodeIds.includes(connectorId)) { + return path; + } + } + + return null; +}; + +const getConnectorDeletionOutputEdges = ( + connectorId: string, + nodes: WorkflowNode[], + edges: WorkflowEdge[], + removedConnectorIds: ReadonlySet +): WorkflowEdge[] | null => { + const visited = new Set(); + const outputEdges: WorkflowEdge[] = []; + + const visit = (currentConnectorId: string): boolean => { + if (visited.has(currentConnectorId)) { + return false; + } + + visited.add(currentConnectorId); + + for (const edge of getConnectorOutputEdges(currentConnectorId, edges)) { + const targetNode = nodes.find((node) => node.id === edge.target); + + if (targetNode && isConnectorNode(targetNode) && removedConnectorIds.has(targetNode.id)) { + if (!visit(targetNode.id)) { + return false; + } + } else { + outputEdges.push(edge); + } + } + + return true; + }; + + return visit(connectorId) ? outputEdges : null; +}; + +/** Builds replacement edges before deleting one or more connector nodes. */ +export const getConnectorDeletionSpliceConnections = ( + connectorId: string, + nodes: WorkflowNode[], + edges: WorkflowEdge[], + removedConnectorIds: ReadonlySet = new Set([connectorId]) +): ConnectorDeletionSpliceConnection[] => { + const source = resolveConnectorDeletionSource(connectorId, nodes, edges, removedConnectorIds); + const outputEdges = getConnectorDeletionOutputEdges(connectorId, nodes, edges, removedConnectorIds); + + if (!source || !outputEdges) { + return []; + } + + const connections: ConnectorDeletionSpliceConnection[] = []; + + for (const outputEdge of outputEdges) { + const type = + source.fieldName === 'loop_linkage' && outputEdge.targetHandle === 'loop_linkage' ? 'loop_linkage' : 'default'; + const replacement = { + id: `splice-${source.nodeId}-${source.fieldName}-${outputEdge.target}-${outputEdge.targetHandle}`, + source: source.nodeId, + sourceHandle: source.fieldName, + target: outputEdge.target, + targetHandle: outputEdge.targetHandle, + type, + } satisfies ConnectorDeletionSpliceConnection; + + if ( + !connections.some( + (edge) => + edge.source === replacement.source && + edge.sourceHandle === replacement.sourceHandle && + edge.target === replacement.target && + edge.targetHandle === replacement.targetHandle + ) + ) { + connections.push(replacement); + } + } + + return connections; +}; + export const resolveConnectorSource = ( connectorId: string, nodes: WorkflowNode[], diff --git a/invokeai/frontend/webv2/src/features/workflow/core/document.ts b/invokeai/frontend/webv2/src/features/workflow/core/document.ts index 001af378972..cdb49fb7cbb 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/document.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/document.ts @@ -16,6 +16,7 @@ import type { XYPosition, } from './types'; +import { getConnectorDeletionSpliceConnections } from './connectors'; import { isInvocationNode, isNotesNode } from './types'; /** @@ -424,9 +425,33 @@ const applyProjectGraphAction = (document: ProjectGraphState, action: ProjectGra return document; } + const removedConnectorIds = new Set( + document.nodes.filter((node) => removedNodeIds.has(node.id) && node.type === 'connector').map((node) => node.id) + ); + const spliceEdges = [...removedConnectorIds].flatMap((connectorId) => + getConnectorDeletionSpliceConnections(connectorId, document.nodes, document.edges, removedConnectorIds) + ); + const remainingNodeIds = new Set( + document.nodes.filter((node) => !removedNodeIds.has(node.id)).map((node) => node.id) + ); + const existingEdgeKeys = new Set( + document.edges.map((edge) => `${edge.source}:${edge.sourceHandle}->${edge.target}:${edge.targetHandle}`) + ); + const edges = document.edges.filter( + (edge) => !removedNodeIds.has(edge.source) && !removedNodeIds.has(edge.target) + ); + + for (const edge of spliceEdges) { + const key = `${edge.source}:${edge.sourceHandle}->${edge.target}:${edge.targetHandle}`; + if (remainingNodeIds.has(edge.source) && remainingNodeIds.has(edge.target) && !existingEdgeKeys.has(key)) { + existingEdgeKeys.add(key); + edges.push(edge); + } + } + return { ...document, - edges: document.edges.filter((edge) => !removedNodeIds.has(edge.source) && !removedNodeIds.has(edge.target)), + edges, form: removeNodeFieldElements(document.form, removedNodeIds), nodes: document.nodes.filter((node) => !removedNodeIds.has(node.id)), }; diff --git a/invokeai/frontend/webv2/src/features/workflow/core/forLoops.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/forLoops.test.ts new file mode 100644 index 00000000000..7e7b4a83c5f --- /dev/null +++ b/invokeai/frontend/webv2/src/features/workflow/core/forLoops.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from 'vitest'; + +import type { WorkflowEdge, WorkflowInvocationNode } from './types'; + +import { createProjectGraph } from './document'; +import { getCanonicalWorkflowEdges, validateForLoopGraph } from './forLoops'; + +const node = (id: string, type: string): WorkflowInvocationNode => ({ + data: { + inputs: {}, + isIntermediate: true, + isOpen: true, + label: '', + nodePack: 'invokeai', + notes: '', + type, + useCache: true, + version: '1.0.0', + }, + id, + position: { x: 0, y: 0 }, + type: 'invocation', +}); + +const edge = ( + id: string, + source: string, + sourceHandle: string, + target: string, + targetHandle: string, + type: WorkflowEdge['type'] = 'default' +): WorkflowEdge => ({ id, source, sourceHandle, target, targetHandle, type }); + +const linkedGraph = () => { + const forNode = node('for', 'for'); + const bodyNode = node('body', 'number'); + const returnNode = node('return', 'for_return'); + const document = { + ...createProjectGraph('for-loop-test'), + nodes: [forNode, bodyNode, returnNode], + edges: [ + edge('iteration', 'for', 'item', 'body', 'value'), + edge('body-output', 'body', 'value', 'return', 'output'), + edge('linkage', 'for', 'loop_linkage', 'return', 'loop_linkage', 'loop_linkage'), + ], + }; + + return { bodyNode, document, forNode, returnNode }; +}; + +describe('For/ForReturn graph contracts', () => { + it('accepts a direct loop linkage and excludes it from data-flow traversal', () => { + const { document } = linkedGraph(); + + expect(validateForLoopGraph(document)).toBeNull(); + expect(getCanonicalWorkflowEdges(document).find((candidate) => candidate.id === 'linkage')).toMatchObject({ + source: { field: 'loop_linkage', node_id: 'for' }, + destination: { field: 'loop_linkage', node_id: 'return' }, + type: 'loop_linkage', + }); + + expect( + validateForLoopGraph({ + ...document, + edges: document.edges.map((candidate) => + candidate.id === 'linkage' ? { ...candidate, type: 'default' as const } : candidate + ), + }) + ).toBe('nodes.forLoopLinkageInvalid'); + }); + + it('rejects an unlinked loop and an unsupported nested loop body', () => { + const { document } = linkedGraph(); + const unlinked = { ...document, edges: document.edges.filter((candidate) => candidate.id !== 'linkage') }; + expect(validateForLoopGraph(unlinked)).toBe('nodes.forLoopLinkageMissing'); + + const innerFor = node('inner-for', 'for'); + const innerReturn = node('inner-return', 'for_return'); + const nested = { + ...document, + nodes: [...document.nodes, innerFor, innerReturn], + edges: [ + ...document.edges, + edge('nested', 'for', 'item', 'inner-for', 'collection'), + edge('inner-iteration', 'inner-for', 'item', 'inner-return', 'output'), + edge('inner-linkage', 'inner-for', 'loop_linkage', 'inner-return', 'loop_linkage', 'loop_linkage'), + ], + }; + expect(validateForLoopGraph(nested)).toBe('nodes.forLoopNestedUnsupported'); + }); + + it('accepts a nested For whose final collection closes the outer body', () => { + const document = { + ...createProjectGraph('nested-for-loop-test'), + nodes: [ + node('outer', 'for'), + node('inner-collection', 'add'), + node('inner', 'for'), + node('inner-body', 'add'), + node('inner-condition', 'add'), + node('inner-return', 'for_return'), + node('outer-return', 'for_return'), + ], + edges: [ + edge('outer-item', 'outer', 'item', 'inner-collection', 'value'), + edge('inner-collection', 'inner-collection', 'value', 'inner', 'collection'), + edge('inner-item', 'inner', 'item', 'inner-body', 'value'), + edge('inner-item-condition', 'inner', 'item', 'inner-condition', 'value'), + edge('inner-output', 'inner-body', 'value', 'inner-return', 'output'), + edge('inner-condition', 'inner-condition', 'value', 'inner-return', 'continue_condition'), + edge('inner-final', 'inner', 'output_collection', 'outer-return', 'output'), + edge('inner-linkage', 'inner', 'loop_linkage', 'inner-return', 'loop_linkage', 'loop_linkage'), + edge('outer-linkage', 'outer', 'loop_linkage', 'outer-return', 'loop_linkage', 'loop_linkage'), + ], + }; + + expect(validateForLoopGraph(document)).toBeNull(); + }); + + it('rejects a nested For with an external outer continuation condition', () => { + const document = { + ...createProjectGraph('nested-for-loop-invalid-test'), + nodes: [ + node('outer', 'for'), + node('inner-collection', 'add'), + node('inner', 'for'), + node('inner-body', 'add'), + node('inner-return', 'for_return'), + node('outer-return', 'for_return'), + node('external-condition', 'add'), + ], + edges: [ + edge('outer-item', 'outer', 'item', 'inner-collection', 'value'), + edge('inner-collection', 'inner-collection', 'value', 'inner', 'collection'), + edge('inner-item', 'inner', 'item', 'inner-body', 'value'), + edge('inner-output', 'inner-body', 'value', 'inner-return', 'output'), + edge('inner-final', 'inner', 'output_collection', 'outer-return', 'output'), + edge('inner-linkage', 'inner', 'loop_linkage', 'inner-return', 'loop_linkage', 'loop_linkage'), + edge('outer-linkage', 'outer', 'loop_linkage', 'outer-return', 'loop_linkage', 'loop_linkage'), + edge('external-condition', 'external-condition', 'value', 'outer-return', 'continue_condition'), + ], + }; + + expect(validateForLoopGraph(document)).toBe('nodes.forLoopNestedUnsupported'); + }); + + it('canonicalizes a complete connector alias without changing the stored edge list', () => { + const forNode = node('for', 'for'); + const returnNode = node('return', 'for_return'); + const connector = { + data: { label: '' }, + id: 'connector', + position: { x: 0, y: 0 }, + type: 'connector' as const, + }; + const document = { + ...createProjectGraph('connector-loop-test'), + nodes: [forNode, connector, returnNode], + edges: [ + edge('for-to-connector', 'for', 'loop_linkage', 'connector', 'in'), + edge('connector-to-return', 'connector', 'out', 'return', 'loop_linkage'), + ], + }; + + expect(validateForLoopGraph(document)).toBe('nodes.forLoopMissingIterationOutput'); + expect(getCanonicalWorkflowEdges(document)).toEqual([ + { + id: 'resolved-loop-linkage-for-return', + source: { field: 'loop_linkage', node_id: 'for' }, + destination: { field: 'loop_linkage', node_id: 'return' }, + type: 'loop_linkage', + }, + ]); + }); +}); diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/validateForLoopGraph.ts b/invokeai/frontend/webv2/src/features/workflow/core/forLoops.ts similarity index 60% rename from invokeai/frontend/web/src/features/nodes/util/graph/validateForLoopGraph.ts rename to invokeai/frontend/webv2/src/features/workflow/core/forLoops.ts index 39c07e65b54..6c05c9e8b3d 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/validateForLoopGraph.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/forLoops.ts @@ -1,7 +1,10 @@ -import { LOOP_LINKAGE_FIELD } from 'features/nodes/types/constants'; -import type { Graph } from 'services/api/types'; +import type { WorkflowEdge, WorkflowNode, ProjectGraphState } from './types'; -type ForLoopGraphError = +import { getResolvedWorkflowEdgesIndexed, resolveLoopLinkagePath } from './connectors'; +import { createWorkflowGraphIndex } from './graphIndex'; +import { isInvocationNode } from './types'; + +export type ForLoopGraphError = | 'nodes.forLoopMissingIterationOutput' | 'nodes.forLoopReturnCount' | 'nodes.forLoopUnterminatedBody' @@ -17,75 +20,269 @@ type ForLoopGraphError = | 'nodes.forLoopLinkageDuplicate' | 'nodes.forReturnOwnership'; +export type LoopBodyBoundaryStatus = + | 'complete' + | 'missing_linkage' + | 'invalid_linkage' + | 'duplicate_linkage' + | 'missing_return' + | 'multiple_returns' + | 'orphan_return'; + +export interface LoopBodyBoundary { + forNodeId?: string; + returnNodeId?: string; + bodyNodeIds: string[]; + status: LoopBodyBoundaryStatus; +} + +const LOOP_LINKAGE_FIELD = 'loop_linkage'; const ITERATION_OUTPUT_FIELDS = new Set(['item', 'index', 'total', 'state']); const FINAL_OUTPUT_FIELDS = new Set(['output_collection', 'final_state']); -export const validateForLoopGraph = (graph: Graph): ForLoopGraphError | null => { - const nodes = graph.nodes ?? {}; - const allEdges = graph.edges ?? []; - const edges = allEdges.filter((edge) => edge.type !== 'loop_linkage'); - const linkageEdges = allEdges.filter((edge) => edge.type === 'loop_linkage'); +type LoopNode = { id: string; type: string }; +type LoopEdge = { + id: string; + type: WorkflowEdge['type']; + source: { node_id: string; field: string }; + destination: { node_id: string; field: string }; +}; + +const getInvocationNodes = (nodes: WorkflowNode[]): LoopNode[] => + nodes.filter(isInvocationNode).map((node) => ({ id: node.id, type: node.data.type })); + +/** Resolves connectors for loop validation and compilation without mutating the document. */ +export const getCanonicalWorkflowEdges = (document: Pick): LoopEdge[] => { + const index = createWorkflowGraphIndex(document.nodes, document.edges); + const aliasEdgeIds = new Set(); + const aliasEdges: LoopEdge[] = []; + + for (const edge of document.edges) { + const sourceNode = index.nodesById.get(edge.source); + + if ( + edge.type !== 'default' || + edge.targetHandle !== LOOP_LINKAGE_FIELD || + !sourceNode || + isInvocationNode(sourceNode) + ) { + continue; + } + + const path = resolveLoopLinkagePath(edge, document.nodes, document.edges); + + if (!path) { + continue; + } + + path.edgeIds.forEach((edgeId) => aliasEdgeIds.add(edgeId)); + aliasEdges.push({ + destination: { field: LOOP_LINKAGE_FIELD, node_id: path.returnNodeId }, + id: `resolved-loop-linkage-${path.forNodeId}-${path.returnNodeId}`, + source: { field: LOOP_LINKAGE_FIELD, node_id: path.forNodeId }, + type: 'loop_linkage', + }); + } + + const resolved = getResolvedWorkflowEdgesIndexed(document.edges, index).filter( + (edge) => + !aliasEdgeIds.has(edge.id) && + index.nodesById.get(edge.source) !== undefined && + index.nodesById.get(edge.target) !== undefined && + isInvocationNode(index.nodesById.get(edge.source) as WorkflowNode) && + isInvocationNode(index.nodesById.get(edge.target) as WorkflowNode) + ); + + return [ + ...resolved.map((edge) => ({ + destination: { field: edge.targetHandle, node_id: edge.target }, + id: edge.id, + source: { field: edge.sourceHandle, node_id: edge.source }, + type: edge.type, + })), + ...aliasEdges, + ]; +}; + +const walk = (startIds: Iterable, adjacency: Map): Set => { + const visited = new Set(); + const pending = [...startIds]; + + while (pending.length > 0) { + const nodeId = pending.pop(); + + if (nodeId === undefined || visited.has(nodeId)) { + continue; + } + + visited.add(nodeId); + pending.push(...(adjacency.get(nodeId) ?? [])); + } + + return visited; +}; + +const getBodyNodeIds = ( + reachableNodeIds: Set, + returnNodeId: string | undefined, + incoming: Map +): Set => { + if (!returnNodeId) { + return new Set(reachableNodeIds); + } + + const bodyNodeIds = new Set([...walk([returnNodeId], incoming)].filter((nodeId) => reachableNodeIds.has(nodeId))); + bodyNodeIds.add(returnNodeId); + return bodyNodeIds; +}; + +export const getForLoopBodyBoundaries = (nodes: WorkflowNode[], edges: WorkflowEdge[]): LoopBodyBoundary[] => { + const invocationNodes = nodes.filter(isInvocationNode); + const nodesById = new Map(invocationNodes.map((node) => [node.id, node])); + const canonicalEdges = getCanonicalWorkflowEdges({ nodes, edges }); + const dataEdges = canonicalEdges.filter((edge) => edge.type !== 'loop_linkage'); const outgoing = new Map(); const incoming = new Map(); - for (const edge of edges) { - const sourceId = edge.source.node_id; - const destinationId = edge.destination.node_id; - outgoing.set(sourceId, [...(outgoing.get(sourceId) ?? []), destinationId]); - incoming.set(destinationId, [...(incoming.get(destinationId) ?? []), sourceId]); + for (const edge of dataEdges) { + outgoing.set(edge.source.node_id, [...(outgoing.get(edge.source.node_id) ?? []), edge.destination.node_id]); + incoming.set(edge.destination.node_id, [...(incoming.get(edge.destination.node_id) ?? []), edge.source.node_id]); } - const walk = (startIds: Iterable, adjacency: Map): Set => { - const visited = new Set(); - const pending = [...startIds]; - while (pending.length > 0) { - const nodeId = pending.pop(); - if (nodeId === undefined || visited.has(nodeId)) { - continue; - } - visited.add(nodeId); - pending.push(...(adjacency.get(nodeId) ?? [])); + const linkedReturnByForId = new Map(); + const linkedForByReturnId = new Map(); + let duplicateLinkage = false; + + for (const edge of canonicalEdges.filter((candidate) => candidate.type === 'loop_linkage')) { + if (linkedReturnByForId.has(edge.source.node_id) || linkedForByReturnId.has(edge.destination.node_id)) { + duplicateLinkage = true; + continue; } - return visited; - }; + linkedReturnByForId.set(edge.source.node_id, edge.destination.node_id); + linkedForByReturnId.set(edge.destination.node_id, edge.source.node_id); + } - const hasPath = (startId: string, targetId: string): boolean => - startId === targetId || walk([startId], outgoing).has(targetId); + const reachableReturnIds = new Set(); + const boundaries = invocationNodes + .filter((node) => node.data.type === 'for') + .map((forNode) => { + const iterationTargets = dataEdges + .filter((edge) => edge.source.node_id === forNode.id && ITERATION_OUTPUT_FIELDS.has(edge.source.field)) + .map((edge) => edge.destination.node_id); + const reachableNodeIds = walk(iterationTargets, outgoing); + const reachableReturns = [...reachableNodeIds].filter( + (nodeId) => nodesById.get(nodeId)?.data.type === 'for_return' + ); + reachableReturns.forEach((nodeId) => reachableReturnIds.add(nodeId)); + const linkedReturnId = linkedReturnByForId.get(forNode.id); + const returnNodeId = linkedReturnId ?? (reachableReturns.length === 1 ? reachableReturns[0] : undefined); + let status: LoopBodyBoundaryStatus; + + if (duplicateLinkage && linkedReturnId !== undefined) { + status = 'duplicate_linkage'; + } else if (linkedReturnId === undefined) { + status = 'missing_linkage'; + } else if (!reachableNodeIds.has(linkedReturnId)) { + status = 'invalid_linkage'; + } else if (reachableReturns.length === 0) { + status = 'missing_return'; + } else if (reachableReturns.length > 1) { + status = 'multiple_returns'; + } else { + status = 'complete'; + } + + return { + ...(returnNodeId ? { returnNodeId } : {}), + bodyNodeIds: [forNode.id, ...getBodyNodeIds(reachableNodeIds, returnNodeId, incoming)], + forNodeId: forNode.id, + status, + }; + }); + + boundaries.push( + ...invocationNodes + .filter( + (node) => + node.data.type === 'for_return' && !linkedForByReturnId.has(node.id) && !reachableReturnIds.has(node.id) + ) + .map((node) => ({ + bodyNodeIds: [node.id], + returnNodeId: node.id, + status: 'orphan_return' as const, + })) + ); + + return boundaries; +}; + +/** Validates the scheduler-specific For/ForReturn graph contract before queueing. */ +export const validateForLoopGraph = ( + document: Pick +): ForLoopGraphError | null => { + const nodes = getInvocationNodes(document.nodes); + const nodesById = new Map(nodes.map((node) => [node.id, node])); + const allEdges = getCanonicalWorkflowEdges(document); + + if ( + allEdges.some( + (edge) => + edge.type === 'default' && + edge.source.field === LOOP_LINKAGE_FIELD && + edge.destination.field === LOOP_LINKAGE_FIELD && + nodesById.has(edge.source.node_id) && + nodesById.has(edge.destination.node_id) + ) + ) { + return 'nodes.forLoopLinkageInvalid'; + } + + const edges = allEdges.filter((edge) => edge.type !== 'loop_linkage'); + const linkageEdges = allEdges.filter((edge) => edge.type === 'loop_linkage'); + const outgoing = new Map(); + const incoming = new Map(); + + for (const edge of edges) { + outgoing.set(edge.source.node_id, [...(outgoing.get(edge.source.node_id) ?? []), edge.destination.node_id]); + incoming.set(edge.destination.node_id, [...(incoming.get(edge.destination.node_id) ?? []), edge.source.node_id]); + } const linkedReturnByForId = new Map(); const linkedForByReturnId = new Map(); + for (const edge of linkageEdges) { - const sourceNode = nodes[edge.source.node_id]; - const destinationNode = nodes[edge.destination.node_id]; + const sourceNode = nodesById.get(edge.source.node_id); + const returnNode = nodesById.get(edge.destination.node_id); + if ( edge.source.field !== LOOP_LINKAGE_FIELD || edge.destination.field !== LOOP_LINKAGE_FIELD || sourceNode?.type !== 'for' || - destinationNode?.type !== 'for_return' + returnNode?.type !== 'for_return' ) { return 'nodes.forLoopLinkageInvalid'; } + if (linkedReturnByForId.has(edge.source.node_id) || linkedForByReturnId.has(edge.destination.node_id)) { return 'nodes.forLoopLinkageDuplicate'; } + linkedReturnByForId.set(edge.source.node_id, edge.destination.node_id); linkedForByReturnId.set(edge.destination.node_id, edge.source.node_id); } - if ( - Object.values(nodes).some((node) => { - if (node.type === 'for') { - return !linkedReturnByForId.has(node.id); - } - if (node.type === 'for_return') { - return !linkedForByReturnId.has(node.id); - } - return false; - }) - ) { - return 'nodes.forLoopLinkageMissing'; + for (const node of nodes) { + if ( + (node.type === 'for' && !linkedReturnByForId.has(node.id)) || + (node.type === 'for_return' && !linkedForByReturnId.has(node.id)) + ) { + return 'nodes.forLoopLinkageMissing'; + } } + const hasPath = (startId: string, targetId: string): boolean => + startId === targetId || walk([startId], outgoing).has(targetId); + const supportsNestedIterateBody = ( bodyPathNodeIds: Set, iterateNodeIds: string[], @@ -146,6 +343,7 @@ export const validateForLoopGraph = (graph: Graph): ForLoopGraphError | null => if (collectCollectionEdges.length !== 0 || collectItemEdges.length !== 1) { return false; } + const collectItemSourceId = collectItemEdges[0]?.source.node_id; if (collectItemSourceId === undefined || !hasPath(iterateId, collectItemSourceId)) { return false; @@ -176,7 +374,7 @@ export const validateForLoopGraph = (graph: Graph): ForLoopGraphError | null => return null; } - const innerForIds = [...reachableBodyNodeIds].filter((nodeId) => nodes[nodeId]?.type === 'for'); + const innerForIds = [...reachableBodyNodeIds].filter((nodeId) => nodesById.get(nodeId)?.type === 'for'); const directInnerForIds = innerForIds.filter( (innerForId) => !innerForIds.some((otherInnerForId) => otherInnerForId !== innerForId && hasPath(otherInnerForId, innerForId)) @@ -184,25 +382,23 @@ export const validateForLoopGraph = (graph: Graph): ForLoopGraphError | null => if (directInnerForIds.length === 0) { return null; } + const innerBodyPathNodeIds = new Set(); for (const innerForId of directInnerForIds) { - if (innerForId === undefined) { - return null; - } - const innerIterationEdges = edges.filter( (edge) => edge.source.node_id === innerForId && ITERATION_OUTPUT_FIELDS.has(edge.source.field) ); if (innerIterationEdges.length === 0) { return null; } + const innerReachableBodyNodeIds = walk( innerIterationEdges.map((edge) => edge.destination.node_id), outgoing ); const innerReachableReturnIds = [...innerReachableBodyNodeIds].filter( - (nodeId) => nodes[nodeId]?.type === 'for_return' + (nodeId) => nodesById.get(nodeId)?.type === 'for_return' ); const innerReturnId = linkedReturnByForId.get(innerForId); if (innerReturnId === undefined || !innerReachableReturnIds.includes(innerReturnId)) { @@ -214,10 +410,12 @@ export const validateForLoopGraph = (graph: Graph): ForLoopGraphError | null => [...innerReachableBodyNodeIds].filter((nodeId) => nodeId === innerReturnId || innerReturnAncestors.has(nodeId)) ); childBodyPathNodeIds.add(innerReturnId); - const innerNestedForIds = [...childBodyPathNodeIds].filter((nodeId) => nodes[nodeId]?.type === 'for'); - if ([...childBodyPathNodeIds].some((nodeId) => nodes[nodeId]?.type === 'iterate')) { + + const innerNestedForIds = [...childBodyPathNodeIds].filter((nodeId) => nodesById.get(nodeId)?.type === 'for'); + if ([...childBodyPathNodeIds].some((nodeId) => nodesById.get(nodeId)?.type === 'iterate')) { return null; } + const innerNestedBody = innerNestedForIds.length > 0 ? getSupportedNestedForBody(innerForId, innerReachableBodyNodeIds, innerReachableReturnIds) @@ -258,6 +456,7 @@ export const validateForLoopGraph = (graph: Graph): ForLoopGraphError | null => innerBodyPathNodeIds.add(bodyNodeId); } } + if ( new Set(reachableReturnIds.filter((returnId) => !innerBodyPathNodeIds.has(returnId))).size !== 1 || !reachableReturnIds.includes(outerReturnId) @@ -271,6 +470,7 @@ export const validateForLoopGraph = (graph: Graph): ForLoopGraphError | null => if (outerReturnOutputEdges.length !== 1) { return null; } + const unsupportedOuterReturnInput = edges.some( (edge) => edge.destination.node_id === outerReturnId && @@ -291,6 +491,7 @@ export const validateForLoopGraph = (graph: Graph): ForLoopGraphError | null => } outerPreparationNodeIds.add(innerForId); } + const innerFinalDescendantNodeIds = new Set(); for (const innerForId of directInnerForIds) { for (const destinationId of edges @@ -301,6 +502,7 @@ export const validateForLoopGraph = (graph: Graph): ForLoopGraphError | null => } } } + const continuationNodeIds = new Set( [...reachableBodyNodeIds].filter( (nodeId) => @@ -326,10 +528,10 @@ export const validateForLoopGraph = (graph: Graph): ForLoopGraphError | null => return null; } if ( - [...continuationNodeIds].some( - (nodeId) => - nodes[nodeId]?.type === 'for' || nodes[nodeId]?.type === 'iterate' || nodes[nodeId]?.type === 'for_return' - ) + [...continuationNodeIds].some((nodeId) => { + const type = nodesById.get(nodeId)?.type; + return type === 'for' || type === 'iterate' || type === 'for_return'; + }) ) { return null; } @@ -345,6 +547,7 @@ export const validateForLoopGraph = (graph: Graph): ForLoopGraphError | null => ) { return null; } + const outerReturnOutputSource = outerReturnOutputEdges[0]?.source; if (outerReturnOutputSource !== undefined && directInnerForIds.includes(outerReturnOutputSource.node_id)) { if ( @@ -383,7 +586,8 @@ export const validateForLoopGraph = (graph: Graph): ForLoopGraphError | null => if ( [...outerPreparationNodeIds].some( (nodeId) => - !directInnerForIds.includes(nodeId) && (nodes[nodeId]?.type === 'for' || nodes[nodeId]?.type === 'iterate') + !directInnerForIds.includes(nodeId) && + (nodesById.get(nodeId)?.type === 'for' || nodesById.get(nodeId)?.type === 'iterate') ) ) { return null; @@ -402,21 +606,26 @@ export const validateForLoopGraph = (graph: Graph): ForLoopGraphError | null => const matchingForIdsByReturnId = new Map(); - for (const node of Object.values(nodes)) { + for (const node of nodes) { if (node.type !== 'for') { continue; } - const iterationEdges = edges.filter( - (edge) => edge.source.node_id === node.id && ITERATION_OUTPUT_FIELDS.has(edge.source.field) + const collectionInputs = edges.filter( + (edge) => edge.destination.node_id === node.id && edge.destination.field === 'collection' ); - if ( - edges.filter((edge) => edge.destination.node_id === node.id && edge.destination.field === 'collection').length > - 1 || - edges.filter((edge) => edge.destination.node_id === node.id && edge.destination.field === 'state').length > 1 - ) { + const stateInputs = edges.filter( + (edge) => edge.destination.node_id === node.id && edge.destination.field === 'state' + ); + + if (collectionInputs.length > 1 || stateInputs.length > 1) { return 'nodes.forLoopInputCount'; } + + const iterationEdges = edges.filter( + (edge) => edge.source.node_id === node.id && ITERATION_OUTPUT_FIELDS.has(edge.source.field) + ); + if (iterationEdges.length === 0) { return 'nodes.forLoopMissingIterationOutput'; } @@ -425,13 +634,22 @@ export const validateForLoopGraph = (graph: Graph): ForLoopGraphError | null => iterationEdges.map((edge) => edge.destination.node_id), outgoing ); - const reachableReturnIds = [...reachableBodyNodeIds].filter((nodeId) => nodes[nodeId]?.type === 'for_return'); + const reachableReturnIds = [...reachableBodyNodeIds].filter( + (nodeId) => nodesById.get(nodeId)?.type === 'for_return' + ); + const linkedReturnId = linkedReturnByForId.get(node.id); + + if (reachableBodyNodeIds.has(node.id)) { + return 'nodes.forLoopBodyEscape'; + } + const nestedForNodeIds = [...reachableBodyNodeIds].filter( (nodeId) => nodeId !== node.id && - nodes[nodeId]?.type === 'for' && + nodesById.get(nodeId)?.type === 'for' && ![...reachableBodyNodeIds].some( - (otherNodeId) => otherNodeId !== nodeId && nodes[otherNodeId]?.type === 'for' && hasPath(otherNodeId, nodeId) + (otherNodeId) => + otherNodeId !== nodeId && nodesById.get(otherNodeId)?.type === 'for' && hasPath(otherNodeId, nodeId) ) ); const nestedBody = @@ -440,7 +658,6 @@ export const validateForLoopGraph = (graph: Graph): ForLoopGraphError | null => return 'nodes.forLoopNestedUnsupported'; } - const linkedReturnId = linkedReturnByForId.get(node.id); if (nestedBody === null && (linkedReturnId === undefined || !reachableReturnIds.includes(linkedReturnId))) { return 'nodes.forLoopReturnCount'; } @@ -466,13 +683,13 @@ export const validateForLoopGraph = (graph: Graph): ForLoopGraphError | null => return 'nodes.forLoopUnterminatedBody'; } - const iterateNodeIds = [...bodyPathNodeIds].filter((nodeId) => nodes[nodeId]?.type === 'iterate'); + const iterateNodeIds = [...bodyPathNodeIds].filter((nodeId) => nodesById.get(nodeId)?.type === 'iterate'); if ( iterateNodeIds.length > 0 && !supportsNestedIterateBody( bodyPathNodeIds, iterateNodeIds, - [...bodyPathNodeIds].filter((nodeId) => nodes[nodeId]?.type === 'collect'), + [...bodyPathNodeIds].filter((nodeId) => nodesById.get(nodeId)?.type === 'collect'), returnId, node.id ) @@ -485,8 +702,8 @@ export const validateForLoopGraph = (graph: Graph): ForLoopGraphError | null => if (sourceId === node.id || bodyPathNodeIds.has(sourceId)) { continue; } - const activeSourceIds = walk([sourceId], incoming); - if ([...activeSourceIds].some((sourceNodeId) => nodes[sourceNodeId]?.type === 'iterate')) { + + if ([...walk([sourceId], incoming)].some((sourceNodeId) => nodesById.get(sourceNodeId)?.type === 'iterate')) { return 'nodes.forLoopIteratorInputUnsupported'; } } @@ -507,22 +724,27 @@ export const validateForLoopGraph = (graph: Graph): ForLoopGraphError | null => if (bodyNodeId === returnId) { continue; } + if ((outgoing.get(bodyNodeId) ?? []).some((destinationId) => !bodyPathNodeIds.has(destinationId))) { return 'nodes.forLoopBodyEscape'; } } } - for (const node of Object.values(nodes)) { - if (node.type === 'for_return' && matchingForIdsByReturnId.get(node.id)?.length !== 1) { + for (const node of nodes) { + if (node.type !== 'for_return') { + continue; + } + + if (matchingForIdsByReturnId.get(node.id)?.length !== 1) { return 'nodes.forReturnOwnership'; } + if ( - node.type === 'for_return' && - (edges.filter((edge) => edge.destination.node_id === node.id && edge.destination.field === 'output').length > 1 || - edges.filter((edge) => edge.destination.node_id === node.id && edge.destination.field === 'state').length > 1 || - edges.filter((edge) => edge.destination.node_id === node.id && edge.destination.field === 'continue_condition') - .length > 1) + edges.filter((edge) => edge.destination.node_id === node.id && edge.destination.field === 'output').length > 1 || + edges.filter((edge) => edge.destination.node_id === node.id && edge.destination.field === 'state').length > 1 || + edges.filter((edge) => edge.destination.node_id === node.id && edge.destination.field === 'continue_condition') + .length > 1 ) { return 'nodes.forReturnInputCount'; } diff --git a/invokeai/frontend/webv2/src/features/workflow/core/graphContracts.ts b/invokeai/frontend/webv2/src/features/workflow/core/graphContracts.ts index fb27419d2c7..b1ebc92dfb4 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/graphContracts.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/graphContracts.ts @@ -8,6 +8,7 @@ export interface WorkflowBackendGraph { id: string; nodes: Record; edges: Array<{ + type: 'default' | 'loop_linkage'; source: { node_id: string; field: string }; destination: { node_id: string; field: string }; }>; @@ -20,6 +21,7 @@ export interface CompiledWorkflowGraph { nodes: Array<{ id: string; type: string; inputs: Record }>; edges: Array<{ id: string; + type: 'default' | 'loop_linkage'; sourceNodeId: string; sourceField: string; targetNodeId: string; diff --git a/invokeai/frontend/webv2/src/features/workflow/core/graphToDocument.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/graphToDocument.test.ts index c2884a31b4b..c783613afc7 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/graphToDocument.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/graphToDocument.test.ts @@ -121,4 +121,43 @@ describe('previewGraphToDocument', () => { expect(compiled.edges).toHaveLength(1); expect(compiled.edges[0]).toMatchObject({ sourceNodeId: 'seed', targetField: 'seed' }); }); + + it('preserves a direct loop-linkage edge when opening a preview as a document', () => { + const loopTemplates = { + for: template('for', { collection: input('collection', { input: 'connection' }) }), + for_return: template('for_return', { + loop_linkage: input('loop_linkage', { input: 'connection' }), + output: input('output'), + }), + }; + const loopGraph: PreviewGraphLike = { + edges: [ + { + id: 'loop-edge', + sourceField: 'loop_linkage', + sourceNodeId: 'for', + targetField: 'loop_linkage', + targetNodeId: 'for-return', + type: 'loop_linkage', + }, + ], + nodes: [ + { id: 'for', inputs: {}, type: 'for' }, + { id: 'for-return', inputs: {}, type: 'for_return' }, + ], + }; + + const { document } = previewGraphToDocument(loopGraph, loopTemplates); + + expect(document.edges).toEqual([ + { + id: expect.any(String), + source: 'for', + sourceHandle: 'loop_linkage', + target: 'for-return', + targetHandle: 'loop_linkage', + type: 'loop_linkage', + }, + ]); + }); }); diff --git a/invokeai/frontend/webv2/src/features/workflow/core/graphToDocument.ts b/invokeai/frontend/webv2/src/features/workflow/core/graphToDocument.ts index 36322488e32..a3d34979896 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/graphToDocument.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/graphToDocument.ts @@ -11,7 +11,14 @@ import { getLayeredPositions } from './graphLayout'; export interface PreviewGraphLike { label?: string; nodes: Array<{ id: string; type: string; inputs: Record }>; - edges: Array<{ id: string; sourceField: string; sourceNodeId: string; targetField: string; targetNodeId: string }>; + edges: Array<{ + id: string; + sourceField: string; + sourceNodeId: string; + targetField: string; + targetNodeId: string; + type?: 'default' | 'loop_linkage'; + }>; } export interface PreviewGraphDocumentResult { @@ -101,7 +108,7 @@ export const previewGraphToDocument = ( sourceHandle: graphEdge.sourceField, target: graphEdge.targetNodeId, targetHandle: graphEdge.targetField, - type: 'default', + type: graphEdge.type ?? 'default', }); } diff --git a/invokeai/frontend/webv2/src/features/workflow/core/layerWorkflow.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/layerWorkflow.test.ts index daa9f139e9f..a875466654f 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/layerWorkflow.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/layerWorkflow.test.ts @@ -346,6 +346,7 @@ describe('buildLayerWorkflowGraph', () => { expect(built.graph.edges).toContainEqual({ destination: { field: 'image', node_id: built.outputNodeId }, source: { field: 'result', node_id: 'processor' }, + type: 'default', }); expect(Object.values(built.graph.nodes).every((graphNode) => graphNode.is_intermediate === true)).toBe(true); }); @@ -374,10 +375,12 @@ describe('buildLayerWorkflowGraph', () => { expect(built.graph.edges).toContainEqual({ destination: { field: 'image', node_id: 'layer-workflow-source' }, source: { field: 'image', node_id: 'layer-workflow-source-1' }, + type: 'default', }); expect(built.graph.edges).toContainEqual({ destination: { field: 'image', node_id: 'layer-workflow-output-1' }, source: { field: 'image', node_id: 'layer-workflow-output' }, + type: 'default', }); }); diff --git a/invokeai/frontend/webv2/src/features/workflow/core/layerWorkflow.ts b/invokeai/frontend/webv2/src/features/workflow/core/layerWorkflow.ts index 45493648dfb..51f0abf160c 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/layerWorkflow.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/layerWorkflow.ts @@ -280,6 +280,7 @@ export const buildLayerWorkflowGraph = (options: BuildLayerWorkflowGraphOptions) graph.edges.push({ destination: { field: input.fieldName, node_id: input.nodeId }, source: { field: 'image', node_id: sourceNodeId }, + type: 'default', }); } @@ -298,6 +299,7 @@ export const buildLayerWorkflowGraph = (options: BuildLayerWorkflowGraphOptions) graph.edges.push({ destination: { field: 'image', node_id: outputNodeId }, source: { field: output.fieldName, node_id: output.nodeId }, + type: 'default', }); return { graph, outputNodeId }; diff --git a/invokeai/frontend/webv2/src/features/workflow/core/outputFields.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/outputFields.test.ts new file mode 100644 index 00000000000..18f27db9c84 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/workflow/core/outputFields.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import { getOutputFieldNamesByScope, getOutputFieldRows } from './outputFields'; + +describe('output field scopes', () => { + it('groups visible outputs and keeps hidden scheduler outputs out of the node body', () => { + const fields = [ + { + name: 'loop_linkage', + title: 'Linkage', + description: '', + type: { name: 'AnyField', cardinality: 'SINGLE' as const, batch: false }, + }, + { + name: 'item', + title: 'Item', + description: '', + outputScope: 'iteration' as const, + type: { name: 'AnyField', cardinality: 'SINGLE' as const, batch: false }, + }, + { + name: 'output', + title: 'Output', + description: '', + uiHidden: true, + type: { name: 'AnyField', cardinality: 'SINGLE' as const, batch: false }, + }, + { + name: 'output_collection', + title: 'Collection', + description: '', + outputScope: 'final' as const, + type: { name: 'CollectionField', cardinality: 'COLLECTION' as const, batch: false }, + }, + ]; + + const names = getOutputFieldNamesByScope(fields); + + expect(names).toEqual({ + all: ['loop_linkage', 'item', 'output_collection'], + final: ['output_collection'], + iteration: ['item'], + unscoped: ['loop_linkage'], + }); + expect(getOutputFieldRows(names)).toEqual([ + { fieldName: 'loop_linkage', type: 'field' }, + { scope: 'iteration', type: 'header' }, + { fieldName: 'item', type: 'field' }, + { scope: 'final', type: 'header' }, + { fieldName: 'output_collection', type: 'field' }, + ]); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/workflow/core/outputFields.ts b/invokeai/frontend/webv2/src/features/workflow/core/outputFields.ts new file mode 100644 index 00000000000..e955b53bd58 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/workflow/core/outputFields.ts @@ -0,0 +1,42 @@ +import type { FieldOutputTemplate } from './types'; + +export interface OutputFieldNamesByScope { + all: string[]; + unscoped: string[]; + iteration: string[]; + final: string[]; +} + +export type OutputFieldRow = { type: 'field'; fieldName: string } | { type: 'header'; scope: 'iteration' | 'final' }; + +export const getOutputFieldNamesByScope = (fields: FieldOutputTemplate[]): OutputFieldNamesByScope => { + const all = fields.filter((field) => !field.uiHidden).map((field) => field.name); + const fieldsByName = new Map(fields.map((field) => [field.name, field])); + + return { + all, + final: all.filter((name) => fieldsByName.get(name)?.outputScope === 'final'), + iteration: all.filter((name) => fieldsByName.get(name)?.outputScope === 'iteration'), + unscoped: all.filter((name) => !fieldsByName.get(name)?.outputScope), + }; +}; + +export const getOutputFieldRows = (fieldNames: OutputFieldNamesByScope): OutputFieldRow[] => { + if (fieldNames.iteration.length === 0 && fieldNames.final.length === 0) { + return fieldNames.all.map((fieldName) => ({ fieldName, type: 'field' as const })); + } + + const rows: OutputFieldRow[] = fieldNames.unscoped.map((fieldName) => ({ fieldName, type: 'field' as const })); + + if (fieldNames.iteration.length > 0) { + rows.push({ scope: 'iteration', type: 'header' }); + rows.push(...fieldNames.iteration.map((fieldName) => ({ fieldName, type: 'field' as const }))); + } + + if (fieldNames.final.length > 0) { + rows.push({ scope: 'final', type: 'header' }); + rows.push(...fieldNames.final.map((fieldName) => ({ fieldName, type: 'field' as const }))); + } + + return rows; +}; diff --git a/invokeai/frontend/webv2/src/features/workflow/core/types.ts b/invokeai/frontend/webv2/src/features/workflow/core/types.ts index 385df5c7fcd..0a732556b96 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/types.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/types.ts @@ -55,6 +55,8 @@ export interface FieldOutputTemplate { title: string; description: string; type: FieldType; + outputScope?: 'iteration' | 'final'; + uiHidden?: boolean; } export interface InvocationTemplate { @@ -144,7 +146,7 @@ export type WorkflowNode = export interface WorkflowEdge { id: string; - type: 'default'; + type: 'default' | 'loop_linkage'; source: string; target: string; sourceHandle: string; diff --git a/invokeai/frontend/webv2/src/features/workflow/core/validation.ts b/invokeai/frontend/webv2/src/features/workflow/core/validation.ts index 0703896b001..de645e7cc55 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/validation.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/validation.ts @@ -14,10 +14,13 @@ import { CONNECTOR_OUTPUT_HANDLE, resolveConnectorSourceIndexed, resolveConnectorTargetsIndexed, + resolveLoopLinkagePath, } from './connectors'; import { createWorkflowGraphIndex, type WorkflowGraphIndex } from './graphIndex'; import { isConnectorNode, isInvocationNode } from './types'; +export const LOOP_LINKAGE_FIELD = 'loop_linkage'; + /** * Connection validation, ported from the legacy editor's * `validateConnectionTypes` / `validateConnection`. Connector nodes are @@ -106,6 +109,9 @@ export const wouldCreateCycle = (sourceNodeId: string, targetNodeId: string, edg visited.add(nodeId); for (const edge of edges) { + if (edge.type === 'loop_linkage') { + continue; + } if (edge.source === nodeId) { stack.push(edge.target); } @@ -120,6 +126,9 @@ export const hasAnyCycle = (nodes: WorkflowNode[], edges: WorkflowEdge[]): boole const adjacency = new Map(); for (const edge of edges) { + if (edge.type === 'loop_linkage') { + continue; + } adjacency.set(edge.source, [...(adjacency.get(edge.source) ?? []), edge.target]); } @@ -300,6 +309,78 @@ const hasValidSourceHandle = (node: WorkflowNode, handle: string, templates: Inv return isConnectorNode(node) && handle === CONNECTOR_OUTPUT_HANDLE; }; +const isLoopLinkageHandle = (handle: string): boolean => handle === LOOP_LINKAGE_FIELD; + +const isValidLoopLinkageConnection = ( + sourceNode: WorkflowNode, + sourceHandle: string, + targetNode: WorkflowNode, + targetHandle: string, + document: Pick, + index: WorkflowGraphIndex, + templates: InvocationTemplates +): boolean => { + if (isInvocationNode(sourceNode) && isInvocationNode(targetNode)) { + return ( + sourceNode.data.type === 'for' && + targetNode.data.type === 'for_return' && + sourceHandle === LOOP_LINKAGE_FIELD && + targetHandle === LOOP_LINKAGE_FIELD + ); + } + + if (isInvocationNode(sourceNode) && isConnectorNode(targetNode)) { + return ( + sourceNode.data.type === 'for' && sourceHandle === LOOP_LINKAGE_FIELD && targetHandle === CONNECTOR_INPUT_HANDLE + ); + } + + if (isConnectorNode(sourceNode) && isInvocationNode(targetNode)) { + if ( + targetNode.data.type !== 'for_return' || + targetHandle !== LOOP_LINKAGE_FIELD || + sourceHandle !== CONNECTOR_OUTPUT_HANDLE + ) { + return false; + } + + const resolvedSource = resolveConnectorSourceIndexed(sourceNode.id, index, templates); + const resolvedSourceNode = resolvedSource ? index.nodesById.get(resolvedSource.nodeId) : undefined; + + return ( + resolvedSource !== null && + resolvedSourceNode !== undefined && + isInvocationNode(resolvedSourceNode) && + resolvedSourceNode.data.type === 'for' && + resolvedSource.fieldName === LOOP_LINKAGE_FIELD && + resolveLoopLinkagePath( + { + id: '__candidate-loop-linkage__', + source: sourceNode.id, + sourceHandle, + target: targetNode.id, + targetHandle, + type: 'default', + }, + document.nodes, + [ + ...document.edges, + { + id: '__candidate-loop-linkage__', + source: sourceNode.id, + sourceHandle, + target: targetNode.id, + targetHandle, + type: 'default', + }, + ] + ) !== null + ); + } + + return false; +}; + /** Returns a human-readable rejection reason, or null when the connection is valid. */ export const validateConnection = ( candidate: ConnectionCandidate, @@ -324,6 +405,12 @@ export const validateConnection = ( return 'One of the fields has no known definition.'; } + if (isLoopLinkageHandle(sourceHandle) || isLoopLinkageHandle(targetHandle)) { + if (!isValidLoopLinkageConnection(sourceNode, sourceHandle, targetNode, targetHandle, document, index, templates)) { + return 'For loop linkage must connect a For to its ForReturn.'; + } + } + if (isConnectorNode(targetNode)) { if (targetHandle !== CONNECTOR_INPUT_HANDLE) { return 'Connectors only accept input on their left handle.'; diff --git a/invokeai/frontend/webv2/src/features/workflow/core/workflowJson.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/workflowJson.test.ts index f2e8d95b67c..a1ee7574bc0 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/workflowJson.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/workflowJson.test.ts @@ -175,6 +175,43 @@ describe('parseWorkflowJson tolerance', () => { ]); }); + it('round-trips a direct loop_linkage edge without inferring it from handles', () => { + const { document, warnings } = parseWorkflowJson({ + edges: [ + { + id: 'link', + source: 'for', + sourceHandle: 'loop_linkage', + target: 'return', + targetHandle: 'loop_linkage', + type: 'loop_linkage', + }, + ], + nodes: [ + { data: { id: 'for', inputs: {}, type: 'for' }, id: 'for', position: { x: 0, y: 0 }, type: 'invocation' }, + { + data: { id: 'return', inputs: {}, type: 'for_return' }, + id: 'return', + position: { x: 0, y: 0 }, + type: 'invocation', + }, + ], + }); + + expect(warnings).toEqual([]); + expect(document.edges[0]?.type).toBe('loop_linkage'); + expect(serializeWorkflowJson(document).edges).toEqual([ + { + id: 'link', + source: 'for', + sourceHandle: 'loop_linkage', + target: 'return', + targetHandle: 'loop_linkage', + type: 'loop_linkage', + }, + ]); + }); + it('drops dangling edges and unknown form elements with warnings', () => { const { document, warnings } = parseWorkflowJson({ edges: [{ id: 'e1', source: 'missing', sourceHandle: 'out', target: 'n1', targetHandle: 'in', type: 'default' }], diff --git a/invokeai/frontend/webv2/src/features/workflow/core/workflowJson.ts b/invokeai/frontend/webv2/src/features/workflow/core/workflowJson.ts index c64ea1afe22..858d0e72574 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/workflowJson.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/workflowJson.ts @@ -76,13 +76,13 @@ const zConnectorNode = z.object({ const zAnyNode = z.union([zInvocationNode, zNotesNode, zCurrentImageNode, zConnectorNode]); -const zDefaultEdge = z.object({ +const zWorkflowEdge = z.object({ id: z.string().catch(''), source: z.string().min(1), sourceHandle: z.string().min(1), target: z.string().min(1), targetHandle: z.string().min(1), - type: z.literal('default').catch('default'), + type: z.enum(['default', 'loop_linkage']).catch('default'), }); const zFieldIdentifier = z.object({ fieldName: z.string(), nodeId: z.string() }); @@ -304,7 +304,7 @@ export const parseWorkflowJson = (raw: unknown): ParsedWorkflow => { } const rawEdges = parsed.data.edges.flatMap((rawEdge) => { - const edgeResult = zDefaultEdge.safeParse(rawEdge); + const edgeResult = zWorkflowEdge.safeParse(rawEdge); return edgeResult.success ? [edgeResult.data] : []; }); @@ -324,7 +324,7 @@ export const parseWorkflowJson = (raw: unknown): ParsedWorkflow => { sourceHandle: edge.sourceHandle, target: edge.target, targetHandle: edge.targetHandle, - type: 'default', + type: edge.type, }); } diff --git a/invokeai/frontend/webv2/src/features/workflow/data/templates.test.ts b/invokeai/frontend/webv2/src/features/workflow/data/templates.test.ts index 1e49bd1bbd6..567524b9e80 100644 --- a/invokeai/frontend/webv2/src/features/workflow/data/templates.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/data/templates.test.ts @@ -238,6 +238,38 @@ describe('parseOpenApiToTemplates', () => { expect(templates.denoise?.outputs.latents?.type.name).toBe('LatentsField'); expect(templates.add?.outputs.value?.type.name).toBe('IntegerField'); }); + + it('preserves output scopes and hidden scheduler outputs', () => { + const parsed = parseOpenApiToTemplates({ + components: { + schemas: { + ForInvocation: { + class: 'invocation', + output: { $ref: '#/components/schemas/ForOutput' }, + properties: { type: { default: 'for' } }, + }, + ForOutput: { + class: 'output', + properties: { + item: { field_kind: 'output', output_scope: 'iteration', type: 'string' }, + output_collection: { + field_kind: 'output', + output_scope: 'final', + type: 'array', + items: { type: 'string' }, + }, + output: { field_kind: 'output', ui_hidden: true, type: 'string' }, + type: { default: 'for_output' }, + }, + }, + }, + }, + }); + + expect(parsed.for?.outputs.item?.outputScope).toBe('iteration'); + expect(parsed.for?.outputs.output_collection?.outputScope).toBe('final'); + expect(parsed.for?.outputs.output?.uiHidden).toBe(true); + }); }); describe('parseFieldType', () => { diff --git a/invokeai/frontend/webv2/src/features/workflow/data/templates.ts b/invokeai/frontend/webv2/src/features/workflow/data/templates.ts index 124d575797d..0e9800211bf 100644 --- a/invokeai/frontend/webv2/src/features/workflow/data/templates.ts +++ b/invokeai/frontend/webv2/src/features/workflow/data/templates.ts @@ -341,8 +341,13 @@ const parseInvocationSchema = (schema: JsonObject, schemas: JsonObject): Invocat outputs[name] = { description: typeof rawProperty.description === 'string' ? rawProperty.description : '', name, + outputScope: + rawProperty.output_scope === 'iteration' || rawProperty.output_scope === 'final' + ? rawProperty.output_scope + : undefined, title: typeof rawProperty.title === 'string' ? rawProperty.title : startCase(name), type: fieldType, + uiHidden: rawProperty.ui_hidden === true, }; } diff --git a/invokeai/frontend/webv2/src/features/workflow/graph.ts b/invokeai/frontend/webv2/src/features/workflow/graph.ts index d01f7b2bf7e..c3410038840 100644 --- a/invokeai/frontend/webv2/src/features/workflow/graph.ts +++ b/invokeai/frontend/webv2/src/features/workflow/graph.ts @@ -1,2 +1,3 @@ export * from './core/buildGraph'; +export * from './core/forLoops'; export * from './core/layerWorkflow'; diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/WorkflowWidgetChrome.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/WorkflowWidgetChrome.tsx index 6ce17c435aa..d8bc78a56cb 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/WorkflowWidgetChrome.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/WorkflowWidgetChrome.tsx @@ -14,6 +14,8 @@ import { createWorkflowId, getCompatibleInputTemplate, getCompatibleOutputTemplate, + LOOP_LINKAGE_FIELD, + resolveConnectorSource, parseWorkflowJson, serializeWorkflowJson, } from '@features/workflow/utility'; @@ -410,29 +412,76 @@ export const WorkflowDialogHost = () => { } if (addNodeConnection.kind === 'source') { - const targetInput = getCompatibleInputTemplate(template, addNodeConnection.sourceType); + const targetInput = + addNodeConnection.sourceHandle === LOOP_LINKAGE_FIELD && template.type === 'for_return' + ? template.inputs[LOOP_LINKAGE_FIELD] + : template.type === 'for_return' + ? template.inputs.output + : getCompatibleInputTemplate(template, addNodeConnection.sourceType); if (!targetInput) { editGraph({ node, type: 'addNode' }); return; } + const edge = { + id: createWorkflowId('edge'), + source: addNodeConnection.sourceNodeId, + sourceHandle: addNodeConnection.sourceHandle, + target: node.id, + targetHandle: targetInput.name, + type: + addNodeConnection.sourceHandle === LOOP_LINKAGE_FIELD && template.type === 'for_return' + ? ('loop_linkage' as const) + : ('default' as const), + }; + editGraph({ - edge: { - id: createWorkflowId('edge'), - source: addNodeConnection.sourceNodeId, - sourceHandle: addNodeConnection.sourceHandle, - target: node.id, - targetHandle: targetInput.name, - type: 'default', - }, + edge, node, type: 'addNodeAndEdge', }); + + const currentGraph = projectStore.getSnapshot().projectGraph; + const sourceNode = currentGraph.nodes.find((candidate) => candidate.id === addNodeConnection.sourceNodeId); + const resolvedSource = + sourceNode?.type === 'connector' + ? resolveConnectorSource(sourceNode.id, currentGraph.nodes, currentGraph.edges) + : sourceNode?.type === 'invocation' + ? { fieldName: addNodeConnection.sourceHandle, nodeId: sourceNode.id, type: null } + : null; + const resolvedSourceNode = currentGraph.nodes.find((candidate) => candidate.id === resolvedSource?.nodeId); + const shouldAddLoopLinkage = + template.type === 'for_return' && + addNodeConnection.sourceHandle !== LOOP_LINKAGE_FIELD && + ['item', 'index', 'total', 'state'].includes(addNodeConnection.sourceHandle) && + ['item', 'index', 'total', 'state'].includes(resolvedSource?.fieldName ?? '') && + resolvedSourceNode?.type === 'invocation' && + resolvedSourceNode.data.type === 'for' && + !currentGraph.edges.some( + (candidate) => candidate.source === resolvedSourceNode.id && candidate.sourceHandle === LOOP_LINKAGE_FIELD + ); + + if (shouldAddLoopLinkage && resolvedSourceNode?.type === 'invocation') { + editGraph({ + edge: { + id: createWorkflowId('edge'), + source: resolvedSourceNode.id, + sourceHandle: LOOP_LINKAGE_FIELD, + target: node.id, + targetHandle: LOOP_LINKAGE_FIELD, + type: 'loop_linkage', + }, + type: 'addEdge', + }); + } return; } - const sourceOutput = getCompatibleOutputTemplate(template, addNodeConnection.targetType); + const sourceOutput = + addNodeConnection.targetHandle === LOOP_LINKAGE_FIELD && template.type === 'for' + ? template.outputs[LOOP_LINKAGE_FIELD] + : getCompatibleOutputTemplate(template, addNodeConnection.targetType); if (!sourceOutput) { editGraph({ node, type: 'addNode' }); @@ -446,13 +495,16 @@ export const WorkflowDialogHost = () => { sourceHandle: sourceOutput.name, target: addNodeConnection.targetNodeId, targetHandle: addNodeConnection.targetHandle, - type: 'default', + type: + addNodeConnection.targetHandle === LOOP_LINKAGE_FIELD && template.type === 'for' + ? 'loop_linkage' + : 'default', }, node, type: 'addNodeAndEdge', }); }, - [addNodeConnection, editGraph, getInsertPosition] + [addNodeConnection, editGraph, getInsertPosition, projectStore] ); const addNote = useCallback(() => { diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/contracts.ts b/invokeai/frontend/webv2/src/features/workflow/ui/contracts.ts index 17545d7886e..d8eae6887a3 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/contracts.ts +++ b/invokeai/frontend/webv2/src/features/workflow/ui/contracts.ts @@ -86,6 +86,7 @@ export interface WorkflowPreviewGraph { sourceField: string; targetNodeId: string; targetField: string; + type?: 'default' | 'loop_linkage'; }>; backendGraph?: unknown; } diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/editor/AddNodeDialog.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/editor/AddNodeDialog.tsx index 62d336b2bde..b29585f977b 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/editor/AddNodeDialog.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/editor/AddNodeDialog.tsx @@ -1,10 +1,16 @@ -import type { InvocationTemplate } from '@features/workflow/contracts'; +import type { InvocationTemplate, InvocationTemplates } from '@features/workflow/contracts'; +import type { WorkflowNode } from '@features/workflow/core/types'; import type { AddNodeConnectionFilter } from '@features/workflow/ui/workflowUiStore'; import { Badge, Box, Dialog, HStack, Icon, Input, Portal, ScrollArea, Stack, Text } from '@chakra-ui/react'; import { useInvocationTemplatesSelector } from '@features/workflow/react'; import { useWorkflowUi } from '@features/workflow/ui/WorkflowUiContext'; -import { getCompatibleInputTemplate, getCompatibleOutputTemplate } from '@features/workflow/utility'; +import { + getCompatibleInputTemplate, + getCompatibleOutputTemplate, + LOOP_LINKAGE_FIELD, + resolveConnectorSource, +} from '@features/workflow/utility'; import { useMountEffect } from '@platform/react/useMountEffect'; import { IconButton, Tooltip } from '@platform/ui'; import { MiddleTruncate } from '@platform/ui/MiddleTruncate'; @@ -56,12 +62,50 @@ const isCompatibleConnectionTemplate = ( } if (connectionFilter.kind === 'source') { + if (connectionFilter.sourceHandle === LOOP_LINKAGE_FIELD) { + return template.type === 'for_return' && template.inputs[LOOP_LINKAGE_FIELD] !== undefined; + } + return getCompatibleInputTemplate(template, connectionFilter.sourceType) !== null; } + if (connectionFilter.targetHandle === LOOP_LINKAGE_FIELD) { + return template.type === 'for' && template.outputs[LOOP_LINKAGE_FIELD] !== undefined; + } + return getCompatibleOutputTemplate(template, connectionFilter.targetType) !== null; }; +const isForIterationOutputConnection = ( + connectionFilter: AddNodeConnectionFilter | null, + nodes: WorkflowNode[], + edges: Parameters[2], + templates: InvocationTemplates +): boolean => { + if (!connectionFilter || connectionFilter.kind !== 'source') { + return false; + } + + const sourceNode = nodes.find((node) => node.id === connectionFilter.sourceNodeId); + const resolvedSource = + sourceNode?.type === 'connector' + ? resolveConnectorSource(sourceNode.id, nodes, edges, templates) + : sourceNode?.type === 'invocation' + ? { fieldName: connectionFilter.sourceHandle, nodeId: sourceNode.id } + : null; + + if (!resolvedSource) { + return false; + } + + const resolvedSourceNode = nodes.find((node) => node.id === resolvedSource.nodeId); + return ( + resolvedSourceNode?.type === 'invocation' && + resolvedSourceNode.data.type === 'for' && + templates[resolvedSourceNode.data.type]?.outputs[resolvedSource.fieldName]?.outputScope === 'iteration' + ); +}; + const getConnectionFilterName = (connectionFilter: AddNodeConnectionFilter): string => { if (connectionFilter.kind === 'source') { return connectionFilter.sourceType?.name ?? 'connector'; @@ -266,7 +310,7 @@ const AddNodeDialogContent = ({ onAddNote: () => void; onOpenChange: (isOpen: boolean) => void; }) => { - const { registerModalHotkeyLayer } = useWorkflowUi(); + const { getProjectGraph, registerModalHotkeyLayer } = useWorkflowUi(); const error = useInvocationTemplatesSelector((snapshot) => snapshot.error); const status = useInvocationTemplatesSelector((snapshot) => snapshot.status); const templates = useInvocationTemplatesSelector((snapshot) => snapshot.templates); @@ -293,6 +337,13 @@ const AddNodeDialogContent = ({ const groups = useMemo(() => { const terms = searchTerm.trim().toLowerCase().split(/\s+/).filter(Boolean); + const projectGraph = getProjectGraph(); + const shouldPromoteForReturn = isForIterationOutputConnection( + connectionFilter, + projectGraph.nodes, + projectGraph.edges, + templates + ); const utilityRows: NodeRow[] = [ { description: 'Route a connection through a compact pass-through handle.', @@ -363,14 +414,48 @@ const AddNodeDialogContent = ({ const categoryGroups = [...byCategory.entries()] .map(([label, rows]) => ({ label, - rows: rows.sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: 'base' })), + rows: rows.sort((a, b) => { + if (shouldPromoteForReturn) { + if (a.key === 'template:for_return' && b.key !== 'template:for_return') { + return -1; + } + if (a.key !== 'template:for_return' && b.key === 'template:for_return') { + return 1; + } + } + + return a.title.localeCompare(b.title, undefined, { sensitivity: 'base' }); + }), })) - .sort((a, b) => a.label.localeCompare(b.label)); + .sort((a, b) => { + if (shouldPromoteForReturn) { + const aHasForReturn = a.rows.some((row) => row.key === 'template:for_return'); + const bHasForReturn = b.rows.some((row) => row.key === 'template:for_return'); + if (aHasForReturn && !bHasForReturn) { + return -1; + } + if (!aHasForReturn && bHasForReturn) { + return 1; + } + } + + return a.label.localeCompare(b.label); + }); return utilityRows.length > 0 ? [{ label: UTILITY_CATEGORY, rows: utilityRows }, ...categoryGroups] : categoryGroups; - }, [close, connectionFilter, onAddConnector, onAddCurrentImage, onAddNode, onAddNote, searchTerm, templates]); + }, [ + close, + connectionFilter, + getProjectGraph, + onAddConnector, + onAddCurrentImage, + onAddNode, + onAddNote, + searchTerm, + templates, + ]); const totalCount = groups.reduce((sum, group) => sum + group.rows.length, 0); const isAllExpanded = groups.length > 0 && groups.every((group) => expandedCategories.has(group.label)); diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/editor/InvocationFlowNode.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/editor/InvocationFlowNode.tsx index 8cfc369f68d..0f6b8395fd9 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/editor/InvocationFlowNode.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/editor/InvocationFlowNode.tsx @@ -18,6 +18,8 @@ import { useWorkflowNodeExecutionState } from '@features/workflow/ui/WorkflowUiC import { cloneWorkflowFieldDefault, getFieldTypeLabel, + getOutputFieldNamesByScope, + getOutputFieldRows, getWorkflowFieldInvalidReason, isDirectInputField, isExposableField, @@ -478,6 +480,18 @@ const OutputFieldRow = ({ isSkeleton, template }: { isSkeleton: boolean; templat ); }; +const OutputScopeHeader = ({ scope }: { scope: 'iteration' | 'final' }) => { + const { t } = useTranslation(); + + return ( + + + {scope === 'iteration' ? t('nodes.iterationOutputs') : t('nodes.finalOutputs')} + + + ); +}; + /** Keeps every handle mounted (invisible) so edges stay attached when rows are not rendered. */ const HiddenHandles = ({ inputTemplates, @@ -609,6 +623,8 @@ const ExpandedInvocationNode = ({ data, selected }: NodeProps [template.name, template])); + const outputRows = getOutputFieldRows(getOutputFieldNamesByScope(outputTemplates)); const isOpen = node.data.isOpen; const isRunning = execution?.status === 'running'; const isMissingRequiredInput = hasMissingRequiredInputs(node, Object.values(template.inputs), connectedFieldNames); @@ -647,9 +663,17 @@ const ExpandedInvocationNode = ({ data, selected }: NodeProps ) : isOpen ? ( - {outputTemplates.map((outputTemplate) => ( - - ))} + {outputRows.map((row) => + row.type === 'header' ? ( + + ) : ( + + ) + )} {inputTemplates.map((inputTemplate) => ( { - if (status === 'complete') { - return { - border: 'var(--invoke-colors-teal-400)', - text: 'var(--invoke-colors-teal-200)', - }; - } - return { - border: 'var(--invoke-colors-orange-400)', - text: 'var(--invoke-colors-orange-200)', - }; -}; +const BOUNDARY_PADDING = 24; -type Props = { - edges: AnyEdge[]; -}; +const getStatusColor = (status: LoopBodyBoundaryStatus) => + status === 'complete' ? { border: 'green.400', text: 'green.200' } : { border: 'orange.400', text: 'orange.200' }; -const LoopBodyBoundaryOverlay = ({ edges }: Props) => { +export const LoopBodyBoundaryOverlay = ({ edges }: { edges: WorkflowEdge[] }) => { const { t } = useTranslation(); - const nodes = useNodes(); - const { getNodesBounds } = useReactFlow(); - + const flowNodes = useNodes(); + const { getNodesBounds } = useReactFlow(); + const nodes = useMemo(() => flowNodes.map((node) => node.data.documentNode), [flowNodes]); const boundaries = useMemo(() => getForLoopBodyBoundaries(nodes, edges), [edges, nodes]); return ( {boundaries.map((boundary) => { const bounds = getNodesBounds(boundary.bodyNodeIds); + if (bounds.width <= 0 || bounds.height <= 0) { return null; } @@ -48,17 +38,17 @@ const LoopBodyBoundaryOverlay = ({ edges }: Props) => { return ( { top={-6} left={8} px={1} - bg="base.900" + bg="bg.canvas" color={colors.text} fontSize="xs" lineHeight="short" diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/editor/WorkflowEditorView.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/editor/WorkflowEditorView.tsx index 6974e015556..e13ca0fa545 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/editor/WorkflowEditorView.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/editor/WorkflowEditorView.tsx @@ -20,6 +20,7 @@ import { createWorkflowId, getWorkflowSourceFieldType, getWorkflowTargetFieldType, + LOOP_LINKAGE_FIELD, validateConnection, } from '@features/workflow/utility'; import { useModifierHeld } from '@platform/react/useModifierHeld'; @@ -72,6 +73,7 @@ import { type WorkflowFlowInstance, } from './flowInstanceStore'; import { InvocationFlowNode } from './InvocationFlowNode'; +import LoopBodyBoundaryOverlay from './LoopBodyBoundaryOverlay'; import { NodeContextMenu, type WorkflowContextMenuState } from './NodeContextMenu'; import { NotesFlowNode } from './NotesFlowNode'; import { @@ -95,6 +97,7 @@ const nodeTypes: NodeTypes = { const edgeTypes: EdgeTypes = { default: WorkflowEdge, + loop_linkage: WorkflowEdge, step: WorkflowEdge, }; @@ -735,7 +738,10 @@ const WorkflowFlow = ({ runtime }: { runtime: WorkflowRuntimeApi }) => { sourceHandle: connection.sourceHandle, target: connection.target, targetHandle: connection.targetHandle, - type: 'default', + type: + connection.sourceHandle === LOOP_LINKAGE_FIELD && connection.targetHandle === LOOP_LINKAGE_FIELD + ? 'loop_linkage' + : 'default', }, type: 'addEdge', }); @@ -1073,6 +1079,7 @@ const WorkflowFlow = ({ runtime }: { runtime: WorkflowRuntimeApi }) => { size={1.5} variant={BackgroundVariant.Dots} /> + { }); }); + it('renders direct loop linkage edges with their dedicated flow type and style', () => { + const doc = createDoc({ + edges: [{ ...createEdge('link', 'a', 'value', 'b', 'a'), type: 'loop_linkage' }], + }); + const rendered = toFlowEdges(doc, [], 'step', new Set(), createTemplates()); + + expect(rendered[0]).toMatchObject({ + data: { fieldTypeLabel: 'Loop linkage', isLoopLinkage: true, stroke: '#22c55e', strokeDasharray: '6 4' }, + type: 'loop_linkage', + }); + }); + it('replaces edges when templates resolve their field type styling', () => { const doc = createDoc(); const untyped = toFlowEdges(doc, [], 'default'); diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/editor/flowAdapters.ts b/invokeai/frontend/webv2/src/features/workflow/ui/editor/flowAdapters.ts index 13d2084f1fe..eb88ca2107b 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/editor/flowAdapters.ts +++ b/invokeai/frontend/webv2/src/features/workflow/ui/editor/flowAdapters.ts @@ -21,6 +21,7 @@ import { createWorkflowGraphIndex, getFieldTypeColor, getFieldTypeLabel, + getEdgesWithLoopLinkageAliases, getResolvedWorkflowEdgesIndexed, getWorkflowSourceFieldType, getWorkflowTargetFieldType, @@ -300,17 +301,19 @@ export const withNodeSelection = (nodes: WorkflowFlowNode[], selectedIds: Set { fieldTypeLabel: string; pathType: FlowEdgeType; + isLoopLinkage?: boolean; stroke: string; strokeDasharray?: string; strokeWidth: number; tooltip: string; } -export type WorkflowFlowEdge = FlowEdge; +export type WorkflowFlowEdge = FlowEdge; const UNKNOWN_EDGE_DATA = (pathType: FlowEdgeType): WorkflowEdgeData => ({ fieldTypeLabel: 'Unknown', @@ -347,7 +350,16 @@ export const getWorkflowEdgeData = ( const fieldType = getWorkflowEdgeFieldType(document, templates, edge, index); if (!fieldType) { - return UNKNOWN_EDGE_DATA(pathType); + return edge.type === 'loop_linkage' + ? { + ...UNKNOWN_EDGE_DATA(pathType), + fieldTypeLabel: 'Loop linkage', + isLoopLinkage: true, + stroke: '#22c55e', + strokeDasharray: '6 4', + tooltip: 'Loop linkage', + } + : UNKNOWN_EDGE_DATA(pathType); } const fieldTypeLabel = getFieldTypeLabel(fieldType); @@ -359,6 +371,18 @@ export const getWorkflowEdgeData = ( ? '8 3 2 3' : undefined; + if (edge.type === 'loop_linkage') { + return { + fieldTypeLabel: 'Loop linkage', + isLoopLinkage: true, + pathType, + stroke: '#22c55e', + strokeDasharray: '6 4', + strokeWidth: 2, + tooltip: 'Loop linkage', + }; + } + return { fieldTypeLabel, pathType, @@ -371,6 +395,7 @@ export const getWorkflowEdgeData = ( const isSameEdgeData = (a: WorkflowEdgeData | undefined, b: WorkflowEdgeData): boolean => a?.fieldTypeLabel === b.fieldTypeLabel && + a.isLoopLinkage === b.isLoopLinkage && a.pathType === b.pathType && a.stroke === b.stroke && a.strokeDasharray === b.strokeDasharray && @@ -388,9 +413,10 @@ export const toFlowEdges = ( ): WorkflowFlowEdge[] => { const previousById = new Map(previousEdges.map((edge) => [edge.id, edge])); - return document.edges.map((edge) => { + return getEdgesWithLoopLinkageAliases(document.nodes, document.edges).map((edge) => { const previous = previousById.get(edge.id); const data = getWorkflowEdgeData(document, edge, edgeType, templates, index); + const flowType: WorkflowFlowEdgeType = edge.type === 'loop_linkage' ? 'loop_linkage' : edgeType; const isConnectedToSelectedNode = selectedNodeIds.has(edge.source) || selectedNodeIds.has(edge.target); const animated = isConnectedToSelectedNode && !reduceMotion ? true : undefined; const className = isConnectedToSelectedNode ? SELECTED_NODE_EDGE_CLASS : undefined; @@ -399,7 +425,7 @@ export const toFlowEdges = ( if ( previous && - previous.type === edgeType && + previous.type === flowType && previous.source === edge.source && previous.sourceHandle === edge.sourceHandle && previous.target === edge.target && @@ -424,7 +450,7 @@ export const toFlowEdges = ( style, target: edge.target, targetHandle: edge.targetHandle, - type: edgeType, + type: flowType, zIndex, }; }); diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewFlow.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewFlow.tsx index 17a5e06fe59..c4a5915d5ba 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewFlow.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewFlow.tsx @@ -11,10 +11,10 @@ import type { TFunction } from 'i18next'; import { Badge, Box, Stack, Text } from '@chakra-ui/react'; import { isInvocationNode, type ProjectGraphState, type XYPosition } from '@features/workflow/contracts'; +import { getCanonicalWorkflowEdges } from '@features/workflow/core/forLoops'; import { getLayeredPositions } from '@features/workflow/core/graphLayout'; import '@xyflow/react/dist/style.css'; import { useWorkflowPreferencesSelector } from '@features/workflow/ui/WorkflowUiContext'; -import { getResolvedWorkflowEdges } from '@features/workflow/utility'; import { Background, BackgroundVariant, Handle, Position, ReactFlow } from '@xyflow/react'; import { useCallback, useId, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -48,14 +48,15 @@ export const documentToPreviewGraph = ( return { graph: { - edges: getResolvedWorkflowEdges(document.nodes, document.edges) - .filter((edge) => invocationNodeIds.has(edge.source) && invocationNodeIds.has(edge.target)) + edges: getCanonicalWorkflowEdges(document) + .filter((edge) => invocationNodeIds.has(edge.source.node_id) && invocationNodeIds.has(edge.destination.node_id)) .map((edge) => ({ id: edge.id, - sourceField: edge.sourceHandle, - sourceNodeId: edge.source, - targetField: edge.targetHandle, - targetNodeId: edge.target, + sourceField: edge.source.field, + sourceNodeId: edge.source.node_id, + targetField: edge.destination.field, + targetNodeId: edge.destination.node_id, + ...(edge.type ? { type: edge.type } : {}), })), id: document.id, label: document.name || fallbackLabel, @@ -140,6 +141,7 @@ const toPreviewEdges = (graph: WorkflowPreviewGraph): FlowEdge[] => source: edge.sourceNodeId, target: edge.targetNodeId, type: 'default', + ...(edge.type === 'loop_linkage' ? { style: { stroke: '#22c55e', strokeDasharray: '6 4', strokeWidth: 2 } } : {}), })); export const GraphPreviewFlow = ({ diff --git a/invokeai/frontend/webv2/src/features/workflow/utility.ts b/invokeai/frontend/webv2/src/features/workflow/utility.ts index d22b04a8e88..dd4eb11cc7d 100644 --- a/invokeai/frontend/webv2/src/features/workflow/utility.ts +++ b/invokeai/frontend/webv2/src/features/workflow/utility.ts @@ -1,7 +1,9 @@ export * from './core/connectorHandles'; export * from './core/connectors'; export * from './core/document'; +export * from './core/forLoops'; export * from './core/fields'; export * from './core/graphIndex'; +export * from './core/outputFields'; export * from './core/validation'; export * from './core/workflowJson'; diff --git a/invokeai/frontend/webv2/src/workbench/graphContracts.ts b/invokeai/frontend/webv2/src/workbench/graphContracts.ts index 6c03985f8a0..5aff3710e13 100644 --- a/invokeai/frontend/webv2/src/workbench/graphContracts.ts +++ b/invokeai/frontend/webv2/src/workbench/graphContracts.ts @@ -12,6 +12,8 @@ export interface GraphEdgeContract { sourceField: string; targetNodeId: string; targetField: string; + /** Direct loop-control edges retain their semantic type through queue snapshots. */ + type?: 'default' | 'loop_linkage'; } export interface GraphContract { @@ -31,6 +33,8 @@ export interface BackendInvocationContract { } export interface BackendGraphEdgeContract { + /** Direct loop-control edges are distinct from ordinary data-flow edges. */ + type?: 'default' | 'loop_linkage'; source: { node_id: string; field: string; diff --git a/invokeai/frontend/webv2/src/workbench/workbenchState.ts b/invokeai/frontend/webv2/src/workbench/workbenchState.ts index 85ba3adeaad..db04befdd23 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchState.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchState.ts @@ -619,6 +619,7 @@ const cloneGraph = (graph: GraphContract): GraphContract => ({ edges: graph.backendGraph.edges.map((edge) => ({ destination: { ...edge.destination }, source: { ...edge.source }, + ...(edge.type ? { type: edge.type } : {}), })), nodes: Object.fromEntries(Object.entries(graph.backendGraph.nodes).map(([id, node]) => [id, { ...node }])), } From 6b33b17aa88cda0a13c71bf2843d9eb60280796c Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Thu, 3 Sep 2026 00:46:13 -0500 Subject: [PATCH 4/5] fix: complete For loop V7 port validation --- invokeai/app/services/shared/graph.py | 4 +- invokeai/frontend/web/openapi.json | 1367 ++++++++++++++++- .../nodes/util/graph/generation/Graph.ts | 4 +- .../frontend/web/src/services/api/schema.ts | 574 ++++++- .../performance/architecture-baseline.json | 23 +- .../webv2/performance/browser-baseline.json | 277 ++-- .../frontend/webv2/public/locales/en.json | 15 + .../features/workflow/core/forLoops.test.ts | 40 +- .../src/features/workflow/core/forLoops.ts | 33 +- .../features/workflow/core/validation.test.ts | 174 +++ .../src/features/workflow/core/validation.ts | 198 ++- .../ui/graph-preview/GraphPreviewDialog.tsx | 6 +- .../shell/topbar/useInvocationState.ts | 31 +- tests/test_node_graph.py | 25 + 14 files changed, 2521 insertions(+), 250 deletions(-) diff --git a/invokeai/app/services/shared/graph.py b/invokeai/app/services/shared/graph.py index b1139c6994c..ec3bc12fe4a 100644 --- a/invokeai/app/services/shared/graph.py +++ b/invokeai/app/services/shared/graph.py @@ -3516,7 +3516,9 @@ def _is_for_connection_valid(self, node_id: str) -> str | None: return "For loop body does not support iterator-derived external inputs" for edge in self._get_for_final_output_edges(node_id): - if edge.destination.node_id in body_path_nodes: + if edge.destination.node_id in body_path_nodes or nx.has_path( + graph, edge.destination.node_id, return_node_id + ): return "final-scoped For outputs cannot feed the loop body" for body_node_id in body_path_nodes: diff --git a/invokeai/frontend/web/openapi.json b/invokeai/frontend/web/openapi.json index 56213d0e4c3..74324c4bce4 100644 --- a/invokeai/frontend/web/openapi.json +++ b/invokeai/frontend/web/openapi.json @@ -21309,6 +21309,294 @@ "title": "CollectInvocationOutput", "type": "object" }, + "CollectionCartesianInvocation": { + "category": "batch", + "class": "invocation", + "classification": "stable", + "description": "Emits every pair formed by one item from each collection, up to 100,000 pairs.", + "node_pack": "invokeai", + "properties": { + "id": { + "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", + "field_kind": "node_attribute", + "title": "Id", + "type": "string" + }, + "is_intermediate": { + "default": false, + "description": "Whether or not this is an intermediate invocation.", + "field_kind": "node_attribute", + "input": "direct", + "orig_required": true, + "title": "Is Intermediate", + "type": "boolean", + "ui_hidden": false, + "ui_type": "IsIntermediate" + }, + "use_cache": { + "default": true, + "description": "Whether or not to use the cache", + "field_kind": "node_attribute", + "title": "Use Cache", + "type": "boolean" + }, + "first": { + "default": [], + "description": "The first collection", + "field_kind": "input", + "input": "any", + "items": {}, + "orig_default": [], + "orig_required": false, + "title": "First", + "type": "array", + "ui_type": "CollectionField" + }, + "second": { + "default": [], + "description": "The second collection", + "field_kind": "input", + "input": "any", + "items": {}, + "orig_default": [], + "orig_required": false, + "title": "Second", + "type": "array", + "ui_type": "CollectionField" + }, + "type": { + "const": "collection_cartesian", + "default": "collection_cartesian", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["type", "id"], + "tags": ["collection", "cartesian", "product"], + "title": "Cartesian Product of Collections", + "type": "object", + "version": "1.0.0", + "output": { + "$ref": "#/components/schemas/CollectionCartesianInvocationOutput" + } + }, + "CollectionCartesianInvocationOutput": { + "class": "output", + "properties": { + "collection": { + "description": "The Cartesian product pairs", + "field_kind": "output", + "items": {}, + "title": "Collection", + "type": "array", + "ui_hidden": false, + "ui_type": "CollectionField" + }, + "type": { + "const": "collection_cartesian_output", + "default": "collection_cartesian_output", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["output_meta", "collection", "type", "type"], + "title": "CollectionCartesianInvocationOutput", + "type": "object" + }, + "CollectionConcatInvocation": { + "category": "batch", + "class": "invocation", + "classification": "stable", + "description": "Concatenates two collections in left-to-right order.", + "node_pack": "invokeai", + "properties": { + "id": { + "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", + "field_kind": "node_attribute", + "title": "Id", + "type": "string" + }, + "is_intermediate": { + "default": false, + "description": "Whether or not this is an intermediate invocation.", + "field_kind": "node_attribute", + "input": "direct", + "orig_required": true, + "title": "Is Intermediate", + "type": "boolean", + "ui_hidden": false, + "ui_type": "IsIntermediate" + }, + "use_cache": { + "default": true, + "description": "Whether or not to use the cache", + "field_kind": "node_attribute", + "title": "Use Cache", + "type": "boolean" + }, + "first": { + "default": [], + "description": "The first collection", + "field_kind": "input", + "input": "any", + "items": {}, + "orig_default": [], + "orig_required": false, + "title": "First", + "type": "array", + "ui_type": "CollectionField" + }, + "second": { + "default": [], + "description": "The second collection", + "field_kind": "input", + "input": "any", + "items": {}, + "orig_default": [], + "orig_required": false, + "title": "Second", + "type": "array", + "ui_type": "CollectionField" + }, + "type": { + "const": "collection_concat", + "default": "collection_concat", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["type", "id"], + "tags": ["collection", "concat", "sequential"], + "title": "Concatenate Collections", + "type": "object", + "version": "1.0.0", + "output": { + "$ref": "#/components/schemas/CollectionConcatInvocationOutput" + } + }, + "CollectionConcatInvocationOutput": { + "class": "output", + "properties": { + "collection": { + "description": "The concatenated collection", + "field_kind": "output", + "items": {}, + "title": "Collection", + "type": "array", + "ui_hidden": false, + "ui_type": "CollectionField" + }, + "type": { + "const": "collection_concat_output", + "default": "collection_concat_output", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["output_meta", "collection", "type", "type"], + "title": "CollectionConcatInvocationOutput", + "type": "object" + }, + "CollectionZipInvocation": { + "category": "batch", + "class": "invocation", + "classification": "stable", + "description": "Pairs items at matching positions from two equally sized collections.", + "node_pack": "invokeai", + "properties": { + "id": { + "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", + "field_kind": "node_attribute", + "title": "Id", + "type": "string" + }, + "is_intermediate": { + "default": false, + "description": "Whether or not this is an intermediate invocation.", + "field_kind": "node_attribute", + "input": "direct", + "orig_required": true, + "title": "Is Intermediate", + "type": "boolean", + "ui_hidden": false, + "ui_type": "IsIntermediate" + }, + "use_cache": { + "default": true, + "description": "Whether or not to use the cache", + "field_kind": "node_attribute", + "title": "Use Cache", + "type": "boolean" + }, + "first": { + "default": [], + "description": "The first collection", + "field_kind": "input", + "input": "any", + "items": {}, + "orig_default": [], + "orig_required": false, + "title": "First", + "type": "array", + "ui_type": "CollectionField" + }, + "second": { + "default": [], + "description": "The second collection", + "field_kind": "input", + "input": "any", + "items": {}, + "orig_default": [], + "orig_required": false, + "title": "Second", + "type": "array", + "ui_type": "CollectionField" + }, + "type": { + "const": "collection_zip", + "default": "collection_zip", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["type", "id"], + "tags": ["collection", "zip", "pair"], + "title": "Zip Collections", + "type": "object", + "version": "1.0.0", + "output": { + "$ref": "#/components/schemas/CollectionZipInvocationOutput" + } + }, + "CollectionZipInvocationOutput": { + "class": "output", + "properties": { + "collection": { + "description": "The positional pairs", + "field_kind": "output", + "items": {}, + "title": "Collection", + "type": "array", + "ui_hidden": false, + "ui_type": "CollectionField" + }, + "type": { + "const": "collection_zip_output", + "default": "collection_zip_output", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["output_meta", "collection", "type", "type"], + "title": "CollectionZipInvocationOutput", + "type": "object" + }, "ColorCollectionOutput": { "class": "output", "description": "Base class for nodes that output a collection of colors", @@ -27740,6 +28028,13 @@ }, "Edge": { "properties": { + "type": { + "type": "string", + "enum": ["default", "loop_linkage"], + "title": "Type", + "description": "The kind of relationship represented by this edge", + "default": "default" + }, "source": { "$ref": "#/components/schemas/EdgeConnection", "description": "The connection for the edge's from node and field" @@ -35633,64 +35928,392 @@ "title": "FluxVariantType", "description": "FLUX.1 model variants." }, - "FoundModel": { - "properties": { - "path": { - "type": "string", - "title": "Path", - "description": "Path to the model" - }, - "is_installed": { - "type": "boolean", - "title": "Is Installed", - "description": "Whether or not the model is already installed" - } - }, - "type": "object", - "required": ["path", "is_installed"], - "title": "FoundModel" - }, - "FreeUConfig": { - "description": "Configuration for the FreeU hyperparameters.\n- https://huggingface.co/docs/diffusers/main/en/using-diffusers/freeu\n- https://github.com/ChenyangSi/FreeU", - "properties": { - "s1": { - "description": "Scaling factor for stage 1 to attenuate the contributions of the skip features. This is done to mitigate the \"oversmoothing effect\" in the enhanced denoising process.", - "maximum": 3, - "minimum": -1, - "title": "S1", - "type": "number" - }, - "s2": { - "description": "Scaling factor for stage 2 to attenuate the contributions of the skip features. This is done to mitigate the \"oversmoothing effect\" in the enhanced denoising process.", - "maximum": 3, - "minimum": -1, - "title": "S2", - "type": "number" - }, - "b1": { - "description": "Scaling factor for stage 1 to amplify the contributions of backbone features.", - "maximum": 3, - "minimum": -1, - "title": "B1", - "type": "number" - }, - "b2": { - "description": "Scaling factor for stage 2 to amplify the contributions of backbone features.", - "maximum": 3, - "minimum": -1, - "title": "B2", - "type": "number" - } - }, - "required": ["s1", "s2", "b1", "b2"], - "title": "FreeUConfig", - "type": "object" - }, - "FreeUInvocation": { - "category": "model", + "ForInvocation": { "class": "invocation", "classification": "stable", - "description": "Applies FreeU to the UNet. Suggested values (b1/b2/s1/s2):\n\nSD1.5: 1.2/1.4/0.9/0.2,\nSD2: 1.1/1.2/0.9/0.2,\nSDXL: 1.1/1.2/0.6/0.4,", + "node_pack": "invokeai", + "properties": { + "id": { + "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", + "field_kind": "node_attribute", + "title": "Id", + "type": "string" + }, + "is_intermediate": { + "default": false, + "description": "Whether or not this is an intermediate invocation.", + "field_kind": "node_attribute", + "input": "direct", + "orig_required": true, + "title": "Is Intermediate", + "type": "boolean", + "ui_hidden": false, + "ui_type": "IsIntermediate" + }, + "use_cache": { + "default": true, + "description": "Whether or not to use the cache", + "field_kind": "node_attribute", + "title": "Use Cache", + "type": "boolean" + }, + "collection": { + "default": [], + "description": "The list of items to iterate over", + "field_kind": "input", + "input": "any", + "items": {}, + "orig_default": [], + "orig_required": false, + "title": "Collection", + "type": "array", + "ui_type": "CollectionField" + }, + "state": { + "anyOf": [ + { + "$ref": "#/components/schemas/LoopState" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional initial loop state", + "field_kind": "input", + "input": "any", + "orig_default": null, + "orig_required": false + }, + "index": { + "default": -1, + "description": "The internal iteration index for a prepared For execution node", + "field_kind": "input", + "input": "direct", + "orig_default": -1, + "orig_required": false, + "title": "Index", + "type": "integer", + "ui_hidden": true + }, + "type": { + "const": "for", + "default": "for", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["type", "id"], + "title": "ForInvocation", + "type": "object", + "version": "1.3.0", + "output": { + "$ref": "#/components/schemas/ForInvocationOutput" + } + }, + "ForInvocationOutput": { + "class": "output", + "properties": { + "loop_linkage": { + "description": "The loop linkage to the matching ForReturn", + "field_kind": "output", + "title": "Loop Linkage", + "ui_hidden": false, + "ui_type": "AnyField" + }, + "item": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "description": "The item for the current loop iteration, or None when the collection is empty", + "field_kind": "output", + "output_scope": "iteration", + "title": "Collection Item", + "ui_hidden": false, + "ui_type": "CollectionItemField" + }, + "index": { + "description": "The index for the current loop iteration", + "field_kind": "output", + "output_scope": "iteration", + "title": "Index", + "type": "integer", + "ui_hidden": false + }, + "total": { + "description": "The total number of items in the loop collection", + "field_kind": "output", + "output_scope": "iteration", + "title": "Total", + "type": "integer", + "ui_hidden": false + }, + "state": { + "$ref": "#/components/schemas/LoopState", + "description": "The state for the current loop iteration", + "field_kind": "output", + "output_scope": "iteration", + "title": "State", + "ui_hidden": false + }, + "output_collection": { + "description": "The collected loop body outputs", + "field_kind": "output", + "items": {}, + "output_scope": "final", + "title": "Output Collection", + "type": "array", + "ui_hidden": false, + "ui_type": "CollectionField" + }, + "final_state": { + "$ref": "#/components/schemas/LoopState", + "description": "The final loop state", + "field_kind": "output", + "output_scope": "final", + "title": "Final State", + "ui_hidden": false + }, + "type": { + "const": "for_output", + "default": "for_output", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": [ + "output_meta", + "loop_linkage", + "item", + "index", + "total", + "state", + "output_collection", + "final_state", + "type", + "type" + ], + "title": "ForInvocationOutput", + "type": "object" + }, + "ForReturnInvocation": { + "class": "invocation", + "classification": "stable", + "node_pack": "invokeai", + "properties": { + "id": { + "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", + "field_kind": "node_attribute", + "title": "Id", + "type": "string" + }, + "is_intermediate": { + "default": false, + "description": "Whether or not this is an intermediate invocation.", + "field_kind": "node_attribute", + "input": "direct", + "orig_required": true, + "title": "Is Intermediate", + "type": "boolean", + "ui_hidden": false, + "ui_type": "IsIntermediate" + }, + "use_cache": { + "default": true, + "description": "Whether or not to use the cache", + "field_kind": "node_attribute", + "title": "Use Cache", + "type": "boolean" + }, + "loop_linkage": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "description": "The loop linkage from the matching For", + "field_kind": "input", + "input": "connection", + "orig_default": null, + "orig_required": false, + "title": "Loop Linkage", + "ui_type": "AnyField" + }, + "output": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "description": "The output item to append to the loop output collection", + "field_kind": "input", + "input": "any", + "orig_default": null, + "orig_required": false, + "title": "Output", + "ui_type": "CollectionItemField" + }, + "state": { + "anyOf": [ + { + "$ref": "#/components/schemas/LoopState" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The state to pass to the next loop iteration", + "field_kind": "input", + "input": "any", + "orig_default": null, + "orig_required": false + }, + "continue_condition": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": true, + "description": "Whether to schedule the next loop iteration; false finalizes the loop", + "field_kind": "input", + "input": "any", + "orig_default": true, + "orig_required": false, + "title": "Continue Condition" + }, + "type": { + "const": "for_return", + "default": "for_return", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["type", "id"], + "title": "ForReturnInvocation", + "type": "object", + "version": "1.3.2", + "output": { + "$ref": "#/components/schemas/ForReturnInvocationOutput" + } + }, + "ForReturnInvocationOutput": { + "class": "output", + "properties": { + "output": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "description": "The output item to append to the loop output collection", + "field_kind": "output", + "title": "Output", + "ui_hidden": true, + "ui_type": "CollectionItemField" + }, + "state": { + "anyOf": [ + { + "$ref": "#/components/schemas/LoopState" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The state to pass to the next loop iteration", + "field_kind": "output", + "title": "State", + "ui_hidden": true + }, + "type": { + "const": "for_return_output", + "default": "for_return_output", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["output_meta", "output", "state", "type", "type"], + "title": "ForReturnInvocationOutput", + "type": "object" + }, + "FoundModel": { + "properties": { + "path": { + "type": "string", + "title": "Path", + "description": "Path to the model" + }, + "is_installed": { + "type": "boolean", + "title": "Is Installed", + "description": "Whether or not the model is already installed" + } + }, + "type": "object", + "required": ["path", "is_installed"], + "title": "FoundModel" + }, + "FreeUConfig": { + "description": "Configuration for the FreeU hyperparameters.\n- https://huggingface.co/docs/diffusers/main/en/using-diffusers/freeu\n- https://github.com/ChenyangSi/FreeU", + "properties": { + "s1": { + "description": "Scaling factor for stage 1 to attenuate the contributions of the skip features. This is done to mitigate the \"oversmoothing effect\" in the enhanced denoising process.", + "maximum": 3, + "minimum": -1, + "title": "S1", + "type": "number" + }, + "s2": { + "description": "Scaling factor for stage 2 to attenuate the contributions of the skip features. This is done to mitigate the \"oversmoothing effect\" in the enhanced denoising process.", + "maximum": 3, + "minimum": -1, + "title": "S2", + "type": "number" + }, + "b1": { + "description": "Scaling factor for stage 1 to amplify the contributions of backbone features.", + "maximum": 3, + "minimum": -1, + "title": "B1", + "type": "number" + }, + "b2": { + "description": "Scaling factor for stage 2 to amplify the contributions of backbone features.", + "maximum": 3, + "minimum": -1, + "title": "B2", + "type": "number" + } + }, + "required": ["s1", "s2", "b1", "b2"], + "title": "FreeUConfig", + "type": "object" + }, + "FreeUInvocation": { + "category": "model", + "class": "invocation", + "classification": "stable", + "description": "Applies FreeU to the UNet. Suggested values (b1/b2/s1/s2):\n\nSD1.5: 1.2/1.4/0.9/0.2,\nSD2: 1.1/1.2/0.9/0.2,\nSDXL: 1.1/1.2/0.6/0.4,", "node_pack": "invokeai", "properties": { "id": { @@ -36921,6 +37544,15 @@ { "$ref": "#/components/schemas/CollectInvocation" }, + { + "$ref": "#/components/schemas/CollectionCartesianInvocation" + }, + { + "$ref": "#/components/schemas/CollectionConcatInvocation" + }, + { + "$ref": "#/components/schemas/CollectionZipInvocation" + }, { "$ref": "#/components/schemas/ColorCorrectInvocation" }, @@ -37122,6 +37754,12 @@ { "$ref": "#/components/schemas/FluxVaeEncodeInvocation" }, + { + "$ref": "#/components/schemas/ForInvocation" + }, + { + "$ref": "#/components/schemas/ForReturnInvocation" + }, { "$ref": "#/components/schemas/FreeUInvocation" }, @@ -37635,6 +38273,18 @@ { "$ref": "#/components/schemas/SpandrelImageToImageInvocation" }, + { + "$ref": "#/components/schemas/StateEmptyInvocation" + }, + { + "$ref": "#/components/schemas/StateGetInvocation" + }, + { + "$ref": "#/components/schemas/StateMergeInvocation" + }, + { + "$ref": "#/components/schemas/StateSetInvocation" + }, { "$ref": "#/components/schemas/StringBatchInvocation" }, @@ -37869,6 +38519,15 @@ { "$ref": "#/components/schemas/CollectInvocationOutput" }, + { + "$ref": "#/components/schemas/CollectionCartesianInvocationOutput" + }, + { + "$ref": "#/components/schemas/CollectionConcatInvocationOutput" + }, + { + "$ref": "#/components/schemas/CollectionZipInvocationOutput" + }, { "$ref": "#/components/schemas/ColorCollectionOutput" }, @@ -37950,6 +38609,12 @@ { "$ref": "#/components/schemas/FluxReduxOutput" }, + { + "$ref": "#/components/schemas/ForInvocationOutput" + }, + { + "$ref": "#/components/schemas/ForReturnInvocationOutput" + }, { "$ref": "#/components/schemas/Gemma2EncoderOutput" }, @@ -38019,6 +38684,12 @@ { "$ref": "#/components/schemas/LoRASelectorOutput" }, + { + "$ref": "#/components/schemas/LoopStateOutput" + }, + { + "$ref": "#/components/schemas/LoopStateValueOutput" + }, { "$ref": "#/components/schemas/MDControlListOutput" }, @@ -38285,6 +38956,37 @@ "title": "Source Prepared Mapping", "description": "The map of original graph nodes to prepared nodes" }, + "finalized_loop_nodes": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": true, + "title": "Finalized Loop Nodes", + "description": "Legacy set of top-level loop source nodes whose final outputs have been materialized" + }, + "finalized_loop_contexts": { + "items": { + "prefixItems": [ + { + "type": "string" + }, + { + "items": { + "type": "integer" + }, + "type": "array" + } + ], + "type": "array", + "maxItems": 2, + "minItems": 2 + }, + "type": "array", + "uniqueItems": true, + "title": "Finalized Loop Contexts", + "description": "The finalized loop source and parent iteration contexts" + }, "prepared_iteration_paths": { "additionalProperties": { "items": { @@ -38324,7 +39026,8 @@ "workflow_call_stack", "workflow_call_history", "prepared_source_mapping", - "source_prepared_mapping" + "source_prepared_mapping", + "finalized_loop_nodes" ], "title": "GraphExecutionState", "description": "Tracks source-graph expansion, execution progress, and runtime results." @@ -46002,6 +46705,15 @@ { "$ref": "#/components/schemas/CollectInvocation" }, + { + "$ref": "#/components/schemas/CollectionCartesianInvocation" + }, + { + "$ref": "#/components/schemas/CollectionConcatInvocation" + }, + { + "$ref": "#/components/schemas/CollectionZipInvocation" + }, { "$ref": "#/components/schemas/ColorCorrectInvocation" }, @@ -46203,6 +46915,12 @@ { "$ref": "#/components/schemas/FluxVaeEncodeInvocation" }, + { + "$ref": "#/components/schemas/ForInvocation" + }, + { + "$ref": "#/components/schemas/ForReturnInvocation" + }, { "$ref": "#/components/schemas/FreeUInvocation" }, @@ -46716,6 +47434,18 @@ { "$ref": "#/components/schemas/SpandrelImageToImageInvocation" }, + { + "$ref": "#/components/schemas/StateEmptyInvocation" + }, + { + "$ref": "#/components/schemas/StateGetInvocation" + }, + { + "$ref": "#/components/schemas/StateMergeInvocation" + }, + { + "$ref": "#/components/schemas/StateSetInvocation" + }, { "$ref": "#/components/schemas/StringBatchInvocation" }, @@ -46907,6 +47637,15 @@ { "$ref": "#/components/schemas/CollectInvocationOutput" }, + { + "$ref": "#/components/schemas/CollectionCartesianInvocationOutput" + }, + { + "$ref": "#/components/schemas/CollectionConcatInvocationOutput" + }, + { + "$ref": "#/components/schemas/CollectionZipInvocationOutput" + }, { "$ref": "#/components/schemas/ColorCollectionOutput" }, @@ -46988,6 +47727,12 @@ { "$ref": "#/components/schemas/FluxReduxOutput" }, + { + "$ref": "#/components/schemas/ForInvocationOutput" + }, + { + "$ref": "#/components/schemas/ForReturnInvocationOutput" + }, { "$ref": "#/components/schemas/Gemma2EncoderOutput" }, @@ -47057,6 +47802,12 @@ { "$ref": "#/components/schemas/LoRASelectorOutput" }, + { + "$ref": "#/components/schemas/LoopStateOutput" + }, + { + "$ref": "#/components/schemas/LoopStateValueOutput" + }, { "$ref": "#/components/schemas/MDControlListOutput" }, @@ -47413,6 +48164,15 @@ { "$ref": "#/components/schemas/CollectInvocation" }, + { + "$ref": "#/components/schemas/CollectionCartesianInvocation" + }, + { + "$ref": "#/components/schemas/CollectionConcatInvocation" + }, + { + "$ref": "#/components/schemas/CollectionZipInvocation" + }, { "$ref": "#/components/schemas/ColorCorrectInvocation" }, @@ -47614,6 +48374,12 @@ { "$ref": "#/components/schemas/FluxVaeEncodeInvocation" }, + { + "$ref": "#/components/schemas/ForInvocation" + }, + { + "$ref": "#/components/schemas/ForReturnInvocation" + }, { "$ref": "#/components/schemas/FreeUInvocation" }, @@ -48127,6 +48893,18 @@ { "$ref": "#/components/schemas/SpandrelImageToImageInvocation" }, + { + "$ref": "#/components/schemas/StateEmptyInvocation" + }, + { + "$ref": "#/components/schemas/StateGetInvocation" + }, + { + "$ref": "#/components/schemas/StateMergeInvocation" + }, + { + "$ref": "#/components/schemas/StateSetInvocation" + }, { "$ref": "#/components/schemas/StringBatchInvocation" }, @@ -48409,6 +49187,15 @@ "collect": { "$ref": "#/components/schemas/CollectInvocationOutput" }, + "collection_cartesian": { + "$ref": "#/components/schemas/CollectionCartesianInvocationOutput" + }, + "collection_concat": { + "$ref": "#/components/schemas/CollectionConcatInvocationOutput" + }, + "collection_zip": { + "$ref": "#/components/schemas/CollectionZipInvocationOutput" + }, "color": { "$ref": "#/components/schemas/ColorOutput" }, @@ -48610,6 +49397,12 @@ "flux_vae_encode": { "$ref": "#/components/schemas/LatentsOutput" }, + "for": { + "$ref": "#/components/schemas/ForInvocationOutput" + }, + "for_return": { + "$ref": "#/components/schemas/ForReturnInvocationOutput" + }, "freeu": { "$ref": "#/components/schemas/UNetOutput" }, @@ -49123,6 +49916,18 @@ "spandrel_image_to_image_autoscale": { "$ref": "#/components/schemas/ImageOutput" }, + "state_empty": { + "$ref": "#/components/schemas/LoopStateOutput" + }, + "state_get": { + "$ref": "#/components/schemas/LoopStateValueOutput" + }, + "state_merge": { + "$ref": "#/components/schemas/LoopStateOutput" + }, + "state_set": { + "$ref": "#/components/schemas/LoopStateOutput" + }, "string": { "$ref": "#/components/schemas/StringOutput" }, @@ -49305,6 +50110,9 @@ "cogview4_model_loader", "cogview4_text_encoder", "collect", + "collection_cartesian", + "collection_concat", + "collection_zip", "color", "color_correct", "color_map", @@ -49372,6 +50180,8 @@ "flux_text_encoder", "flux_vae_decode", "flux_vae_encode", + "for", + "for_return", "freeu", "gemini_image_generation", "gemma2_encoder_loader", @@ -49543,6 +50353,10 @@ "show_image", "spandrel_image_to_image", "spandrel_image_to_image_autoscale", + "state_empty", + "state_get", + "state_merge", + "state_set", "string", "string_batch", "string_collection", @@ -49765,6 +50579,15 @@ { "$ref": "#/components/schemas/CollectInvocation" }, + { + "$ref": "#/components/schemas/CollectionCartesianInvocation" + }, + { + "$ref": "#/components/schemas/CollectionConcatInvocation" + }, + { + "$ref": "#/components/schemas/CollectionZipInvocation" + }, { "$ref": "#/components/schemas/ColorCorrectInvocation" }, @@ -49966,6 +50789,12 @@ { "$ref": "#/components/schemas/FluxVaeEncodeInvocation" }, + { + "$ref": "#/components/schemas/ForInvocation" + }, + { + "$ref": "#/components/schemas/ForReturnInvocation" + }, { "$ref": "#/components/schemas/FreeUInvocation" }, @@ -50479,6 +51308,18 @@ { "$ref": "#/components/schemas/SpandrelImageToImageInvocation" }, + { + "$ref": "#/components/schemas/StateEmptyInvocation" + }, + { + "$ref": "#/components/schemas/StateGetInvocation" + }, + { + "$ref": "#/components/schemas/StateMergeInvocation" + }, + { + "$ref": "#/components/schemas/StateSetInvocation" + }, { "$ref": "#/components/schemas/StringBatchInvocation" }, @@ -50861,6 +51702,15 @@ { "$ref": "#/components/schemas/CollectInvocation" }, + { + "$ref": "#/components/schemas/CollectionCartesianInvocation" + }, + { + "$ref": "#/components/schemas/CollectionConcatInvocation" + }, + { + "$ref": "#/components/schemas/CollectionZipInvocation" + }, { "$ref": "#/components/schemas/ColorCorrectInvocation" }, @@ -51062,6 +51912,12 @@ { "$ref": "#/components/schemas/FluxVaeEncodeInvocation" }, + { + "$ref": "#/components/schemas/ForInvocation" + }, + { + "$ref": "#/components/schemas/ForReturnInvocation" + }, { "$ref": "#/components/schemas/FreeUInvocation" }, @@ -51575,6 +52431,18 @@ { "$ref": "#/components/schemas/SpandrelImageToImageInvocation" }, + { + "$ref": "#/components/schemas/StateEmptyInvocation" + }, + { + "$ref": "#/components/schemas/StateGetInvocation" + }, + { + "$ref": "#/components/schemas/StateMergeInvocation" + }, + { + "$ref": "#/components/schemas/StateSetInvocation" + }, { "$ref": "#/components/schemas/StringBatchInvocation" }, @@ -59254,6 +60122,67 @@ "title": "LogoutResponse", "description": "Response from logout." }, + "LoopState": { + "properties": { + "values": { + "additionalProperties": true, + "title": "Values", + "type": "object" + } + }, + "title": "LoopState", + "type": "object" + }, + "LoopStateOutput": { + "class": "output", + "properties": { + "state": { + "$ref": "#/components/schemas/LoopState", + "description": "The loop state", + "field_kind": "output", + "ui_hidden": false + }, + "type": { + "const": "loop_state_output", + "default": "loop_state_output", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["output_meta", "state", "type", "type"], + "title": "LoopStateOutput", + "type": "object" + }, + "LoopStateValueOutput": { + "class": "output", + "properties": { + "value": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "description": "The value read from the loop state, or None when the key is missing", + "field_kind": "output", + "title": "Value", + "ui_hidden": false, + "ui_type": "AnyField" + }, + "type": { + "const": "loop_state_value_output", + "default": "loop_state_value_output", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["output_meta", "value", "type", "type"], + "title": "LoopStateValueOutput", + "type": "object" + }, "LoraModelDefaultSettings": { "properties": { "weight": { @@ -76988,12 +77917,29 @@ } ], "default": null + }, + "output_scope": { + "anyOf": [ + { + "$ref": "#/components/schemas/OutputScope" + }, + { + "type": "null" + } + ], + "default": null } }, - "required": ["field_kind", "ui_hidden", "ui_order", "ui_type"], + "required": ["field_kind", "ui_hidden", "ui_order", "ui_type", "output_scope"], "title": "OutputFieldJSONSchemaExtra", "type": "object" }, + "OutputScope": { + "description": "The execution scope for an output field.\n- `Iteration`: The field emits values for a loop body's current iteration.\n- `Final`: The field emits values after a loop boundary completes.", + "enum": ["iteration", "final"], + "title": "OutputScope", + "type": "string" + }, "PBRMapsInvocation": { "category": "controlnet_preprocessors", "class": "invocation", @@ -88241,6 +89187,309 @@ "required": ["description", "source", "name", "base", "type"], "title": "StarterModelWithoutDependencies" }, + "StateEmptyInvocation": { + "category": "workflow", + "class": "invocation", + "classification": "stable", + "description": "Creates an empty loop state.", + "node_pack": "invokeai", + "properties": { + "id": { + "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", + "field_kind": "node_attribute", + "title": "Id", + "type": "string" + }, + "is_intermediate": { + "default": false, + "description": "Whether or not this is an intermediate invocation.", + "field_kind": "node_attribute", + "input": "direct", + "orig_required": true, + "title": "Is Intermediate", + "type": "boolean", + "ui_hidden": false, + "ui_type": "IsIntermediate" + }, + "use_cache": { + "default": true, + "description": "Whether or not to use the cache", + "field_kind": "node_attribute", + "title": "Use Cache", + "type": "boolean" + }, + "type": { + "const": "state_empty", + "default": "state_empty", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["type", "id"], + "tags": ["loop", "state"], + "title": "Empty Loop State", + "type": "object", + "version": "1.0.0", + "output": { + "$ref": "#/components/schemas/LoopStateOutput" + } + }, + "StateGetInvocation": { + "category": "workflow", + "class": "invocation", + "classification": "stable", + "description": "Reads a value from loop state.", + "node_pack": "invokeai", + "properties": { + "id": { + "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", + "field_kind": "node_attribute", + "title": "Id", + "type": "string" + }, + "is_intermediate": { + "default": false, + "description": "Whether or not this is an intermediate invocation.", + "field_kind": "node_attribute", + "input": "direct", + "orig_required": true, + "title": "Is Intermediate", + "type": "boolean", + "ui_hidden": false, + "ui_type": "IsIntermediate" + }, + "use_cache": { + "default": true, + "description": "Whether or not to use the cache", + "field_kind": "node_attribute", + "title": "Use Cache", + "type": "boolean" + }, + "state": { + "anyOf": [ + { + "$ref": "#/components/schemas/LoopState" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The loop state to read", + "field_kind": "input", + "input": "any", + "orig_required": true + }, + "key": { + "default": "", + "description": "The state key to read", + "field_kind": "input", + "input": "any", + "orig_default": "", + "orig_required": false, + "title": "Key", + "type": "string" + }, + "default": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "description": "The value to return when the key is missing", + "input": "any", + "field_kind": "input", + "orig_required": false, + "orig_default": null, + "ui_type": "AnyField", + "title": "Default" + }, + "type": { + "const": "state_get", + "default": "state_get", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["type", "id"], + "tags": ["loop", "state"], + "title": "Get Loop State Value", + "type": "object", + "version": "1.0.2", + "output": { + "$ref": "#/components/schemas/LoopStateValueOutput" + } + }, + "StateMergeInvocation": { + "category": "workflow", + "class": "invocation", + "classification": "stable", + "description": "Returns loop state with multiple values merged.", + "node_pack": "invokeai", + "properties": { + "id": { + "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", + "field_kind": "node_attribute", + "title": "Id", + "type": "string" + }, + "is_intermediate": { + "default": false, + "description": "Whether or not this is an intermediate invocation.", + "field_kind": "node_attribute", + "input": "direct", + "orig_required": true, + "title": "Is Intermediate", + "type": "boolean", + "ui_hidden": false, + "ui_type": "IsIntermediate" + }, + "use_cache": { + "default": true, + "description": "Whether or not to use the cache", + "field_kind": "node_attribute", + "title": "Use Cache", + "type": "boolean" + }, + "state": { + "anyOf": [ + { + "$ref": "#/components/schemas/LoopState" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The loop state to update", + "field_kind": "input", + "input": "any", + "orig_default": null, + "orig_required": false + }, + "values": { + "additionalProperties": true, + "default": {}, + "description": "The values to merge into the loop state. Connect an output to this input.", + "field_kind": "input", + "input": "any", + "orig_default": {}, + "orig_required": false, + "title": "Values", + "type": "object", + "ui_type": "AnyField" + }, + "type": { + "const": "state_merge", + "default": "state_merge", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["type", "id"], + "tags": ["loop", "state"], + "title": "Merge Loop State Values", + "type": "object", + "version": "1.0.1", + "output": { + "$ref": "#/components/schemas/LoopStateOutput" + } + }, + "StateSetInvocation": { + "category": "workflow", + "class": "invocation", + "classification": "stable", + "description": "Returns loop state with one value set.", + "node_pack": "invokeai", + "properties": { + "id": { + "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", + "field_kind": "node_attribute", + "title": "Id", + "type": "string" + }, + "is_intermediate": { + "default": false, + "description": "Whether or not this is an intermediate invocation.", + "field_kind": "node_attribute", + "input": "direct", + "orig_required": true, + "title": "Is Intermediate", + "type": "boolean", + "ui_hidden": false, + "ui_type": "IsIntermediate" + }, + "use_cache": { + "default": true, + "description": "Whether or not to use the cache", + "field_kind": "node_attribute", + "title": "Use Cache", + "type": "boolean" + }, + "state": { + "anyOf": [ + { + "$ref": "#/components/schemas/LoopState" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The loop state to update", + "field_kind": "input", + "input": "any", + "orig_default": null, + "orig_required": false + }, + "key": { + "default": "", + "description": "The state key to set", + "field_kind": "input", + "input": "any", + "orig_default": "", + "orig_required": false, + "title": "Key", + "type": "string" + }, + "value": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "description": "The value to set. Connect an output to this input.", + "field_kind": "input", + "input": "any", + "orig_default": null, + "orig_required": false, + "title": "Value", + "ui_type": "AnyField" + }, + "type": { + "const": "state_set", + "default": "state_set", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["type", "id"], + "tags": ["loop", "state"], + "title": "Set Loop State Value", + "type": "object", + "version": "1.0.1", + "output": { + "$ref": "#/components/schemas/LoopStateOutput" + } + }, "String2Output": { "class": "output", "description": "Base class for invocations that output two strings", @@ -92193,7 +93442,7 @@ "type": "object" }, "UIType": { - "description": "Type hints for the UI for situations in which the field type is not enough to infer the correct UI type.\n\n- Model Fields\nThe most common node-author-facing use will be for model fields. Internally, there is no difference\nbetween SD-1, SD-2 and SDXL model fields - they all use the class `MainModelField`. To ensure the\nbase-model-specific UI is rendered, use e.g. `ui_type=UIType.SDXLMainModelField` to indicate that\nthe field is an SDXL main model field.\n\n- Any Field\nWe cannot infer the usage of `typing.Any` via schema parsing, so you *must* use `ui_type=UIType.Any` to\nindicate that the field accepts any type. Use with caution. This cannot be used on outputs.\n\n- Scheduler Field\nSpecial handling in the UI is needed for this field, which otherwise would be parsed as a plain enum field.\n\n- Internal Fields\nSimilar to the Any Field, the `collect` and `iterate` nodes make use of `typing.Any`. To facilitate\nhandling these types in the client, we use `UIType._Collection` and `UIType._CollectionItem`. These\nshould not be used by node authors.\n\n- DEPRECATED Fields\nThese types are deprecated and should not be used by node authors. A warning will be logged if one is\nused, and the type will be ignored. They are included here for backwards compatibility.", + "description": "Type hints for the UI for situations in which the field type is not enough to infer the correct UI type.\n\n- Model Fields\nThe most common node-author-facing use will be for model fields. Internally, there is no difference\nbetween SD-1, SD-2 and SDXL model fields - they all use the class `MainModelField`. To ensure the\nbase-model-specific UI is rendered, use e.g. `ui_type=UIType.SDXLMainModelField` to indicate that\nthe field is an SDXL main model field.\n\n- Any Field\nWe cannot infer the usage of `typing.Any` via schema parsing, so you *must* use `ui_type=UIType.Any` to\nindicate that the field accepts any type. Use with caution. On inputs, this renders as a connection-only field.\n\n- Scheduler Field\nSpecial handling in the UI is needed for this field, which otherwise would be parsed as a plain enum field.\n\n- Internal Fields\nSimilar to the Any Field, the `collect` and `iterate` nodes make use of `typing.Any`. To facilitate\nhandling these types in the client, we use `UIType._Collection` and `UIType._CollectionItem`. These\nshould not be used by node authors.\n\n- DEPRECATED Fields\nThese types are deprecated and should not be used by node authors. A warning will be logged if one is\nused, and the type will be ignored. They are included here for backwards compatibility.", "enum": [ "SchedulerField", "AnyField", diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/Graph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/Graph.ts index db94b946e8f..a80adcda64b 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/Graph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/Graph.ts @@ -125,7 +125,9 @@ export class Graph { }); } - Object.assign(node, changes); + // Keep the runtime mutation generic without asking TypeScript to expand the full + // generated invocation union for Object.assign's inferred intersection type. + Object.assign(node as object, changes as object); return node; } diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 308287d9ece..72196157145 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -7425,6 +7425,171 @@ export type components = { */ type: "collect_output"; }; + /** + * Cartesian Product of Collections + * @description Emits every pair formed by one item from each collection, up to 100,000 pairs. + */ + CollectionCartesianInvocation: { + /** + * Id + * @description The id of this instance of an invocation. Must be unique among all instances of invocations. + */ + id: string; + /** + * Is Intermediate + * @description Whether or not this is an intermediate invocation. + * @default false + */ + is_intermediate?: boolean; + /** + * Use Cache + * @description Whether or not to use the cache + * @default true + */ + use_cache?: boolean; + /** + * First + * @description The first collection + * @default [] + */ + first?: unknown[]; + /** + * Second + * @description The second collection + * @default [] + */ + second?: unknown[]; + /** + * type + * @default collection_cartesian + * @constant + */ + type: "collection_cartesian"; + }; + /** CollectionCartesianInvocationOutput */ + CollectionCartesianInvocationOutput: { + /** + * Collection + * @description The Cartesian product pairs + */ + collection: unknown[]; + /** + * type + * @default collection_cartesian_output + * @constant + */ + type: "collection_cartesian_output"; + }; + /** + * Concatenate Collections + * @description Concatenates two collections in left-to-right order. + */ + CollectionConcatInvocation: { + /** + * Id + * @description The id of this instance of an invocation. Must be unique among all instances of invocations. + */ + id: string; + /** + * Is Intermediate + * @description Whether or not this is an intermediate invocation. + * @default false + */ + is_intermediate?: boolean; + /** + * Use Cache + * @description Whether or not to use the cache + * @default true + */ + use_cache?: boolean; + /** + * First + * @description The first collection + * @default [] + */ + first?: unknown[]; + /** + * Second + * @description The second collection + * @default [] + */ + second?: unknown[]; + /** + * type + * @default collection_concat + * @constant + */ + type: "collection_concat"; + }; + /** CollectionConcatInvocationOutput */ + CollectionConcatInvocationOutput: { + /** + * Collection + * @description The concatenated collection + */ + collection: unknown[]; + /** + * type + * @default collection_concat_output + * @constant + */ + type: "collection_concat_output"; + }; + /** + * Zip Collections + * @description Pairs items at matching positions from two equally sized collections. + */ + CollectionZipInvocation: { + /** + * Id + * @description The id of this instance of an invocation. Must be unique among all instances of invocations. + */ + id: string; + /** + * Is Intermediate + * @description Whether or not this is an intermediate invocation. + * @default false + */ + is_intermediate?: boolean; + /** + * Use Cache + * @description Whether or not to use the cache + * @default true + */ + use_cache?: boolean; + /** + * First + * @description The first collection + * @default [] + */ + first?: unknown[]; + /** + * Second + * @description The second collection + * @default [] + */ + second?: unknown[]; + /** + * type + * @default collection_zip + * @constant + */ + type: "collection_zip"; + }; + /** CollectionZipInvocationOutput */ + CollectionZipInvocationOutput: { + /** + * Collection + * @description The positional pairs + */ + collection: unknown[]; + /** + * type + * @default collection_zip_output + * @constant + */ + type: "collection_zip_output"; + }; /** * ColorCollectionOutput * @description Base class for nodes that output a collection of colors @@ -10609,6 +10774,13 @@ export type components = { }; /** Edge */ Edge: { + /** + * Type + * @description The kind of relationship represented by this edge + * @default default + * @enum {string} + */ + type?: "default" | "loop_linkage"; /** @description The connection for the edge's from node and field */ source: components["schemas"]["EdgeConnection"]; /** @description The connection for the edge's to node and field */ @@ -14552,6 +14724,164 @@ export type components = { * @enum {string} */ FluxVariantType: "schnell" | "dev" | "dev_fill"; + /** ForInvocation */ + ForInvocation: { + /** + * Id + * @description The id of this instance of an invocation. Must be unique among all instances of invocations. + */ + id: string; + /** + * Is Intermediate + * @description Whether or not this is an intermediate invocation. + * @default false + */ + is_intermediate?: boolean; + /** + * Use Cache + * @description Whether or not to use the cache + * @default true + */ + use_cache?: boolean; + /** + * Collection + * @description The list of items to iterate over + * @default [] + */ + collection?: unknown[]; + /** + * @description Optional initial loop state + * @default null + */ + state?: components["schemas"]["LoopState"] | null; + /** + * Index + * @description The internal iteration index for a prepared For execution node + * @default -1 + */ + index?: number; + /** + * type + * @default for + * @constant + */ + type: "for"; + }; + /** ForInvocationOutput */ + ForInvocationOutput: { + /** + * Loop Linkage + * @description The loop linkage to the matching ForReturn + */ + loop_linkage: unknown; + /** + * Collection Item + * @description The item for the current loop iteration, or None when the collection is empty + * @default null + */ + item: unknown | null; + /** + * Index + * @description The index for the current loop iteration + */ + index: number; + /** + * Total + * @description The total number of items in the loop collection + */ + total: number; + /** + * State + * @description The state for the current loop iteration + */ + state: components["schemas"]["LoopState"]; + /** + * Output Collection + * @description The collected loop body outputs + */ + output_collection: unknown[]; + /** + * Final State + * @description The final loop state + */ + final_state: components["schemas"]["LoopState"]; + /** + * type + * @default for_output + * @constant + */ + type: "for_output"; + }; + /** ForReturnInvocation */ + ForReturnInvocation: { + /** + * Id + * @description The id of this instance of an invocation. Must be unique among all instances of invocations. + */ + id: string; + /** + * Is Intermediate + * @description Whether or not this is an intermediate invocation. + * @default false + */ + is_intermediate?: boolean; + /** + * Use Cache + * @description Whether or not to use the cache + * @default true + */ + use_cache?: boolean; + /** + * Loop Linkage + * @description The loop linkage from the matching For + * @default null + */ + loop_linkage?: unknown | null; + /** + * Output + * @description The output item to append to the loop output collection + * @default null + */ + output?: unknown | null; + /** + * @description The state to pass to the next loop iteration + * @default null + */ + state?: components["schemas"]["LoopState"] | null; + /** + * Continue Condition + * @description Whether to schedule the next loop iteration; false finalizes the loop + * @default true + */ + continue_condition?: boolean | null; + /** + * type + * @default for_return + * @constant + */ + type: "for_return"; + }; + /** ForReturnInvocationOutput */ + ForReturnInvocationOutput: { + /** + * Output + * @description The output item to append to the loop output collection + * @default null + */ + output: unknown | null; + /** + * State + * @description The state to pass to the next loop iteration + * @default null + */ + state: components["schemas"]["LoopState"] | null; + /** + * type + * @default for_return_output + * @constant + */ + type: "for_return_output"; + }; /** FoundModel */ FoundModel: { /** @@ -15259,7 +15589,7 @@ export type components = { * @description The nodes in this graph */ nodes?: { - [key: string]: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3ImageReferenceInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3ReferenceConditioningInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["MiniMaxH3VideoReferenceInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; + [key: string]: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CollectionCartesianInvocation"] | components["schemas"]["CollectionConcatInvocation"] | components["schemas"]["CollectionZipInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["ForInvocation"] | components["schemas"]["ForReturnInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3ImageReferenceInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3ReferenceConditioningInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["MiniMaxH3VideoReferenceInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StateEmptyInvocation"] | components["schemas"]["StateGetInvocation"] | components["schemas"]["StateMergeInvocation"] | components["schemas"]["StateSetInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; }; /** * Edges @@ -15296,7 +15626,7 @@ export type components = { * @description The results of node executions */ results: { - [key: string]: components["schemas"]["AnimaConditioningOutput"] | components["schemas"]["AnimaLLLiteOutput"] | components["schemas"]["AnimaLoRALoaderOutput"] | components["schemas"]["AnimaModelLoaderOutput"] | components["schemas"]["BooleanCollectionOutput"] | components["schemas"]["BooleanOutput"] | components["schemas"]["BoundingBoxCollectionOutput"] | components["schemas"]["BoundingBoxOutput"] | components["schemas"]["CLIPOutput"] | components["schemas"]["CLIPSkipInvocationOutput"] | components["schemas"]["CalculateImageTilesOutput"] | components["schemas"]["CogView4ConditioningOutput"] | components["schemas"]["CogView4ModelLoaderOutput"] | components["schemas"]["CollectInvocationOutput"] | components["schemas"]["ColorCollectionOutput"] | components["schemas"]["ColorOutput"] | components["schemas"]["ConditioningCollectionOutput"] | components["schemas"]["ConditioningOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["DenoiseMaskOutput"] | components["schemas"]["ErnieImageConditioningOutput"] | components["schemas"]["ErnieImageModelLoaderOutput"] | components["schemas"]["ExtractVideoRangeOutput"] | components["schemas"]["FaceMaskOutput"] | components["schemas"]["FaceOffOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["FloatGeneratorOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["Flux2DevLoRALoaderOutput"] | components["schemas"]["Flux2DevModelLoaderOutput"] | components["schemas"]["Flux2KleinLoRALoaderOutput"] | components["schemas"]["Flux2KleinModelLoaderOutput"] | components["schemas"]["FluxConditioningCollectionOutput"] | components["schemas"]["FluxConditioningOutput"] | components["schemas"]["FluxControlLoRALoaderOutput"] | components["schemas"]["FluxControlNetOutput"] | components["schemas"]["FluxFillOutput"] | components["schemas"]["FluxKontextOutput"] | components["schemas"]["FluxLoRALoaderOutput"] | components["schemas"]["FluxModelLoaderOutput"] | components["schemas"]["FluxReduxOutput"] | components["schemas"]["Gemma2EncoderOutput"] | components["schemas"]["GradientMaskOutput"] | components["schemas"]["IPAdapterOutput"] | components["schemas"]["IdealSizeOutput"] | components["schemas"]["Ideogram4ConditioningOutput"] | components["schemas"]["Ideogram4ModelLoaderOutput"] | components["schemas"]["IfInvocationOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ImageGeneratorOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["ImagePanelCoordinateOutput"] | components["schemas"]["IntegerCollectionOutput"] | components["schemas"]["IntegerGeneratorOutput"] | components["schemas"]["IntegerOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["Krea2ConditioningOutput"] | components["schemas"]["Krea2LoRALoaderOutput"] | components["schemas"]["Krea2ModelLoaderOutput"] | components["schemas"]["LatentsCollectionOutput"] | components["schemas"]["LatentsMetaOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["LoRALoaderOutput"] | components["schemas"]["LoRASelectorOutput"] | components["schemas"]["MDControlListOutput"] | components["schemas"]["MDIPAdapterListOutput"] | components["schemas"]["MDT2IAdapterListOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["MetadataItemOutput"] | components["schemas"]["MetadataOutput"] | components["schemas"]["MetadataToLorasCollectionOutput"] | components["schemas"]["MetadataToModelOutput"] | components["schemas"]["MetadataToSDXLModelOutput"] | components["schemas"]["MiniMaxH3ConditioningOutput"] | components["schemas"]["MiniMaxH3DenoiseOutput"] | components["schemas"]["MiniMaxH3FrameConditioningOutput"] | components["schemas"]["MiniMaxH3LoRACollectionLoaderOutput"] | components["schemas"]["MiniMaxH3LoRALoaderOutput"] | components["schemas"]["MiniMaxH3ModelLoaderOutput"] | components["schemas"]["MiniMaxH3ReferenceConditioningOutput"] | components["schemas"]["MiniMaxH3ReferenceMediaOutput"] | components["schemas"]["ModelIdentifierOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PBRMapsOutput"] | components["schemas"]["PairTileImageOutput"] | components["schemas"]["PiDDecoderOutput"] | components["schemas"]["PromptTemplateOutput"] | components["schemas"]["QwenImageConditioningOutput"] | components["schemas"]["QwenImageLoRALoaderOutput"] | components["schemas"]["QwenImageModelLoaderOutput"] | components["schemas"]["SD3ConditioningOutput"] | components["schemas"]["SDXLLoRALoaderOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["SchedulerOutput"] | components["schemas"]["Sd3ModelLoaderOutput"] | components["schemas"]["SeamlessModeOutput"] | components["schemas"]["String2Output"] | components["schemas"]["StringCollectionOutput"] | components["schemas"]["StringGeneratorOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["StringPosNegOutput"] | components["schemas"]["T2IAdapterOutput"] | components["schemas"]["TileToPropertiesOutput"] | components["schemas"]["UNetOutput"] | components["schemas"]["VAEOutput"] | components["schemas"]["VideoOutput"] | components["schemas"]["WanConditioningOutput"] | components["schemas"]["WanLoRALoaderOutput"] | components["schemas"]["WanModelLoaderOutput"] | components["schemas"]["WanRefImageOutput"] | components["schemas"]["WorkflowReturnGetOutput"] | components["schemas"]["WorkflowReturnOutput"] | components["schemas"]["WorkflowReturnValueOutput"] | components["schemas"]["ZImageConditioningOutput"] | components["schemas"]["ZImageControlOutput"] | components["schemas"]["ZImageLoRALoaderOutput"] | components["schemas"]["ZImageModelLoaderOutput"]; + [key: string]: components["schemas"]["AnimaConditioningOutput"] | components["schemas"]["AnimaLLLiteOutput"] | components["schemas"]["AnimaLoRALoaderOutput"] | components["schemas"]["AnimaModelLoaderOutput"] | components["schemas"]["BooleanCollectionOutput"] | components["schemas"]["BooleanOutput"] | components["schemas"]["BoundingBoxCollectionOutput"] | components["schemas"]["BoundingBoxOutput"] | components["schemas"]["CLIPOutput"] | components["schemas"]["CLIPSkipInvocationOutput"] | components["schemas"]["CalculateImageTilesOutput"] | components["schemas"]["CogView4ConditioningOutput"] | components["schemas"]["CogView4ModelLoaderOutput"] | components["schemas"]["CollectInvocationOutput"] | components["schemas"]["CollectionCartesianInvocationOutput"] | components["schemas"]["CollectionConcatInvocationOutput"] | components["schemas"]["CollectionZipInvocationOutput"] | components["schemas"]["ColorCollectionOutput"] | components["schemas"]["ColorOutput"] | components["schemas"]["ConditioningCollectionOutput"] | components["schemas"]["ConditioningOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["DenoiseMaskOutput"] | components["schemas"]["ErnieImageConditioningOutput"] | components["schemas"]["ErnieImageModelLoaderOutput"] | components["schemas"]["ExtractVideoRangeOutput"] | components["schemas"]["FaceMaskOutput"] | components["schemas"]["FaceOffOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["FloatGeneratorOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["Flux2DevLoRALoaderOutput"] | components["schemas"]["Flux2DevModelLoaderOutput"] | components["schemas"]["Flux2KleinLoRALoaderOutput"] | components["schemas"]["Flux2KleinModelLoaderOutput"] | components["schemas"]["FluxConditioningCollectionOutput"] | components["schemas"]["FluxConditioningOutput"] | components["schemas"]["FluxControlLoRALoaderOutput"] | components["schemas"]["FluxControlNetOutput"] | components["schemas"]["FluxFillOutput"] | components["schemas"]["FluxKontextOutput"] | components["schemas"]["FluxLoRALoaderOutput"] | components["schemas"]["FluxModelLoaderOutput"] | components["schemas"]["FluxReduxOutput"] | components["schemas"]["ForInvocationOutput"] | components["schemas"]["ForReturnInvocationOutput"] | components["schemas"]["Gemma2EncoderOutput"] | components["schemas"]["GradientMaskOutput"] | components["schemas"]["IPAdapterOutput"] | components["schemas"]["IdealSizeOutput"] | components["schemas"]["Ideogram4ConditioningOutput"] | components["schemas"]["Ideogram4ModelLoaderOutput"] | components["schemas"]["IfInvocationOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ImageGeneratorOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["ImagePanelCoordinateOutput"] | components["schemas"]["IntegerCollectionOutput"] | components["schemas"]["IntegerGeneratorOutput"] | components["schemas"]["IntegerOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["Krea2ConditioningOutput"] | components["schemas"]["Krea2LoRALoaderOutput"] | components["schemas"]["Krea2ModelLoaderOutput"] | components["schemas"]["LatentsCollectionOutput"] | components["schemas"]["LatentsMetaOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["LoRALoaderOutput"] | components["schemas"]["LoRASelectorOutput"] | components["schemas"]["LoopStateOutput"] | components["schemas"]["LoopStateValueOutput"] | components["schemas"]["MDControlListOutput"] | components["schemas"]["MDIPAdapterListOutput"] | components["schemas"]["MDT2IAdapterListOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["MetadataItemOutput"] | components["schemas"]["MetadataOutput"] | components["schemas"]["MetadataToLorasCollectionOutput"] | components["schemas"]["MetadataToModelOutput"] | components["schemas"]["MetadataToSDXLModelOutput"] | components["schemas"]["MiniMaxH3ConditioningOutput"] | components["schemas"]["MiniMaxH3DenoiseOutput"] | components["schemas"]["MiniMaxH3FrameConditioningOutput"] | components["schemas"]["MiniMaxH3LoRACollectionLoaderOutput"] | components["schemas"]["MiniMaxH3LoRALoaderOutput"] | components["schemas"]["MiniMaxH3ModelLoaderOutput"] | components["schemas"]["MiniMaxH3ReferenceConditioningOutput"] | components["schemas"]["MiniMaxH3ReferenceMediaOutput"] | components["schemas"]["ModelIdentifierOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PBRMapsOutput"] | components["schemas"]["PairTileImageOutput"] | components["schemas"]["PiDDecoderOutput"] | components["schemas"]["PromptTemplateOutput"] | components["schemas"]["QwenImageConditioningOutput"] | components["schemas"]["QwenImageLoRALoaderOutput"] | components["schemas"]["QwenImageModelLoaderOutput"] | components["schemas"]["SD3ConditioningOutput"] | components["schemas"]["SDXLLoRALoaderOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["SchedulerOutput"] | components["schemas"]["Sd3ModelLoaderOutput"] | components["schemas"]["SeamlessModeOutput"] | components["schemas"]["String2Output"] | components["schemas"]["StringCollectionOutput"] | components["schemas"]["StringGeneratorOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["StringPosNegOutput"] | components["schemas"]["T2IAdapterOutput"] | components["schemas"]["TileToPropertiesOutput"] | components["schemas"]["UNetOutput"] | components["schemas"]["VAEOutput"] | components["schemas"]["VideoOutput"] | components["schemas"]["WanConditioningOutput"] | components["schemas"]["WanLoRALoaderOutput"] | components["schemas"]["WanModelLoaderOutput"] | components["schemas"]["WanRefImageOutput"] | components["schemas"]["WorkflowReturnGetOutput"] | components["schemas"]["WorkflowReturnOutput"] | components["schemas"]["WorkflowReturnValueOutput"] | components["schemas"]["ZImageConditioningOutput"] | components["schemas"]["ZImageControlOutput"] | components["schemas"]["ZImageLoRALoaderOutput"] | components["schemas"]["ZImageModelLoaderOutput"]; }; /** * Errors @@ -15343,6 +15673,19 @@ export type components = { source_prepared_mapping: { [key: string]: string[]; }; + /** + * Finalized Loop Nodes + * @description Legacy set of top-level loop source nodes whose final outputs have been materialized + */ + finalized_loop_nodes: string[]; + /** + * Finalized Loop Contexts + * @description The finalized loop source and parent iteration contexts + */ + finalized_loop_contexts?: [ + string, + number[] + ][]; /** * Prepared Iteration Paths * @description The iteration coordinates of each prepared execution node @@ -19447,7 +19790,7 @@ export type components = { * Invocation * @description The ID of the invocation */ - invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3ImageReferenceInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3ReferenceConditioningInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["MiniMaxH3VideoReferenceInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; + invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CollectionCartesianInvocation"] | components["schemas"]["CollectionConcatInvocation"] | components["schemas"]["CollectionZipInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["ForInvocation"] | components["schemas"]["ForReturnInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3ImageReferenceInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3ReferenceConditioningInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["MiniMaxH3VideoReferenceInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StateEmptyInvocation"] | components["schemas"]["StateGetInvocation"] | components["schemas"]["StateMergeInvocation"] | components["schemas"]["StateSetInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; /** * Invocation Source Id * @description The ID of the prepared invocation's source node @@ -19457,7 +19800,7 @@ export type components = { * Result * @description The result of the invocation */ - result: components["schemas"]["AnimaConditioningOutput"] | components["schemas"]["AnimaLLLiteOutput"] | components["schemas"]["AnimaLoRALoaderOutput"] | components["schemas"]["AnimaModelLoaderOutput"] | components["schemas"]["BooleanCollectionOutput"] | components["schemas"]["BooleanOutput"] | components["schemas"]["BoundingBoxCollectionOutput"] | components["schemas"]["BoundingBoxOutput"] | components["schemas"]["CLIPOutput"] | components["schemas"]["CLIPSkipInvocationOutput"] | components["schemas"]["CalculateImageTilesOutput"] | components["schemas"]["CogView4ConditioningOutput"] | components["schemas"]["CogView4ModelLoaderOutput"] | components["schemas"]["CollectInvocationOutput"] | components["schemas"]["ColorCollectionOutput"] | components["schemas"]["ColorOutput"] | components["schemas"]["ConditioningCollectionOutput"] | components["schemas"]["ConditioningOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["DenoiseMaskOutput"] | components["schemas"]["ErnieImageConditioningOutput"] | components["schemas"]["ErnieImageModelLoaderOutput"] | components["schemas"]["ExtractVideoRangeOutput"] | components["schemas"]["FaceMaskOutput"] | components["schemas"]["FaceOffOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["FloatGeneratorOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["Flux2DevLoRALoaderOutput"] | components["schemas"]["Flux2DevModelLoaderOutput"] | components["schemas"]["Flux2KleinLoRALoaderOutput"] | components["schemas"]["Flux2KleinModelLoaderOutput"] | components["schemas"]["FluxConditioningCollectionOutput"] | components["schemas"]["FluxConditioningOutput"] | components["schemas"]["FluxControlLoRALoaderOutput"] | components["schemas"]["FluxControlNetOutput"] | components["schemas"]["FluxFillOutput"] | components["schemas"]["FluxKontextOutput"] | components["schemas"]["FluxLoRALoaderOutput"] | components["schemas"]["FluxModelLoaderOutput"] | components["schemas"]["FluxReduxOutput"] | components["schemas"]["Gemma2EncoderOutput"] | components["schemas"]["GradientMaskOutput"] | components["schemas"]["IPAdapterOutput"] | components["schemas"]["IdealSizeOutput"] | components["schemas"]["Ideogram4ConditioningOutput"] | components["schemas"]["Ideogram4ModelLoaderOutput"] | components["schemas"]["IfInvocationOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ImageGeneratorOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["ImagePanelCoordinateOutput"] | components["schemas"]["IntegerCollectionOutput"] | components["schemas"]["IntegerGeneratorOutput"] | components["schemas"]["IntegerOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["Krea2ConditioningOutput"] | components["schemas"]["Krea2LoRALoaderOutput"] | components["schemas"]["Krea2ModelLoaderOutput"] | components["schemas"]["LatentsCollectionOutput"] | components["schemas"]["LatentsMetaOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["LoRALoaderOutput"] | components["schemas"]["LoRASelectorOutput"] | components["schemas"]["MDControlListOutput"] | components["schemas"]["MDIPAdapterListOutput"] | components["schemas"]["MDT2IAdapterListOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["MetadataItemOutput"] | components["schemas"]["MetadataOutput"] | components["schemas"]["MetadataToLorasCollectionOutput"] | components["schemas"]["MetadataToModelOutput"] | components["schemas"]["MetadataToSDXLModelOutput"] | components["schemas"]["MiniMaxH3ConditioningOutput"] | components["schemas"]["MiniMaxH3DenoiseOutput"] | components["schemas"]["MiniMaxH3FrameConditioningOutput"] | components["schemas"]["MiniMaxH3LoRACollectionLoaderOutput"] | components["schemas"]["MiniMaxH3LoRALoaderOutput"] | components["schemas"]["MiniMaxH3ModelLoaderOutput"] | components["schemas"]["MiniMaxH3ReferenceConditioningOutput"] | components["schemas"]["MiniMaxH3ReferenceMediaOutput"] | components["schemas"]["ModelIdentifierOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PBRMapsOutput"] | components["schemas"]["PairTileImageOutput"] | components["schemas"]["PiDDecoderOutput"] | components["schemas"]["PromptTemplateOutput"] | components["schemas"]["QwenImageConditioningOutput"] | components["schemas"]["QwenImageLoRALoaderOutput"] | components["schemas"]["QwenImageModelLoaderOutput"] | components["schemas"]["SD3ConditioningOutput"] | components["schemas"]["SDXLLoRALoaderOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["SchedulerOutput"] | components["schemas"]["Sd3ModelLoaderOutput"] | components["schemas"]["SeamlessModeOutput"] | components["schemas"]["String2Output"] | components["schemas"]["StringCollectionOutput"] | components["schemas"]["StringGeneratorOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["StringPosNegOutput"] | components["schemas"]["T2IAdapterOutput"] | components["schemas"]["TileToPropertiesOutput"] | components["schemas"]["UNetOutput"] | components["schemas"]["VAEOutput"] | components["schemas"]["VideoOutput"] | components["schemas"]["WanConditioningOutput"] | components["schemas"]["WanLoRALoaderOutput"] | components["schemas"]["WanModelLoaderOutput"] | components["schemas"]["WanRefImageOutput"] | components["schemas"]["WorkflowReturnGetOutput"] | components["schemas"]["WorkflowReturnOutput"] | components["schemas"]["WorkflowReturnValueOutput"] | components["schemas"]["ZImageConditioningOutput"] | components["schemas"]["ZImageControlOutput"] | components["schemas"]["ZImageLoRALoaderOutput"] | components["schemas"]["ZImageModelLoaderOutput"]; + result: components["schemas"]["AnimaConditioningOutput"] | components["schemas"]["AnimaLLLiteOutput"] | components["schemas"]["AnimaLoRALoaderOutput"] | components["schemas"]["AnimaModelLoaderOutput"] | components["schemas"]["BooleanCollectionOutput"] | components["schemas"]["BooleanOutput"] | components["schemas"]["BoundingBoxCollectionOutput"] | components["schemas"]["BoundingBoxOutput"] | components["schemas"]["CLIPOutput"] | components["schemas"]["CLIPSkipInvocationOutput"] | components["schemas"]["CalculateImageTilesOutput"] | components["schemas"]["CogView4ConditioningOutput"] | components["schemas"]["CogView4ModelLoaderOutput"] | components["schemas"]["CollectInvocationOutput"] | components["schemas"]["CollectionCartesianInvocationOutput"] | components["schemas"]["CollectionConcatInvocationOutput"] | components["schemas"]["CollectionZipInvocationOutput"] | components["schemas"]["ColorCollectionOutput"] | components["schemas"]["ColorOutput"] | components["schemas"]["ConditioningCollectionOutput"] | components["schemas"]["ConditioningOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["DenoiseMaskOutput"] | components["schemas"]["ErnieImageConditioningOutput"] | components["schemas"]["ErnieImageModelLoaderOutput"] | components["schemas"]["ExtractVideoRangeOutput"] | components["schemas"]["FaceMaskOutput"] | components["schemas"]["FaceOffOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["FloatGeneratorOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["Flux2DevLoRALoaderOutput"] | components["schemas"]["Flux2DevModelLoaderOutput"] | components["schemas"]["Flux2KleinLoRALoaderOutput"] | components["schemas"]["Flux2KleinModelLoaderOutput"] | components["schemas"]["FluxConditioningCollectionOutput"] | components["schemas"]["FluxConditioningOutput"] | components["schemas"]["FluxControlLoRALoaderOutput"] | components["schemas"]["FluxControlNetOutput"] | components["schemas"]["FluxFillOutput"] | components["schemas"]["FluxKontextOutput"] | components["schemas"]["FluxLoRALoaderOutput"] | components["schemas"]["FluxModelLoaderOutput"] | components["schemas"]["FluxReduxOutput"] | components["schemas"]["ForInvocationOutput"] | components["schemas"]["ForReturnInvocationOutput"] | components["schemas"]["Gemma2EncoderOutput"] | components["schemas"]["GradientMaskOutput"] | components["schemas"]["IPAdapterOutput"] | components["schemas"]["IdealSizeOutput"] | components["schemas"]["Ideogram4ConditioningOutput"] | components["schemas"]["Ideogram4ModelLoaderOutput"] | components["schemas"]["IfInvocationOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ImageGeneratorOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["ImagePanelCoordinateOutput"] | components["schemas"]["IntegerCollectionOutput"] | components["schemas"]["IntegerGeneratorOutput"] | components["schemas"]["IntegerOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["Krea2ConditioningOutput"] | components["schemas"]["Krea2LoRALoaderOutput"] | components["schemas"]["Krea2ModelLoaderOutput"] | components["schemas"]["LatentsCollectionOutput"] | components["schemas"]["LatentsMetaOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["LoRALoaderOutput"] | components["schemas"]["LoRASelectorOutput"] | components["schemas"]["LoopStateOutput"] | components["schemas"]["LoopStateValueOutput"] | components["schemas"]["MDControlListOutput"] | components["schemas"]["MDIPAdapterListOutput"] | components["schemas"]["MDT2IAdapterListOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["MetadataItemOutput"] | components["schemas"]["MetadataOutput"] | components["schemas"]["MetadataToLorasCollectionOutput"] | components["schemas"]["MetadataToModelOutput"] | components["schemas"]["MetadataToSDXLModelOutput"] | components["schemas"]["MiniMaxH3ConditioningOutput"] | components["schemas"]["MiniMaxH3DenoiseOutput"] | components["schemas"]["MiniMaxH3FrameConditioningOutput"] | components["schemas"]["MiniMaxH3LoRACollectionLoaderOutput"] | components["schemas"]["MiniMaxH3LoRALoaderOutput"] | components["schemas"]["MiniMaxH3ModelLoaderOutput"] | components["schemas"]["MiniMaxH3ReferenceConditioningOutput"] | components["schemas"]["MiniMaxH3ReferenceMediaOutput"] | components["schemas"]["ModelIdentifierOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PBRMapsOutput"] | components["schemas"]["PairTileImageOutput"] | components["schemas"]["PiDDecoderOutput"] | components["schemas"]["PromptTemplateOutput"] | components["schemas"]["QwenImageConditioningOutput"] | components["schemas"]["QwenImageLoRALoaderOutput"] | components["schemas"]["QwenImageModelLoaderOutput"] | components["schemas"]["SD3ConditioningOutput"] | components["schemas"]["SDXLLoRALoaderOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["SchedulerOutput"] | components["schemas"]["Sd3ModelLoaderOutput"] | components["schemas"]["SeamlessModeOutput"] | components["schemas"]["String2Output"] | components["schemas"]["StringCollectionOutput"] | components["schemas"]["StringGeneratorOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["StringPosNegOutput"] | components["schemas"]["T2IAdapterOutput"] | components["schemas"]["TileToPropertiesOutput"] | components["schemas"]["UNetOutput"] | components["schemas"]["VAEOutput"] | components["schemas"]["VideoOutput"] | components["schemas"]["WanConditioningOutput"] | components["schemas"]["WanLoRALoaderOutput"] | components["schemas"]["WanModelLoaderOutput"] | components["schemas"]["WanRefImageOutput"] | components["schemas"]["WorkflowReturnGetOutput"] | components["schemas"]["WorkflowReturnOutput"] | components["schemas"]["WorkflowReturnValueOutput"] | components["schemas"]["ZImageConditioningOutput"] | components["schemas"]["ZImageControlOutput"] | components["schemas"]["ZImageLoRALoaderOutput"] | components["schemas"]["ZImageModelLoaderOutput"]; }; /** * InvocationErrorEvent @@ -19511,7 +19854,7 @@ export type components = { * Invocation * @description The ID of the invocation */ - invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3ImageReferenceInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3ReferenceConditioningInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["MiniMaxH3VideoReferenceInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; + invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CollectionCartesianInvocation"] | components["schemas"]["CollectionConcatInvocation"] | components["schemas"]["CollectionZipInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["ForInvocation"] | components["schemas"]["ForReturnInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3ImageReferenceInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3ReferenceConditioningInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["MiniMaxH3VideoReferenceInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StateEmptyInvocation"] | components["schemas"]["StateGetInvocation"] | components["schemas"]["StateMergeInvocation"] | components["schemas"]["StateSetInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; /** * Invocation Source Id * @description The ID of the prepared invocation's source node @@ -19567,6 +19910,9 @@ export type components = { cogview4_model_loader: components["schemas"]["CogView4ModelLoaderOutput"]; cogview4_text_encoder: components["schemas"]["CogView4ConditioningOutput"]; collect: components["schemas"]["CollectInvocationOutput"]; + collection_cartesian: components["schemas"]["CollectionCartesianInvocationOutput"]; + collection_concat: components["schemas"]["CollectionConcatInvocationOutput"]; + collection_zip: components["schemas"]["CollectionZipInvocationOutput"]; color: components["schemas"]["ColorOutput"]; color_correct: components["schemas"]["ImageOutput"]; color_map: components["schemas"]["ImageOutput"]; @@ -19634,6 +19980,8 @@ export type components = { flux_text_encoder: components["schemas"]["FluxConditioningOutput"]; flux_vae_decode: components["schemas"]["ImageOutput"]; flux_vae_encode: components["schemas"]["LatentsOutput"]; + for: components["schemas"]["ForInvocationOutput"]; + for_return: components["schemas"]["ForReturnInvocationOutput"]; freeu: components["schemas"]["UNetOutput"]; gemini_image_generation: components["schemas"]["ImageCollectionOutput"]; gemma2_encoder_loader: components["schemas"]["Gemma2EncoderOutput"]; @@ -19805,6 +20153,10 @@ export type components = { show_image: components["schemas"]["ImageOutput"]; spandrel_image_to_image: components["schemas"]["ImageOutput"]; spandrel_image_to_image_autoscale: components["schemas"]["ImageOutput"]; + state_empty: components["schemas"]["LoopStateOutput"]; + state_get: components["schemas"]["LoopStateValueOutput"]; + state_merge: components["schemas"]["LoopStateOutput"]; + state_set: components["schemas"]["LoopStateOutput"]; string: components["schemas"]["StringOutput"]; string_batch: components["schemas"]["StringOutput"]; string_collection: components["schemas"]["StringCollectionOutput"]; @@ -19907,7 +20259,7 @@ export type components = { * Invocation * @description The ID of the invocation */ - invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3ImageReferenceInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3ReferenceConditioningInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["MiniMaxH3VideoReferenceInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; + invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CollectionCartesianInvocation"] | components["schemas"]["CollectionConcatInvocation"] | components["schemas"]["CollectionZipInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["ForInvocation"] | components["schemas"]["ForReturnInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3ImageReferenceInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3ReferenceConditioningInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["MiniMaxH3VideoReferenceInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StateEmptyInvocation"] | components["schemas"]["StateGetInvocation"] | components["schemas"]["StateMergeInvocation"] | components["schemas"]["StateSetInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; /** * Invocation Source Id * @description The ID of the prepared invocation's source node @@ -19988,7 +20340,7 @@ export type components = { * Invocation * @description The ID of the invocation */ - invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3ImageReferenceInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3ReferenceConditioningInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["MiniMaxH3VideoReferenceInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; + invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CollectionCartesianInvocation"] | components["schemas"]["CollectionConcatInvocation"] | components["schemas"]["CollectionZipInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["ForInvocation"] | components["schemas"]["ForReturnInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3ImageReferenceInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3ReferenceConditioningInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["MiniMaxH3VideoReferenceInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StateEmptyInvocation"] | components["schemas"]["StateGetInvocation"] | components["schemas"]["StateMergeInvocation"] | components["schemas"]["StateSetInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; /** * Invocation Source Id * @description The ID of the prepared invocation's source node @@ -24188,6 +24540,39 @@ export type components = { */ success: boolean; }; + /** LoopState */ + LoopState: { + /** Values */ + values?: { + [key: string]: unknown; + }; + }; + /** LoopStateOutput */ + LoopStateOutput: { + /** @description The loop state */ + state: components["schemas"]["LoopState"]; + /** + * type + * @default loop_state_output + * @constant + */ + type: "loop_state_output"; + }; + /** LoopStateValueOutput */ + LoopStateValueOutput: { + /** + * Value + * @description The value read from the loop state, or None when the key is missing + * @default null + */ + value: unknown | null; + /** + * type + * @default loop_state_value_output + * @constant + */ + type: "loop_state_value_output"; + }; /** LoraModelDefaultSettings */ LoraModelDefaultSettings: { /** @@ -32410,7 +32795,17 @@ export type components = { ui_order: number | null; /** @default null */ ui_type: components["schemas"]["UIType"] | null; + /** @default null */ + output_scope: components["schemas"]["OutputScope"] | null; }; + /** + * OutputScope + * @description The execution scope for an output field. + * - `Iteration`: The field emits values for a loop body's current iteration. + * - `Final`: The field emits values after a loop boundary completes. + * @enum {string} + */ + OutputScope: "iteration" | "final"; /** * PBR Maps * @description Generate Normal, Displacement and Roughness Map from a given image @@ -38405,6 +38800,169 @@ export type components = { */ previous_names?: string[]; }; + /** + * Empty Loop State + * @description Creates an empty loop state. + */ + StateEmptyInvocation: { + /** + * Id + * @description The id of this instance of an invocation. Must be unique among all instances of invocations. + */ + id: string; + /** + * Is Intermediate + * @description Whether or not this is an intermediate invocation. + * @default false + */ + is_intermediate?: boolean; + /** + * Use Cache + * @description Whether or not to use the cache + * @default true + */ + use_cache?: boolean; + /** + * type + * @default state_empty + * @constant + */ + type: "state_empty"; + }; + /** + * Get Loop State Value + * @description Reads a value from loop state. + */ + StateGetInvocation: { + /** + * Id + * @description The id of this instance of an invocation. Must be unique among all instances of invocations. + */ + id: string; + /** + * Is Intermediate + * @description Whether or not this is an intermediate invocation. + * @default false + */ + is_intermediate?: boolean; + /** + * Use Cache + * @description Whether or not to use the cache + * @default true + */ + use_cache?: boolean; + /** + * @description The loop state to read + * @default null + */ + state?: components["schemas"]["LoopState"] | null; + /** + * Key + * @description The state key to read + * @default + */ + key?: string; + /** + * Default + * @description The value to return when the key is missing + * @default null + */ + default?: unknown | null; + /** + * type + * @default state_get + * @constant + */ + type: "state_get"; + }; + /** + * Merge Loop State Values + * @description Returns loop state with multiple values merged. + */ + StateMergeInvocation: { + /** + * Id + * @description The id of this instance of an invocation. Must be unique among all instances of invocations. + */ + id: string; + /** + * Is Intermediate + * @description Whether or not this is an intermediate invocation. + * @default false + */ + is_intermediate?: boolean; + /** + * Use Cache + * @description Whether or not to use the cache + * @default true + */ + use_cache?: boolean; + /** + * @description The loop state to update + * @default null + */ + state?: components["schemas"]["LoopState"] | null; + /** + * Values + * @description The values to merge into the loop state. Connect an output to this input. + * @default {} + */ + values?: { + [key: string]: unknown; + }; + /** + * type + * @default state_merge + * @constant + */ + type: "state_merge"; + }; + /** + * Set Loop State Value + * @description Returns loop state with one value set. + */ + StateSetInvocation: { + /** + * Id + * @description The id of this instance of an invocation. Must be unique among all instances of invocations. + */ + id: string; + /** + * Is Intermediate + * @description Whether or not this is an intermediate invocation. + * @default false + */ + is_intermediate?: boolean; + /** + * Use Cache + * @description Whether or not to use the cache + * @default true + */ + use_cache?: boolean; + /** + * @description The loop state to update + * @default null + */ + state?: components["schemas"]["LoopState"] | null; + /** + * Key + * @description The state key to set + * @default + */ + key?: string; + /** + * Value + * @description The value to set. Connect an output to this input. + * @default null + */ + value?: unknown | null; + /** + * type + * @default state_set + * @constant + */ + type: "state_set"; + }; /** * String2Output * @description Base class for invocations that output two strings @@ -40668,7 +41226,7 @@ export type components = { * * - Any Field * We cannot infer the usage of `typing.Any` via schema parsing, so you *must* use `ui_type=UIType.Any` to - * indicate that the field accepts any type. Use with caution. This cannot be used on outputs. + * indicate that the field accepts any type. Use with caution. On inputs, this renders as a connection-only field. * * - Scheduler Field * Special handling in the UI is needed for this field, which otherwise would be parsed as a plain enum field. diff --git a/invokeai/frontend/webv2/performance/architecture-baseline.json b/invokeai/frontend/webv2/performance/architecture-baseline.json index 9a94e000195..593c69e0985 100644 --- a/invokeai/frontend/webv2/performance/architecture-baseline.json +++ b/invokeai/frontend/webv2/performance/architecture-baseline.json @@ -2,10 +2,10 @@ "build": { "launchpad": { "baseline": { - "brotliBytes": 842332, + "brotliBytes": 842323, "cssRawBytes": 2159, "fontRawBytes": 219480, - "gzipBytes": 841167, + "gzipBytes": 841164, "imageRawBytes": 156376, "initialRawBytes": 1961176, "largestAssetRawBytes": 746400, @@ -291,10 +291,10 @@ ] }, "limits": { - "brotliBytes": 850756, + "brotliBytes": 850747, "cssRawBytes": 2181, "fontRawBytes": 221675, - "gzipBytes": 849579, + "gzipBytes": 849576, "imageRawBytes": 157940, "initialRawBytes": 1980788, "largestAssetRawBytes": 753864, @@ -309,12 +309,12 @@ }, "editor": { "baseline": { - "brotliBytes": 1111234, + "brotliBytes": 1115456, "cssRawBytes": 2159, "fontRawBytes": 219480, - "gzipBytes": 1106583, + "gzipBytes": 1110555, "imageRawBytes": 156376, - "initialRawBytes": 2776741, + "initialRawBytes": 2792218, "largestAssetRawBytes": 746400, "otherAssetRawBytes": 0, "ownedRawBytes": 114915, @@ -616,6 +616,7 @@ "source:src/features/workflow/core/connectors.ts", "source:src/features/workflow/core/document.ts", "source:src/features/workflow/core/fields.ts", + "source:src/features/workflow/core/forLoops.ts", "source:src/features/workflow/core/graphIndex.ts", "source:src/features/workflow/core/types.ts", "source:src/features/workflow/core/validation.ts", @@ -936,12 +937,12 @@ ] }, "limits": { - "brotliBytes": 1122347, + "brotliBytes": 1126611, "cssRawBytes": 2181, "fontRawBytes": 221675, - "gzipBytes": 1117649, + "gzipBytes": 1121661, "imageRawBytes": 157940, - "initialRawBytes": 2804509, + "initialRawBytes": 2820141, "largestAssetRawBytes": 753864, "otherAssetRawBytes": 0, "ownedRawBytes": 114915, @@ -953,7 +954,7 @@ "source": "src/app/WorkbenchApp.tsx" } }, - "capturedAt": "2026-09-02", + "capturedAt": "2026-09-03", "developmentInvalidation": { "platformUiBarrel": { "specifier": "@platform/ui", diff --git a/invokeai/frontend/webv2/performance/browser-baseline.json b/invokeai/frontend/webv2/performance/browser-baseline.json index 8c48ffed6f6..f467d889264 100644 --- a/invokeai/frontend/webv2/performance/browser-baseline.json +++ b/invokeai/frontend/webv2/performance/browser-baseline.json @@ -25,13 +25,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 107.80000000004657, + "domContentLoadedMedianMs": 188.5, "id": "launchpad", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 107.90000000002328, - "longestTaskMaxMs": 66, + "loadMedianMs": 188.80000001192093, + "longestTaskMaxMs": 116, "owner": "app", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:launchpad", @@ -41,24 +41,24 @@ "fontRawBytes": 23664, "imageRawBytes": 0, "largestAssetRawBytes": 746400, - "otherRawBytes": 164949, + "otherRawBytes": 166744, "requestCount": 38, "scriptRawBytes": 1638536, "scriptRequestCount": 34, - "totalRawBytes": 1829308 + "totalRawBytes": 1831103 }, "resourceLimits": { "cssRawBytes": 2181, "fontRawBytes": 23901, "imageRawBytes": 0, "largestAssetRawBytes": 753864, - "otherRawBytes": 166599, + "otherRawBytes": 168412, "requestCount": 38, "scriptRawBytes": 1654922, "scriptRequestCount": 34, - "totalRawBytes": 1847602 + "totalRawBytes": 1849415 }, - "routeReadyMedianMs": 243.59999999997672, + "routeReadyMedianMs": 433.90000000596046, "scriptSourceOwnerSet": "launchpad-static", "stateProfile": "empty" }, @@ -85,13 +85,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 108.19999999995343, + "domContentLoadedMedianMs": 181.90000000596046, "id": "launchpad", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 108.29999999993015, - "longestTaskMaxMs": 67, + "loadMedianMs": 182.09999999403954, + "longestTaskMaxMs": 108, "owner": "app", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:launchpad", @@ -101,24 +101,24 @@ "fontRawBytes": 23664, "imageRawBytes": 0, "largestAssetRawBytes": 746400, - "otherRawBytes": 164949, + "otherRawBytes": 166744, "requestCount": 38, "scriptRawBytes": 1638536, "scriptRequestCount": 34, - "totalRawBytes": 1829308 + "totalRawBytes": 1831103 }, "resourceLimits": { "cssRawBytes": 2181, "fontRawBytes": 23901, "imageRawBytes": 0, "largestAssetRawBytes": 753864, - "otherRawBytes": 166599, + "otherRawBytes": 168412, "requestCount": 38, "scriptRawBytes": 1654922, "scriptRequestCount": 34, - "totalRawBytes": 1847602 + "totalRawBytes": 1849415 }, - "routeReadyMedianMs": 257.5, + "routeReadyMedianMs": 453.19999998807907, "scriptSourceOwnerSet": "launchpad-static", "stateProfile": "representative" }, @@ -145,15 +145,15 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 109.69999999995343, + "domContentLoadedMedianMs": 178.5, "id": "editor-minimal", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 109.80000000004657, - "longestTaskMaxMs": 90, + "loadMedianMs": 178.7000000178814, + "longestTaskMaxMs": 181, "owner": "workbench", - "projectSwitchMedianMs": 33.199999999953434, + "projectSwitchMedianMs": 55.10000002384186, "readyMark": "invokeai:ready:widget:center:preview", "remediationTicket": "deepen-widget-registry-loading", "resourceBaseline": { @@ -161,24 +161,24 @@ "fontRawBytes": 23664, "imageRawBytes": 156376, "largestAssetRawBytes": 746400, - "otherRawBytes": 164949, + "otherRawBytes": 166744, "requestCount": 116, - "scriptRawBytes": 3320473, + "scriptRawBytes": 3338032, "scriptRequestCount": 111, - "totalRawBytes": 3667621 + "totalRawBytes": 3686975 }, "resourceLimits": { "cssRawBytes": 2181, "fontRawBytes": 23901, "imageRawBytes": 157940, "largestAssetRawBytes": 753864, - "otherRawBytes": 166599, + "otherRawBytes": 168412, "requestCount": 116, - "scriptRawBytes": 3353678, + "scriptRawBytes": 3371413, "scriptRequestCount": 111, - "totalRawBytes": 3704298 + "totalRawBytes": 3723845 }, - "routeReadyMedianMs": 642.0999999999767, + "routeReadyMedianMs": 918.7000000178814, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "empty" }, @@ -205,15 +205,15 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 111.29999999993015, + "domContentLoadedMedianMs": 189.5, "id": "editor-minimal", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 111.39999999990687, - "longestTaskMaxMs": 157, + "loadMedianMs": 189.69999998807907, + "longestTaskMaxMs": 263, "owner": "workbench", - "projectSwitchMedianMs": 62.800000000046566, + "projectSwitchMedianMs": 137.7000000178814, "readyMark": "invokeai:ready:widget:center:preview", "remediationTicket": "deepen-widget-registry-loading", "resourceBaseline": { @@ -221,24 +221,24 @@ "fontRawBytes": 23664, "imageRawBytes": 156376, "largestAssetRawBytes": 746400, - "otherRawBytes": 164949, + "otherRawBytes": 166744, "requestCount": 116, - "scriptRawBytes": 3320473, + "scriptRawBytes": 3338032, "scriptRequestCount": 111, - "totalRawBytes": 3667621 + "totalRawBytes": 3686975 }, "resourceLimits": { "cssRawBytes": 2181, "fontRawBytes": 23901, "imageRawBytes": 157940, "largestAssetRawBytes": 753864, - "otherRawBytes": 166599, + "otherRawBytes": 168412, "requestCount": 116, - "scriptRawBytes": 3353678, + "scriptRawBytes": 3371413, "scriptRequestCount": 111, - "totalRawBytes": 3704298 + "totalRawBytes": 3723845 }, - "routeReadyMedianMs": 414, + "routeReadyMedianMs": 792.5999999940395, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "representative" }, @@ -247,31 +247,31 @@ "cssRawBytes": 0, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 315002, + "largestAssetRawBytes": 315113, "otherRawBytes": 0, "requestCount": 7, - "scriptRawBytes": 482471, + "scriptRawBytes": 482614, "scriptRequestCount": 7, - "totalRawBytes": 482471 + "totalRawBytes": 482614 }, "activatedResourceLimits": { "cssRawBytes": 0, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 318153, + "largestAssetRawBytes": 318265, "otherRawBytes": 0, "requestCount": 7, - "scriptRawBytes": 487296, + "scriptRawBytes": 487441, "scriptRequestCount": 7, - "totalRawBytes": 487296 + "totalRawBytes": 487441 }, - "domContentLoadedMedianMs": 110.10000000009313, + "domContentLoadedMedianMs": 194.2999999821186, "id": "editor-canvas", - "layoutAckMedianMs": 2.1000000000931323, - "layoutReturnSwitchMedianMs": 51.59999999997672, - "layoutSwitchMedianMs": 89.19999999995343, - "loadMedianMs": 110.10000000009313, - "longestTaskMaxMs": 118, + "layoutAckMedianMs": 4.5999999940395355, + "layoutReturnSwitchMedianMs": 59.80000001192093, + "layoutSwitchMedianMs": 145.5, + "loadMedianMs": 194.5, + "longestTaskMaxMs": 226, "owner": "canvas", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:canvas", @@ -281,24 +281,24 @@ "fontRawBytes": 23664, "imageRawBytes": 156376, "largestAssetRawBytes": 746400, - "otherRawBytes": 164949, + "otherRawBytes": 166744, "requestCount": 123, - "scriptRawBytes": 3802944, + "scriptRawBytes": 3820646, "scriptRequestCount": 118, - "totalRawBytes": 4150092 + "totalRawBytes": 4169589 }, "resourceLimits": { "cssRawBytes": 2181, "fontRawBytes": 23901, "imageRawBytes": 157940, "largestAssetRawBytes": 753864, - "otherRawBytes": 166599, + "otherRawBytes": 168412, "requestCount": 123, - "scriptRawBytes": 3840974, + "scriptRawBytes": 3858853, "scriptRequestCount": 118, - "totalRawBytes": 4191593 + "totalRawBytes": 4211285 }, - "routeReadyMedianMs": 1016.7000000000698, + "routeReadyMedianMs": 1546.5, "scriptSourceOwnerSet": "editor-canvas-static", "stateProfile": "empty" }, @@ -307,31 +307,31 @@ "cssRawBytes": 0, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 315002, + "largestAssetRawBytes": 315113, "otherRawBytes": 0, "requestCount": 7, - "scriptRawBytes": 482471, + "scriptRawBytes": 482614, "scriptRequestCount": 7, - "totalRawBytes": 482471 + "totalRawBytes": 482614 }, "activatedResourceLimits": { "cssRawBytes": 0, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 318153, + "largestAssetRawBytes": 318265, "otherRawBytes": 0, "requestCount": 7, - "scriptRawBytes": 487296, + "scriptRawBytes": 487441, "scriptRequestCount": 7, - "totalRawBytes": 487296 + "totalRawBytes": 487441 }, - "domContentLoadedMedianMs": 109.90000000002328, + "domContentLoadedMedianMs": 185, "id": "editor-canvas", - "layoutAckMedianMs": 2.599999999976717, - "layoutReturnSwitchMedianMs": 48.300000000046566, - "layoutSwitchMedianMs": 86.29999999993015, - "loadMedianMs": 110.09999999997672, - "longestTaskMaxMs": 168, + "layoutAckMedianMs": 5.5, + "layoutReturnSwitchMedianMs": 61.900000005960464, + "layoutSwitchMedianMs": 175, + "loadMedianMs": 185.2000000178814, + "longestTaskMaxMs": 298, "owner": "canvas", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:canvas", @@ -341,24 +341,24 @@ "fontRawBytes": 23664, "imageRawBytes": 156376, "largestAssetRawBytes": 746400, - "otherRawBytes": 164949, + "otherRawBytes": 166744, "requestCount": 123, - "scriptRawBytes": 3802944, + "scriptRawBytes": 3820646, "scriptRequestCount": 118, - "totalRawBytes": 4150092 + "totalRawBytes": 4169589 }, "resourceLimits": { "cssRawBytes": 2181, "fontRawBytes": 23901, "imageRawBytes": 157940, "largestAssetRawBytes": 753864, - "otherRawBytes": 166599, + "otherRawBytes": 168412, "requestCount": 123, - "scriptRawBytes": 3840974, + "scriptRawBytes": 3858853, "scriptRequestCount": 118, - "totalRawBytes": 4191593 + "totalRawBytes": 4211285 }, - "routeReadyMedianMs": 1101.3000000000466, + "routeReadyMedianMs": 1715.5999999940395, "scriptSourceOwnerSet": "editor-canvas-static", "stateProfile": "representative" }, @@ -367,31 +367,31 @@ "cssRawBytes": 15413, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 173980, + "largestAssetRawBytes": 174186, "otherRawBytes": 0, "requestCount": 7, - "scriptRawBytes": 364235, + "scriptRawBytes": 367955, "scriptRequestCount": 6, - "totalRawBytes": 379648 + "totalRawBytes": 383368 }, "activatedResourceLimits": { "cssRawBytes": 15568, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 175720, + "largestAssetRawBytes": 175928, "otherRawBytes": 0, "requestCount": 7, - "scriptRawBytes": 367878, + "scriptRawBytes": 371635, "scriptRequestCount": 6, - "totalRawBytes": 383445 + "totalRawBytes": 387202 }, - "domContentLoadedMedianMs": 110.59999999997672, + "domContentLoadedMedianMs": 172, "id": "editor-workflow", - "layoutAckMedianMs": 2.400000000023283, - "layoutReturnSwitchMedianMs": 31.400000000023283, - "layoutSwitchMedianMs": 86.09999999997672, - "loadMedianMs": 110.70000000006985, - "longestTaskMaxMs": 91, + "layoutAckMedianMs": 4.300000011920929, + "layoutReturnSwitchMedianMs": 39.69999998807907, + "layoutSwitchMedianMs": 131, + "loadMedianMs": 172.19999998807907, + "longestTaskMaxMs": 186, "owner": "workflow", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:workflow", @@ -401,24 +401,24 @@ "fontRawBytes": 23664, "imageRawBytes": 156376, "largestAssetRawBytes": 746400, - "otherRawBytes": 164949, + "otherRawBytes": 166744, "requestCount": 123, - "scriptRawBytes": 3684708, + "scriptRawBytes": 3705987, "scriptRequestCount": 117, - "totalRawBytes": 4047269 + "totalRawBytes": 4070343 }, "resourceLimits": { "cssRawBytes": 17748, "fontRawBytes": 23901, "imageRawBytes": 157940, "largestAssetRawBytes": 753864, - "otherRawBytes": 166599, + "otherRawBytes": 168412, "requestCount": 123, - "scriptRawBytes": 3721556, + "scriptRawBytes": 3743047, "scriptRequestCount": 117, - "totalRawBytes": 4087742 + "totalRawBytes": 4111047 }, - "routeReadyMedianMs": 1004.7999999999302, + "routeReadyMedianMs": 1375, "scriptSourceOwnerSet": "editor-workflow-static", "stateProfile": "empty" }, @@ -427,31 +427,31 @@ "cssRawBytes": 15413, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 173980, + "largestAssetRawBytes": 174186, "otherRawBytes": 0, "requestCount": 7, - "scriptRawBytes": 364235, + "scriptRawBytes": 367955, "scriptRequestCount": 6, - "totalRawBytes": 379648 + "totalRawBytes": 383368 }, "activatedResourceLimits": { "cssRawBytes": 15568, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 175720, + "largestAssetRawBytes": 175928, "otherRawBytes": 0, "requestCount": 7, - "scriptRawBytes": 367878, + "scriptRawBytes": 371635, "scriptRequestCount": 6, - "totalRawBytes": 383445 + "totalRawBytes": 387202 }, - "domContentLoadedMedianMs": 110.19999999995343, + "domContentLoadedMedianMs": 173.09999999403954, "id": "editor-workflow", - "layoutAckMedianMs": 2.3999999999068677, - "layoutReturnSwitchMedianMs": 37.800000000046566, - "layoutSwitchMedianMs": 121.5, - "loadMedianMs": 110.30000000004657, - "longestTaskMaxMs": 316, + "layoutAckMedianMs": 7.5999999940395355, + "layoutReturnSwitchMedianMs": 50.5, + "layoutSwitchMedianMs": 217.40000000596046, + "loadMedianMs": 173.2999999821186, + "longestTaskMaxMs": 555, "owner": "workflow", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:workflow", @@ -461,24 +461,24 @@ "fontRawBytes": 23664, "imageRawBytes": 156376, "largestAssetRawBytes": 746400, - "otherRawBytes": 164949, + "otherRawBytes": 166744, "requestCount": 123, - "scriptRawBytes": 3684708, + "scriptRawBytes": 3705987, "scriptRequestCount": 117, - "totalRawBytes": 4047269 + "totalRawBytes": 4070343 }, "resourceLimits": { "cssRawBytes": 17748, "fontRawBytes": 23901, "imageRawBytes": 157940, "largestAssetRawBytes": 753864, - "otherRawBytes": 166599, + "otherRawBytes": 168412, "requestCount": 123, - "scriptRawBytes": 3721556, + "scriptRawBytes": 3743047, "scriptRequestCount": 117, - "totalRawBytes": 4087742 + "totalRawBytes": 4111047 }, - "routeReadyMedianMs": 1132.5999999999767, + "routeReadyMedianMs": 1684.5, "scriptSourceOwnerSet": "editor-workflow-static", "stateProfile": "representative" }, @@ -505,13 +505,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 108.90000000002328, + "domContentLoadedMedianMs": 176.19999998807907, "id": "editor-gallery", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, - "layoutSwitchMedianMs": 22.5, - "loadMedianMs": 109, - "longestTaskMaxMs": 91, + "layoutSwitchMedianMs": 41.20000001788139, + "loadMedianMs": 176.5, + "longestTaskMaxMs": 180, "owner": "gallery", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:gallery", @@ -521,24 +521,24 @@ "fontRawBytes": 23664, "imageRawBytes": 156376, "largestAssetRawBytes": 746400, - "otherRawBytes": 164949, + "otherRawBytes": 166744, "requestCount": 116, - "scriptRawBytes": 3320473, + "scriptRawBytes": 3338032, "scriptRequestCount": 111, - "totalRawBytes": 3667621 + "totalRawBytes": 3686975 }, "resourceLimits": { "cssRawBytes": 2181, "fontRawBytes": 23901, "imageRawBytes": 157940, "largestAssetRawBytes": 753864, - "otherRawBytes": 166599, + "otherRawBytes": 168412, "requestCount": 116, - "scriptRawBytes": 3353678, + "scriptRawBytes": 3371413, "scriptRequestCount": 111, - "totalRawBytes": 3704298 + "totalRawBytes": 3723845 }, - "routeReadyMedianMs": 1218.5999999999767, + "routeReadyMedianMs": 1600.5999999940395, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "empty" }, @@ -565,13 +565,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 108, + "domContentLoadedMedianMs": 179.80000001192093, "id": "editor-gallery", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, - "layoutSwitchMedianMs": 79.59999999997672, - "loadMedianMs": 108.10000000009313, - "longestTaskMaxMs": 159, + "layoutSwitchMedianMs": 127.09999999403954, + "loadMedianMs": 180, + "longestTaskMaxMs": 224, "owner": "gallery", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:gallery", @@ -581,24 +581,24 @@ "fontRawBytes": 23664, "imageRawBytes": 156376, "largestAssetRawBytes": 746400, - "otherRawBytes": 164949, + "otherRawBytes": 166744, "requestCount": 116, - "scriptRawBytes": 3320473, + "scriptRawBytes": 3338032, "scriptRequestCount": 111, - "totalRawBytes": 3667621 + "totalRawBytes": 3686975 }, "resourceLimits": { "cssRawBytes": 2181, "fontRawBytes": 23901, "imageRawBytes": 157940, "largestAssetRawBytes": 753864, - "otherRawBytes": 166599, + "otherRawBytes": 168412, "requestCount": 116, - "scriptRawBytes": 3353678, + "scriptRawBytes": 3371413, "scriptRequestCount": 111, - "totalRawBytes": 3704298 + "totalRawBytes": 3723845 }, - "routeReadyMedianMs": 1376, + "routeReadyMedianMs": 1992.5, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "representative" } @@ -1336,6 +1336,7 @@ "source:src/features/workflow/core/connectors.ts", "source:src/features/workflow/core/document.ts", "source:src/features/workflow/core/fields.ts", + "source:src/features/workflow/core/forLoops.ts", "source:src/features/workflow/core/graphIndex.ts", "source:src/features/workflow/core/libraryTags.ts", "source:src/features/workflow/core/modelRequirements.ts", @@ -2295,6 +2296,7 @@ "source:src/features/workflow/core/connectors.ts", "source:src/features/workflow/core/document.ts", "source:src/features/workflow/core/fields.ts", + "source:src/features/workflow/core/forLoops.ts", "source:src/features/workflow/core/graphIndex.ts", "source:src/features/workflow/core/layerWorkflow.ts", "source:src/features/workflow/core/libraryTags.ts", @@ -3425,11 +3427,13 @@ "source:src/features/workflow/core/connectors.ts", "source:src/features/workflow/core/document.ts", "source:src/features/workflow/core/fields.ts", + "source:src/features/workflow/core/forLoops.ts", "source:src/features/workflow/core/graphIndex.ts", "source:src/features/workflow/core/graphLayout.ts", "source:src/features/workflow/core/graphToDocument.ts", "source:src/features/workflow/core/libraryTags.ts", "source:src/features/workflow/core/modelRequirements.ts", + "source:src/features/workflow/core/outputFields.ts", "source:src/features/workflow/core/types.ts", "source:src/features/workflow/core/validation.ts", "source:src/features/workflow/core/workflowJson.ts", @@ -3450,6 +3454,7 @@ "source:src/features/workflow/ui/editor/CurrentImageFlowNode.tsx", "source:src/features/workflow/ui/editor/EditorToolbar.tsx", "source:src/features/workflow/ui/editor/InvocationFlowNode.tsx", + "source:src/features/workflow/ui/editor/LoopBodyBoundaryOverlay.tsx", "source:src/features/workflow/ui/editor/NodeContextMenu.tsx", "source:src/features/workflow/ui/editor/NotesFlowNode.tsx", "source:src/features/workflow/ui/editor/WorkflowEdge.tsx", diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index 40fb2271e16..f88b4de973a 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -1317,6 +1317,21 @@ "nodeClassification": "Classification: {{classification}}", "iterationOutputs": "Iteration outputs", "finalOutputs": "Final outputs", + "forLoopValidationFailed": "For loop validation failed", + "forLoopMissingIterationOutput": "The For node must have at least one iteration output connection", + "forLoopReturnCount": "The For node must have exactly one matching ForReturn", + "forLoopUnterminatedBody": "Every For body path must terminate at its matching ForReturn", + "forLoopNestedUnsupported": "This nested For loop arrangement is not supported", + "forLoopIterateUnsupported": "Iterate nodes are not supported in this For loop body", + "forLoopIteratorInputUnsupported": "For loop body inputs cannot depend on an external Iterate node", + "forLoopFinalOutputInBody": "Final-scoped For outputs cannot feed the loop body", + "forLoopBodyEscape": "For loop body paths cannot leave the loop before ForReturn", + "forLoopInputCount": "For inputs have too many connections", + "forReturnInputCount": "ForReturn inputs have too many connections", + "forLoopLinkageMissing": "Every For and ForReturn must have loop linkage", + "forLoopLinkageInvalid": "Loop linkage must connect a For to its ForReturn", + "forLoopLinkageDuplicate": "A For and ForReturn can each belong to only one loop", + "forReturnOwnership": "Each ForReturn must belong to exactly one For", "forLoopBodyBoundary": "For loop body", "forLoopBodyBoundaryStatus": { "missing_linkage": "missing loop linkage", diff --git a/invokeai/frontend/webv2/src/features/workflow/core/forLoops.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/forLoops.test.ts index 7e7b4a83c5f..e54d4702b47 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/forLoops.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/forLoops.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import type { WorkflowEdge, WorkflowInvocationNode } from './types'; import { createProjectGraph } from './document'; -import { getCanonicalWorkflowEdges, validateForLoopGraph } from './forLoops'; +import { getCanonicalWorkflowEdges, localizeForLoopValidationReason, validateForLoopGraph } from './forLoops'; const node = (id: string, type: string): WorkflowInvocationNode => ({ data: { @@ -49,6 +49,21 @@ const linkedGraph = () => { }; describe('For/ForReturn graph contracts', () => { + it('localizes scheduler validation reasons without changing unrelated messages', () => { + const translate = (key: string) => + ({ + 'nodes.forLoopFinalOutputInBody': 'Final outputs cannot feed the loop body', + 'nodes.forLoopValidationFailed': 'For loop validation failed', + })[key] ?? key; + + expect( + localizeForLoopValidationReason('For loop validation failed: nodes.forLoopFinalOutputInBody.', translate) + ).toBe('For loop validation failed: Final outputs cannot feed the loop body.'); + expect(localizeForLoopValidationReason('The graph contains a cycle.', translate)).toBe( + 'The graph contains a cycle.' + ); + }); + it('accepts a direct loop linkage and excludes it from data-flow traversal', () => { const { document } = linkedGraph(); @@ -117,6 +132,29 @@ describe('For/ForReturn graph contracts', () => { expect(validateForLoopGraph(document)).toBeNull(); }); + it('rejects a final-scoped output routed through a branch that joins the return', () => { + const document = { + ...createProjectGraph('final-output-branch-test'), + nodes: [ + node('for', 'for'), + node('body', 'number'), + node('return', 'for_return'), + node('downstream', 'number'), + node('downstream-tail', 'number'), + ], + edges: [ + edge('iteration', 'for', 'item', 'body', 'value'), + edge('body-output', 'body', 'value', 'return', 'output'), + edge('final-branch', 'for', 'final_state', 'downstream', 'value'), + edge('final-branch-tail', 'downstream', 'value', 'downstream-tail', 'value'), + edge('final-branch-return', 'downstream-tail', 'value', 'return', 'state'), + edge('linkage', 'for', 'loop_linkage', 'return', 'loop_linkage', 'loop_linkage'), + ], + }; + + expect(validateForLoopGraph(document)).toBe('nodes.forLoopFinalOutputInBody'); + }); + it('rejects a nested For with an external outer continuation condition', () => { const document = { ...createProjectGraph('nested-for-loop-invalid-test'), diff --git a/invokeai/frontend/webv2/src/features/workflow/core/forLoops.ts b/invokeai/frontend/webv2/src/features/workflow/core/forLoops.ts index 6c05c9e8b3d..dc605ac1446 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/forLoops.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/forLoops.ts @@ -20,6 +20,37 @@ export type ForLoopGraphError = | 'nodes.forLoopLinkageDuplicate' | 'nodes.forReturnOwnership'; +const FOR_LOOP_VALIDATION_PREFIX = 'For loop validation failed: '; +const FOR_LOOP_GRAPH_ERRORS: ReadonlySet = new Set([ + 'nodes.forLoopMissingIterationOutput', + 'nodes.forLoopReturnCount', + 'nodes.forLoopUnterminatedBody', + 'nodes.forLoopNestedUnsupported', + 'nodes.forLoopIterateUnsupported', + 'nodes.forLoopIteratorInputUnsupported', + 'nodes.forLoopFinalOutputInBody', + 'nodes.forLoopBodyEscape', + 'nodes.forLoopInputCount', + 'nodes.forReturnInputCount', + 'nodes.forLoopLinkageMissing', + 'nodes.forLoopLinkageInvalid', + 'nodes.forLoopLinkageDuplicate', + 'nodes.forReturnOwnership', +]); + +export const localizeForLoopValidationReason = (reason: string, translate: (key: string) => string): string => { + if (!reason.startsWith(FOR_LOOP_VALIDATION_PREFIX) || !reason.endsWith('.')) { + return reason; + } + + const error = reason.slice(FOR_LOOP_VALIDATION_PREFIX.length, -1); + if (!FOR_LOOP_GRAPH_ERRORS.has(error)) { + return reason; + } + + return `${translate('nodes.forLoopValidationFailed')}: ${translate(error)}.`; +}; + export type LoopBodyBoundaryStatus = | 'complete' | 'missing_linkage' @@ -714,7 +745,7 @@ export const validateForLoopGraph = ( (edge) => edge.source.node_id === node.id && FINAL_OUTPUT_FIELDS.has(edge.source.field) && - bodyPathNodeIds.has(edge.destination.node_id) + (bodyPathNodeIds.has(edge.destination.node_id) || hasPath(edge.destination.node_id, returnId)) ) ) { return 'nodes.forLoopFinalOutputInBody'; diff --git a/invokeai/frontend/webv2/src/features/workflow/core/validation.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/validation.test.ts index 859967608aa..7572f319aab 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/validation.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/validation.test.ts @@ -189,6 +189,36 @@ const templates: InvocationTemplates = { }, }; +const loopTemplates: InvocationTemplates = { + ...templates, + for: { + ...templates.number, + outputs: { + loop_linkage: { + ...templates.number.outputs.value, + name: 'loop_linkage', + title: 'Loop linkage', + type: single('AnyField'), + }, + }, + type: 'for', + }, + for_return: { + ...templates.number, + inputs: { + loop_linkage: { + ...templates.number.inputs.value, + input: 'connection', + name: 'loop_linkage', + title: 'Loop linkage', + type: single('AnyField'), + }, + }, + outputs: {}, + type: 'for_return', + }, +}; + describe('getCompatibleInputTemplate', () => { it('returns the first visible connectable input by UI order', () => { const baseInput = templates.number.inputs.value; @@ -528,6 +558,150 @@ describe('validateConnection', () => { ).toMatch(/already has an input/); }); + it('rejects a For connection that would give one For two loop owners', () => { + const document = { + ...baseDocument, + nodes: [makeNode('for', 'for'), makeNode('return-1', 'for_return'), makeNode('return-2', 'for_return')], + edges: [ + { + id: 'existing-linkage', + source: 'for', + sourceHandle: 'loop_linkage', + target: 'return-1', + targetHandle: 'loop_linkage', + type: 'loop_linkage' as const, + }, + ], + }; + + expect( + validateConnection( + { + sourceHandle: 'loop_linkage', + sourceNodeId: 'for', + targetHandle: 'loop_linkage', + targetNodeId: 'return-2', + }, + document, + loopTemplates + ) + ).toMatch(/For loop linkage/); + }); + + it('rejects a direct linkage connection that would give one ForReturn two loop owners', () => { + const document = { + ...baseDocument, + nodes: [makeNode('for-1', 'for'), makeNode('for-2', 'for'), makeNode('return', 'for_return')], + edges: [ + { + id: 'existing-linkage', + source: 'for-1', + sourceHandle: 'loop_linkage', + target: 'return', + targetHandle: 'loop_linkage', + type: 'loop_linkage' as const, + }, + ], + }; + + expect( + validateConnection( + { + sourceHandle: 'loop_linkage', + sourceNodeId: 'for-2', + targetHandle: 'loop_linkage', + targetNodeId: 'return', + }, + document, + loopTemplates + ) + ).toMatch(/For loop linkage/); + }); + + it('rejects a For connection that would duplicate a connector loop alias', () => { + const document = { + ...baseDocument, + nodes: [ + makeNode('for', 'for'), + makeNode('return', 'for_return'), + makeConnector('connector-1'), + makeConnector('connector-2'), + ], + edges: [ + { + id: 'for-to-connector-1', + source: 'for', + sourceHandle: 'loop_linkage', + target: 'connector-1', + targetHandle: 'in', + type: 'default' as const, + }, + { + id: 'connector-1-to-return', + source: 'connector-1', + sourceHandle: 'out', + target: 'return', + targetHandle: 'loop_linkage', + type: 'default' as const, + }, + ], + }; + + expect( + validateConnection( + { + sourceHandle: 'loop_linkage', + sourceNodeId: 'for', + targetHandle: 'in', + targetNodeId: 'connector-2', + }, + document, + loopTemplates + ) + ).toMatch(/For loop linkage/); + }); + + it('rejects a loop-linked connector when it is reused for ordinary data', () => { + const document = { + ...baseDocument, + nodes: [makeNode('for', 'for'), makeConnector('connector'), makeNode('target', 'number')], + edges: [ + { + id: 'for-to-connector', + source: 'for', + sourceHandle: 'loop_linkage', + target: 'connector', + targetHandle: 'in', + type: 'default' as const, + }, + ], + }; + + expect( + validateConnection( + { sourceHandle: 'out', sourceNodeId: 'connector', targetHandle: 'value', targetNodeId: 'target' }, + document, + loopTemplates + ) + ).toMatch(/For loop linkage/); + }); + + it('allows wiring a connector to a ForReturn before its upstream For is attached', () => { + const document = { + ...baseDocument, + nodes: [makeConnector('connector'), makeNode('return', 'for_return')], + edges: [], + }; + + expect( + validateConnection( + { sourceHandle: 'out', sourceNodeId: 'connector', targetHandle: 'loop_linkage', targetNodeId: 'return' }, + document, + loopTemplates + ) + ).toBeNull(); + }); + it('rejects connector input sources incompatible with an existing downstream target', () => { const stringTemplates: InvocationTemplates = { ...templates, diff --git a/invokeai/frontend/webv2/src/features/workflow/core/validation.ts b/invokeai/frontend/webv2/src/features/workflow/core/validation.ts index de645e7cc55..a5493e81094 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/validation.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/validation.ts @@ -311,6 +311,71 @@ const hasValidSourceHandle = (node: WorkflowNode, handle: string, templates: Inv const isLoopLinkageHandle = (handle: string): boolean => handle === LOOP_LINKAGE_FIELD; +interface LoopLinkageOwnership { + forNodeId: string; + returnNodeId: string; +} + +const getLoopLinkageOwnerships = (document: Pick): LoopLinkageOwnership[] => { + const ownerships: LoopLinkageOwnership[] = []; + + for (const edge of document.edges) { + if ( + edge.type === 'loop_linkage' && + edge.sourceHandle === LOOP_LINKAGE_FIELD && + edge.targetHandle === LOOP_LINKAGE_FIELD + ) { + ownerships.push({ forNodeId: edge.source, returnNodeId: edge.target }); + continue; + } + + if (edge.type !== 'default' || edge.targetHandle !== LOOP_LINKAGE_FIELD) { + continue; + } + + const path = resolveLoopLinkagePath(edge, document.nodes, document.edges); + if (path) { + ownerships.push({ forNodeId: path.forNodeId, returnNodeId: path.returnNodeId }); + } + } + + return ownerships; +}; + +const hasLoopLinkageOwnershipConflict = ( + candidate: LoopLinkageOwnership, + existingOwnerships: LoopLinkageOwnership[] +): boolean => + existingOwnerships.some( + (ownership) => ownership.forNodeId === candidate.forNodeId || ownership.returnNodeId === candidate.returnNodeId + ); + +const getConnectorTerminalEdges = (connectorId: string, index: WorkflowGraphIndex): WorkflowEdge[] => { + const pendingConnectorIds = [connectorId]; + const visitedConnectorIds = new Set(); + const terminalEdges: WorkflowEdge[] = []; + + while (pendingConnectorIds.length > 0) { + const currentConnectorId = pendingConnectorIds.pop(); + if (currentConnectorId === undefined || visitedConnectorIds.has(currentConnectorId)) { + continue; + } + + visitedConnectorIds.add(currentConnectorId); + + for (const edge of index.connectorOutputsById.get(currentConnectorId) ?? []) { + const targetNode = index.nodesById.get(edge.target); + if (targetNode && isConnectorNode(targetNode) && edge.targetHandle === CONNECTOR_INPUT_HANDLE) { + pendingConnectorIds.push(targetNode.id); + } else { + terminalEdges.push(edge); + } + } + } + + return terminalEdges; +}; + const isValidLoopLinkageConnection = ( sourceNode: WorkflowNode, sourceHandle: string, @@ -320,19 +385,56 @@ const isValidLoopLinkageConnection = ( index: WorkflowGraphIndex, templates: InvocationTemplates ): boolean => { + const existingOwnerships = getLoopLinkageOwnerships(document); + if (isInvocationNode(sourceNode) && isInvocationNode(targetNode)) { - return ( + const isDirectLinkage = sourceNode.data.type === 'for' && targetNode.data.type === 'for_return' && sourceHandle === LOOP_LINKAGE_FIELD && - targetHandle === LOOP_LINKAGE_FIELD + targetHandle === LOOP_LINKAGE_FIELD; + + return ( + isDirectLinkage && + !hasLoopLinkageOwnershipConflict({ forNodeId: sourceNode.id, returnNodeId: targetNode.id }, existingOwnerships) ); } if (isInvocationNode(sourceNode) && isConnectorNode(targetNode)) { - return ( - sourceNode.data.type === 'for' && sourceHandle === LOOP_LINKAGE_FIELD && targetHandle === CONNECTOR_INPUT_HANDLE - ); + if ( + sourceNode.data.type === 'for' && + sourceHandle === LOOP_LINKAGE_FIELD && + targetHandle === CONNECTOR_INPUT_HANDLE + ) { + if (existingOwnerships.some((ownership) => ownership.forNodeId === sourceNode.id)) { + return false; + } + + const candidateEdge: WorkflowEdge = { + id: '__candidate-loop-linkage__', + source: sourceNode.id, + sourceHandle, + target: targetNode.id, + targetHandle, + type: 'default', + }; + const terminalEdges = getConnectorTerminalEdges(targetNode.id, index); + if (terminalEdges.length === 0) { + return true; + } + + const candidatePaths = terminalEdges.map((edge) => + resolveLoopLinkagePath(edge, document.nodes, [...document.edges, candidateEdge]) + ); + + return ( + candidatePaths.length === 1 && + candidatePaths[0] !== null && + !hasLoopLinkageOwnershipConflict(candidatePaths[0], existingOwnerships) + ); + } + + return false; } if (isConnectorNode(sourceNode) && isInvocationNode(targetNode)) { @@ -347,34 +449,67 @@ const isValidLoopLinkageConnection = ( const resolvedSource = resolveConnectorSourceIndexed(sourceNode.id, index, templates); const resolvedSourceNode = resolvedSource ? index.nodesById.get(resolvedSource.nodeId) : undefined; + if (existingOwnerships.some((ownership) => ownership.returnNodeId === targetNode.id)) { + return false; + } + + if (resolvedSource === null) { + return true; + } + + const candidateEdge: WorkflowEdge = { + id: '__candidate-loop-linkage__', + source: sourceNode.id, + sourceHandle, + target: targetNode.id, + targetHandle, + type: 'default', + }; + const stagedEdges = [...document.edges, candidateEdge]; + const candidatePath = resolveLoopLinkagePath(candidateEdge, document.nodes, stagedEdges); + return ( - resolvedSource !== null && resolvedSourceNode !== undefined && isInvocationNode(resolvedSourceNode) && resolvedSourceNode.data.type === 'for' && resolvedSource.fieldName === LOOP_LINKAGE_FIELD && - resolveLoopLinkagePath( - { - id: '__candidate-loop-linkage__', - source: sourceNode.id, - sourceHandle, - target: targetNode.id, - targetHandle, - type: 'default', - }, - document.nodes, - [ - ...document.edges, - { - id: '__candidate-loop-linkage__', - source: sourceNode.id, - sourceHandle, - target: targetNode.id, - targetHandle, - type: 'default', - }, - ] - ) !== null + candidatePath !== null && + !hasLoopLinkageOwnershipConflict(candidatePath, existingOwnerships) + ); + } + + if ( + isConnectorNode(sourceNode) && + isConnectorNode(targetNode) && + sourceHandle === CONNECTOR_OUTPUT_HANDLE && + targetHandle === CONNECTOR_INPUT_HANDLE + ) { + const resolvedSource = resolveConnectorSourceIndexed(sourceNode.id, index, templates); + if (resolvedSource?.fieldName !== LOOP_LINKAGE_FIELD) { + return false; + } + + const candidateEdge: WorkflowEdge = { + id: '__candidate-loop-linkage__', + source: sourceNode.id, + sourceHandle, + target: targetNode.id, + targetHandle, + type: 'default', + }; + const terminalEdges = getConnectorTerminalEdges(targetNode.id, index); + if (terminalEdges.length === 0) { + return true; + } + + const candidatePaths = terminalEdges.map((edge) => + resolveLoopLinkagePath(edge, document.nodes, [...document.edges, candidateEdge]) + ); + + return ( + candidatePaths.length === 1 && + candidatePaths[0] !== null && + !hasLoopLinkageOwnershipConflict(candidatePaths[0], existingOwnerships) ); } @@ -405,7 +540,12 @@ export const validateConnection = ( return 'One of the fields has no known definition.'; } - if (isLoopLinkageHandle(sourceHandle) || isLoopLinkageHandle(targetHandle)) { + const sourceConnectorLoopLinkage = + isConnectorNode(sourceNode) && + sourceHandle === CONNECTOR_OUTPUT_HANDLE && + resolveConnectorSourceIndexed(sourceNode.id, index, templates)?.fieldName === LOOP_LINKAGE_FIELD; + + if (isLoopLinkageHandle(sourceHandle) || isLoopLinkageHandle(targetHandle) || sourceConnectorLoopLinkage) { if (!isValidLoopLinkageConnection(sourceNode, sourceHandle, targetNode, targetHandle, document, index, templates)) { return 'For loop linkage must connect a For to its ForReturn.'; } diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewDialog.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewDialog.tsx index 2a370d5f355..0d68c7eb8bc 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewDialog.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewDialog.tsx @@ -3,6 +3,7 @@ import type { ReactFlowInstance } from '@xyflow/react'; import type { ReactNode } from 'react'; import { Box, Dialog, Icon, Portal, SegmentGroup, Stack, Text } from '@chakra-ui/react'; +import { localizeForLoopValidationReason } from '@features/workflow/core/forLoops'; import { useWorkflowGraphPreview } from '@features/workflow/ui/WorkflowUiContext'; import { Button, JsonPreview, toaster } from '@platform/ui'; import { CheckIcon, ChevronUpIcon, CopyIcon, TriangleAlertIcon } from 'lucide-react'; @@ -91,6 +92,9 @@ export const GraphPreviewDialog = ({ const flowInstanceRef = useRef(null); const dialogRoute = graphPreview.getRoute(sourceId); const canInvoke = dialogRoute?.canInvoke === true; + const validationMessage = dialogRoute?.validationMessage + ? localizeForLoopValidationReason(dialogRoute.validationMessage, t) + : undefined; const hasInvalidReasons = source.invalidReasons.length > 0; const graph = source.graph; @@ -305,7 +309,7 @@ export const GraphPreviewDialog = ({ cursor={canInvoke ? undefined : 'not-allowed'} opacity={canInvoke ? undefined : 0.6} size="xs" - title={dialogRoute.validationMessage} + title={validationMessage} onClick={invokeRoute} > {t('graphPreview.invokeRoute', { route: dialogRoute.label })} diff --git a/invokeai/frontend/webv2/src/workbench/shell/topbar/useInvocationState.ts b/invokeai/frontend/webv2/src/workbench/shell/topbar/useInvocationState.ts index 424dc013590..2d885884a83 100644 --- a/invokeai/frontend/webv2/src/workbench/shell/topbar/useInvocationState.ts +++ b/invokeai/frontend/webv2/src/workbench/shell/topbar/useInvocationState.ts @@ -31,6 +31,33 @@ import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; const selectInvocationRouteInput = createInvocationRouteInputSelector(); +const FOR_LOOP_VALIDATION_PREFIX = 'For loop validation failed: '; +const FOR_LOOP_GRAPH_ERRORS: ReadonlySet = new Set([ + 'nodes.forLoopMissingIterationOutput', + 'nodes.forLoopReturnCount', + 'nodes.forLoopUnterminatedBody', + 'nodes.forLoopNestedUnsupported', + 'nodes.forLoopIterateUnsupported', + 'nodes.forLoopIteratorInputUnsupported', + 'nodes.forLoopFinalOutputInBody', + 'nodes.forLoopBodyEscape', + 'nodes.forLoopInputCount', + 'nodes.forReturnInputCount', + 'nodes.forLoopLinkageMissing', + 'nodes.forLoopLinkageInvalid', + 'nodes.forLoopLinkageDuplicate', + 'nodes.forReturnOwnership', +]); +const localizeForLoopValidationReason = (reason: string, translate: (key: string) => string): string => { + if (!reason.startsWith(FOR_LOOP_VALIDATION_PREFIX) || !reason.endsWith('.')) { + return reason; + } + + const error = reason.slice(FOR_LOOP_VALIDATION_PREFIX.length, -1); + return FOR_LOOP_GRAPH_ERRORS.has(error) + ? `${translate('nodes.forLoopValidationFailed')}: ${translate(error)}.` + : reason; +}; const areTypeIdSetsEqual = (left: ReadonlySet, right: ReadonlySet): boolean => left.size === right.size && [...left].every((typeId) => right.has(typeId)); @@ -111,9 +138,9 @@ export const useInvocationState = (): InvocationState => { () => [ ...(isConnected ? [] : ['The backend is disconnected.']), ...(expansionReason === null ? [] : [expansionReason]), - ...resolvedRoute.validationReasons, + ...resolvedRoute.validationReasons.map((reason) => localizeForLoopValidationReason(reason, t)), ], - [expansionReason, isConnected, resolvedRoute.validationReasons] + [expansionReason, isConnected, resolvedRoute.validationReasons, t] ); const isValid = isInvocationRouteValid(resolvedRoute) && isConnected && expansionReason === null; diff --git a/tests/test_node_graph.py b/tests/test_node_graph.py index c49ab16a990..f7805507720 100644 --- a/tests/test_node_graph.py +++ b/tests/test_node_graph.py @@ -819,6 +819,31 @@ def test_graph_rejects_final_scoped_for_output_into_body(): g.validate_self() +def test_graph_rejects_final_scoped_for_output_through_branch_to_return(): + g = Graph() + loop = ForInvocation(id="for", collection=["a", "b"]) + body = AnyTypeTestInvocation(id="body") + body_return = ForReturnInvocation(id="return") + downstream = AnyTypeTestInvocation(id="downstream") + downstream_tail = AnyTypeTestInvocation(id="downstream_tail") + + for node in (loop, body, body_return, downstream, downstream_tail): + g.add_node(node) + g.edges.extend( + [ + create_edge(loop.id, "item", body.id, "value"), + create_edge(body.id, "value", body_return.id, "output"), + create_edge(loop.id, "final_state", downstream.id, "value"), + create_edge(downstream.id, "value", downstream_tail.id, "value"), + create_edge(downstream_tail.id, "value", body_return.id, "state"), + create_loop_linkage(loop.id, body_return.id), + ] + ) + + with pytest.raises(InvalidEdgeError, match="final-scoped"): + g.validate_self() + + def test_graph_rejects_orphan_for_return(): g = Graph() body_return = ForReturnInvocation(id="return") From ad54fc0d4d61db22d2dfc3ebd12e7cd4719fce54 Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Thu, 3 Sep 2026 06:33:48 -0500 Subject: [PATCH 5/5] fix: address For loop review findings --- .../docs/features/Workflows/loop-nodes.mdx | 8 +- invokeai/app/invocations/loops.py | 4 +- invokeai/app/services/shared/graph.py | 46 +++++++--- .../services/shared/workflow_graph_builder.py | 28 ++++-- invokeai/frontend/web/openapi.json | 4 +- .../frontend/web/src/services/api/schema.ts | 2 +- .../features/workflow/core/buildGraph.test.ts | 92 +++++++++++++++++++ .../features/workflow/core/forLoops.test.ts | 45 ++++++++- .../src/features/workflow/core/forLoops.ts | 31 +++++-- .../workflow/ui/WorkflowWidgetChrome.tsx | 17 ++-- tests/app/invocations/test_loop_nodes.py | 7 ++ .../services/test_for_loop_session_runner.py | 36 +++++++- 12 files changed, 271 insertions(+), 49 deletions(-) diff --git a/docs/src/content/docs/features/Workflows/loop-nodes.mdx b/docs/src/content/docs/features/Workflows/loop-nodes.mdx index ffb856cf43b..fb05cd23643 100644 --- a/docs/src/content/docs/features/Workflows/loop-nodes.mdx +++ b/docs/src/content/docs/features/Workflows/loop-nodes.mdx @@ -3,7 +3,7 @@ title: Loop Nodes description: Repeat workflow steps over a collection and optionally carry values from one iteration to the next. sidebar: order: 4 -lastUpdated: 2026-08-30 +lastUpdated: 2026-09-03 --- Loop nodes let a workflow repeat the same group of steps for every item in a collection. For example, a workflow can @@ -39,6 +39,10 @@ The loop is not executable until its two boundary nodes are paired. Connect `For `ForReturn.loop_linkage`. When you add `ForReturn` from an iteration-output connection in the node picker, the editor can create this association for you. Replacing either boundary node requires pairing the replacement again. +Deleting a connector preserves its through-connections by reconnecting the upstream source to surviving downstream +targets. Deleting a complete loop-linkage alias collapses it to a direct `loop_linkage` edge; ordinary connector paths +remain ordinary data edges. + ### A Simple Example To process every image in a collection: @@ -146,7 +150,7 @@ These nodes make the collection relationship explicit before the loop: collections may have different lengths. - **Zip Collections** pairs items by position. Both collections must have the same length; a mismatch is an error. - **Cartesian Product of Collections** creates every possible pair, one item from each collection. The collections may - have different lengths. The result is limited to 100,000 pairs. + have different lengths. Products larger than 100,000 pairs fail with an error. Choose based on the relationship between the items: diff --git a/invokeai/app/invocations/loops.py b/invokeai/app/invocations/loops.py index 4c472b352bc..019e71e8c58 100644 --- a/invokeai/app/invocations/loops.py +++ b/invokeai/app/invocations/loops.py @@ -29,7 +29,7 @@ class LoopStateOutput(BaseInvocationOutput): class LoopStateValueOutput(BaseInvocationOutput): value: Optional[Any] = OutputField( default=None, - description="The value read from the loop state, or None when the key is missing", + description="The value read from the loop state, or the configured default when the key is missing", ui_type=UIType.Any, ) @@ -42,7 +42,7 @@ def invoke(self, context: InvocationContext) -> LoopStateOutput: return LoopStateOutput(state=LoopState()) -@invocation("state_get", title="Get Loop State Value", tags=["loop", "state"], category="workflow", version="1.0.2") +@invocation("state_get", title="Get Loop State Value", tags=["loop", "state"], category="workflow", version="1.0.3") class StateGetInvocation(BaseInvocation): """Reads a value from loop state.""" diff --git a/invokeai/app/services/shared/graph.py b/invokeai/app/services/shared/graph.py index ec3bc12fe4a..ef63d8c3eca 100644 --- a/invokeai/app/services/shared/graph.py +++ b/invokeai/app/services/shared/graph.py @@ -99,6 +99,14 @@ class _SupportedNestedForBody: continuation_nodes: frozenset[str] +@dataclass(frozen=True) +class _SupportedNestedIterateBody: + body_path_nodes: set[str] + return_node_id: str + iterate_node_id: str + collect_node_id: str + + class EdgeConnection(BaseModel): model_config = ConfigDict(frozen=True) @@ -381,7 +389,8 @@ def mark_exec_node_skipped(self, exec_node_id: str) -> None: if all(n in self._state.executed for n in prepared_nodes): if source_node_id not in self._state.executed: self._state.executed.add(source_node_id) - self._state.executed_history.append(source_node_id) + if source_node_id not in self._state.executed_history: + self._state.executed_history.append(source_node_id) def try_resolve_if_node(self, exec_node_id: str) -> None: if exec_node_id in self._state._resolved_if_exec_branches: @@ -955,9 +964,12 @@ def _create_nested_iterate_body_iteration( prepared_for_id: str, graph: "nx.DiGraph", execution_graph: "nx.DiGraph", - nested_body: tuple[set[str], str, str, str], + nested_body: _SupportedNestedIterateBody, ) -> Optional[str]: - body_path_nodes, source_return_id, source_iterate_id, source_collect_id = nested_body + body_path_nodes = nested_body.body_path_nodes + source_return_id = nested_body.return_node_id + source_iterate_id = nested_body.iterate_node_id + source_collect_id = nested_body.collect_node_id prepared_for_node = self._state.execution_graph.get_node(prepared_for_id) outer_iteration_path = self._state._get_iteration_path(prepared_for_id) if isinstance(prepared_for_node, ForInvocation) and prepared_for_node.index >= 0: @@ -1362,7 +1374,8 @@ def _get_parent_iteration_mappings_without_iterators(self, next_node_id: str) -> def _mark_source_node_empty(self, source_node_id: str) -> None: self._state.source_prepared_mapping[source_node_id] = set() self._state.executed.add(source_node_id) - self._state.executed_history.append(source_node_id) + if source_node_id not in self._state.executed_history: + self._state.executed_history.append(source_node_id) def _index_prepared_nodes_by_iteration_path( self, prepared_nodes: set[str], input_edges: list[Edge] @@ -1885,8 +1898,8 @@ def _try_materialize_deferred_nested_for_body(self, exec_node_id: str) -> None: nested_body = self._state.graph._get_supported_for_nested_iterate_body(source_for_id, graph) nested_for_body = self._state.graph._get_supported_for_nested_for_body(source_for_id, graph) if nested_body is not None: - body_path_nodes, _return_id, iterate_id, _collect_id = nested_body - deferred_node_ids = (iterate_id,) + body_path_nodes = nested_body.body_path_nodes + deferred_node_ids = (nested_body.iterate_node_id,) elif nested_for_body is None: continue else: @@ -2033,7 +2046,9 @@ def complete(self, exec_node_id: str, output: BaseInvocationOutput) -> None: (iterate_path := self._state._get_iteration_path(prepared_iterate_id))[: len(prepared_for_path)] == prepared_for_path and len(iterate_path) > len(prepared_for_path) - for prepared_iterate_id in self._state._prepared_registry().get_prepared_ids(nested_body[2]) + for prepared_iterate_id in self._state._prepared_registry().get_prepared_ids( + nested_body.iterate_node_id + ) ): self._state._materializer().create_for_body_iteration( source_for_id=source_for_id, prepared_for_id=exec_node_id @@ -2042,7 +2057,7 @@ def complete(self, exec_node_id: str, output: BaseInvocationOutput) -> None: self._try_materialize_deferred_nested_for_body(exec_node_id) else: self._try_materialize_deferred_nested_for_body(exec_node_id) - if len(self._state.executed_history) == len(self._state.graph.nodes): + if self._state.is_complete(): self._state.execution_graph._invalidate_edge_indexes() self._state._ready_queues = {} self._state._ready_node_ids = set() @@ -3203,7 +3218,7 @@ def _get_for_body_path_to_return(self, node_id: str, graph: "nx.DiGraph") -> tup def _get_supported_for_nested_iterate_body( self, node_id: str, graph: "nx.DiGraph" - ) -> tuple[set[str], str, str, str] | None: + ) -> _SupportedNestedIterateBody | None: """Returns the bounded internal Iterate body contract, if this For uses it.""" body_path_to_return = self._get_for_body_path_to_return(node_id, graph) if body_path_to_return is None: @@ -3267,7 +3282,12 @@ def _get_supported_for_nested_iterate_body( ): return None - return body_path_nodes, return_node_id, iterate_node_id, collect_node_id + return _SupportedNestedIterateBody( + body_path_nodes=body_path_nodes, + return_node_id=return_node_id, + iterate_node_id=iterate_node_id, + collect_node_id=collect_node_id, + ) def _get_supported_for_nested_for_body(self, node_id: str, graph: "nx.DiGraph") -> _SupportedNestedForBody | None: """Returns the supported recursive nested For contract, if this For uses it. @@ -4005,7 +4025,8 @@ def _mark_for_source_complete(self, source_for_id: str) -> None: for source_node_id in source_node_ids: if source_node_id not in self.executed: self.executed.add(source_node_id) - self.executed_history.append(source_node_id) + if source_node_id not in self.executed_history: + self.executed_history.append(source_node_id) def _get_for_parent_iteration_paths(self, source_for_id: str) -> set[tuple[int, ...]]: return { @@ -4211,7 +4232,8 @@ def is_complete(self) -> bool: for source_node_id in nx.topological_sort(self.graph.nx_graph_flat()): if source_node_id in completed_source_ids and source_node_id not in self.executed: self.executed.add(source_node_id) - self.executed_history.append(source_node_id) + if source_node_id not in self.executed_history: + self.executed_history.append(source_node_id) return complete def has_error(self) -> bool: diff --git a/invokeai/app/services/shared/workflow_graph_builder.py b/invokeai/app/services/shared/workflow_graph_builder.py index 712de8d0641..16ce64ce7f1 100644 --- a/invokeai/app/services/shared/workflow_graph_builder.py +++ b/invokeai/app/services/shared/workflow_graph_builder.py @@ -1,4 +1,5 @@ from collections.abc import Mapping, MutableMapping, Sequence +from dataclasses import dataclass from typing import Any from invokeai.app.invocations.baseinvocation import Classification, InvocationRegistry @@ -20,6 +21,13 @@ class InvalidWorkflowInputError(ValueError): pass +@dataclass(frozen=True) +class _ConnectorLoopLinkagePath: + source_id: str + target_id: str + edge_path: tuple[Mapping[str, Any], ...] + + def _is_mapping(value: Any) -> bool: return isinstance(value, Mapping) @@ -298,7 +306,7 @@ def resolve(node_id: str) -> tuple[str, str] | None: def _resolve_for_connector_loop_linkage_path( edge: Mapping[str, Any], workflow_nodes: dict[str, Mapping[str, Any]], workflow_edges: Sequence[Mapping[str, Any]] -) -> tuple[str, str, list[Mapping[str, Any]]] | None: +) -> _ConnectorLoopLinkagePath | None: """Resolve one connector alias path from a For to its direct ForReturn association.""" if edge.get("sourceHandle") != "loop_linkage" or edge.get("targetHandle") != CONNECTOR_INPUT_HANDLE: return None @@ -340,7 +348,7 @@ def _resolve_for_connector_loop_linkage_path( if _is_invocation_node(target_node): if target_node.get("data", {}).get("type") != "for_return" or target_handle != "loop_linkage": return None - return source_id, target_id, path_edges + return _ConnectorLoopLinkagePath(source_id=source_id, target_id=target_id, edge_path=tuple(path_edges)) if ( not isinstance(target_id, str) or not _is_connector_node(target_node) @@ -362,7 +370,7 @@ def build_graph_from_workflow(workflow: Mapping[str, Any]) -> Graph: default_edges = _get_default_edges(workflow_edges) loop_linkage_edges = _get_loop_linkage_edges(workflow_edges) - connector_loop_linkage_paths: list[tuple[str, str, list[Mapping[str, Any]]]] = [] + connector_loop_linkage_paths: list[_ConnectorLoopLinkagePath] = [] connector_loop_linkage_edge_ids: set[int] = set() linked_for_ids = { edge.get("source") @@ -393,14 +401,14 @@ def build_graph_from_workflow(workflow: Mapping[str, Any]) -> Graph: raise InvalidWorkflowInputError( "loop_linkage connector path must resolve to exactly one For and one ForReturn without branching" ) - if path[0] in linked_for_ids or path[1] in linked_return_ids: + if path.source_id in linked_for_ids or path.target_id in linked_return_ids: raise InvalidWorkflowInputError( "loop_linkage connector path must resolve to exactly one For and one ForReturn without branching" ) connector_loop_linkage_paths.append(path) - connector_loop_linkage_edge_ids.update(id(path_edge) for path_edge in path[2]) - linked_for_ids.add(path[0]) - linked_return_ids.add(path[1]) + connector_loop_linkage_edge_ids.update(id(path_edge) for path_edge in path.edge_path) + linked_for_ids.add(path.source_id) + linked_return_ids.add(path.target_id) parsed_nodes: dict[str, dict[str, Any]] = {} for node in workflow_nodes.values(): @@ -502,12 +510,12 @@ def build_graph_from_workflow(workflow: Mapping[str, Any]) -> Graph: } ) - for source_id, target_id, _ in connector_loop_linkage_paths: + for path in connector_loop_linkage_paths: parsed_edges.append( { "type": "loop_linkage", - "source": {"node_id": source_id, "field": "loop_linkage"}, - "destination": {"node_id": target_id, "field": "loop_linkage"}, + "source": {"node_id": path.source_id, "field": "loop_linkage"}, + "destination": {"node_id": path.target_id, "field": "loop_linkage"}, } ) diff --git a/invokeai/frontend/web/openapi.json b/invokeai/frontend/web/openapi.json index 74324c4bce4..ebe972e5719 100644 --- a/invokeai/frontend/web/openapi.json +++ b/invokeai/frontend/web/openapi.json @@ -60165,7 +60165,7 @@ } ], "default": null, - "description": "The value read from the loop state, or None when the key is missing", + "description": "The value read from the loop state, or the configured default when the key is missing", "field_kind": "output", "title": "Value", "ui_hidden": false, @@ -89319,7 +89319,7 @@ "tags": ["loop", "state"], "title": "Get Loop State Value", "type": "object", - "version": "1.0.2", + "version": "1.0.3", "output": { "$ref": "#/components/schemas/LoopStateValueOutput" } diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 72196157145..56b337bdd7c 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -24562,7 +24562,7 @@ export type components = { LoopStateValueOutput: { /** * Value - * @description The value read from the loop state, or None when the key is missing + * @description The value read from the loop state, or the configured default when the key is missing * @default null */ value: unknown | null; diff --git a/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.test.ts index 6623468000c..1c361151057 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.test.ts @@ -461,4 +461,96 @@ describe('compileProjectGraph', () => { }, ]); }); + + it('rejects a loop-linkage source reused as ordinary connector data', () => { + const forTemplate: InvocationTemplate = { + ...template('for', { + collection: input('collection', { + default: [], + type: { batch: false, cardinality: 'COLLECTION', name: 'CollectionField' }, + }), + }), + outputs: { + item: { + description: '', + name: 'item', + outputScope: 'iteration', + title: 'Item', + type: { batch: false, cardinality: 'SINGLE', name: 'CollectionItemField' }, + }, + loop_linkage: { + description: '', + name: 'loop_linkage', + title: 'Loop linkage', + type: { batch: false, cardinality: 'SINGLE', name: 'AnyField' }, + }, + output_collection: { + description: '', + name: 'output_collection', + outputScope: 'final', + title: 'Output collection', + type: { batch: false, cardinality: 'COLLECTION', name: 'CollectionField' }, + }, + }, + }; + const returnTemplate: InvocationTemplate = { + ...template('for_return', { + loop_linkage: input('loop_linkage', { + input: 'connection', + type: { batch: false, cardinality: 'SINGLE', name: 'AnyField' }, + }), + output: input('output', { type: { batch: false, cardinality: 'SINGLE', name: 'CollectionItemField' } }), + }), + outputs: {}, + }; + const sinkTemplate = template('sink', { value: input('value') }); + const forNode = buildInvocationNode(forTemplate, { x: 0, y: 0 }); + const returnNode = buildInvocationNode(returnTemplate, { x: 100, y: 0 }); + const sinkNode = buildInvocationNode(sinkTemplate, { x: 200, y: 0 }); + const connector = buildConnectorNode({ x: 50, y: 0 }); + const document: ProjectGraphState = { + ...createProjectGraph('invalid-loop-linkage-source'), + edges: [ + { + id: 'item', + source: forNode.id, + sourceHandle: 'item', + target: returnNode.id, + targetHandle: 'output', + type: 'default', + }, + { + id: 'linkage', + source: forNode.id, + sourceHandle: 'loop_linkage', + target: returnNode.id, + targetHandle: 'loop_linkage', + type: 'loop_linkage', + }, + { + id: 'connector-in', + source: forNode.id, + sourceHandle: 'loop_linkage', + target: connector.id, + targetHandle: 'in', + type: 'default', + }, + { + id: 'connector-out', + source: connector.id, + sourceHandle: 'out', + target: sinkNode.id, + targetHandle: 'value', + type: 'default', + }, + ], + nodes: [forNode, returnNode, sinkNode, connector], + }; + const loopTemplates = { for: forTemplate, for_return: returnTemplate, sink: sinkTemplate }; + + expect(getProjectGraphReadiness(document, { error: null, status: 'loaded', templates: loopTemplates })).toEqual({ + canInvoke: false, + reasons: ['For loop validation failed: nodes.forLoopLinkageInvalid.'], + }); + }); }); diff --git a/invokeai/frontend/webv2/src/features/workflow/core/forLoops.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/forLoops.test.ts index e54d4702b47..f069f199f4e 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/forLoops.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/forLoops.test.ts @@ -3,7 +3,13 @@ import { describe, expect, it } from 'vitest'; import type { WorkflowEdge, WorkflowInvocationNode } from './types'; import { createProjectGraph } from './document'; -import { getCanonicalWorkflowEdges, localizeForLoopValidationReason, validateForLoopGraph } from './forLoops'; +import { + getCanonicalWorkflowEdges, + getForLoopBodyBoundaries, + localizeForLoopValidationReason, + shouldAddForReturnLoopLinkage, + validateForLoopGraph, +} from './forLoops'; const node = (id: string, type: string): WorkflowInvocationNode => ({ data: { @@ -64,6 +70,43 @@ describe('For/ForReturn graph contracts', () => { ); }); + it('promotes connector-routed For iteration output to direct loop linkage', () => { + const forNode = node('for', 'for'); + + expect(shouldAddForReturnLoopLinkage('for_return', { fieldName: 'item', nodeId: forNode.id }, forNode, [])).toBe( + true + ); + }); + + it('scopes duplicate linkage status to the affected loop', () => { + const document = { + ...createProjectGraph('duplicate-boundary-test'), + nodes: [ + node('for-a', 'for'), + node('body-a', 'number'), + node('return-a', 'for_return'), + node('for-b', 'for'), + node('body-b', 'number'), + node('return-b', 'for_return'), + node('stray-return', 'for_return'), + ], + edges: [ + edge('a-item', 'for-a', 'item', 'body-a', 'value'), + edge('a-output', 'body-a', 'value', 'return-a', 'output'), + edge('a-linkage', 'for-a', 'loop_linkage', 'return-a', 'loop_linkage', 'loop_linkage'), + edge('a-duplicate', 'for-a', 'loop_linkage', 'stray-return', 'loop_linkage', 'loop_linkage'), + edge('b-item', 'for-b', 'item', 'body-b', 'value'), + edge('b-output', 'body-b', 'value', 'return-b', 'output'), + edge('b-linkage', 'for-b', 'loop_linkage', 'return-b', 'loop_linkage', 'loop_linkage'), + ], + }; + + const boundaries = getForLoopBodyBoundaries(document.nodes, document.edges); + + expect(boundaries.find((boundary) => boundary.forNodeId === 'for-a')?.status).toBe('duplicate_linkage'); + expect(boundaries.find((boundary) => boundary.forNodeId === 'for-b')?.status).toBe('complete'); + }); + it('accepts a direct loop linkage and excludes it from data-flow traversal', () => { const { document } = linkedGraph(); diff --git a/invokeai/frontend/webv2/src/features/workflow/core/forLoops.ts b/invokeai/frontend/webv2/src/features/workflow/core/forLoops.ts index dc605ac1446..df6f75104f6 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/forLoops.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/forLoops.ts @@ -3,6 +3,7 @@ import type { WorkflowEdge, WorkflowNode, ProjectGraphState } from './types'; import { getResolvedWorkflowEdgesIndexed, resolveLoopLinkagePath } from './connectors'; import { createWorkflowGraphIndex } from './graphIndex'; import { isInvocationNode } from './types'; +import { LOOP_LINKAGE_FIELD } from './validation'; export type ForLoopGraphError = | 'nodes.forLoopMissingIterationOutput' @@ -67,10 +68,21 @@ export interface LoopBodyBoundary { status: LoopBodyBoundaryStatus; } -const LOOP_LINKAGE_FIELD = 'loop_linkage'; const ITERATION_OUTPUT_FIELDS = new Set(['item', 'index', 'total', 'state']); const FINAL_OUTPUT_FIELDS = new Set(['output_collection', 'final_state']); +export const shouldAddForReturnLoopLinkage = ( + targetType: string, + resolvedSource: { fieldName: string; nodeId: string } | null, + resolvedSourceNode: WorkflowNode | undefined, + edges: readonly WorkflowEdge[] +): boolean => + targetType === 'for_return' && + ITERATION_OUTPUT_FIELDS.has(resolvedSource?.fieldName ?? '') && + resolvedSourceNode?.type === 'invocation' && + resolvedSourceNode.data.type === 'for' && + !edges.some((edge) => edge.source === resolvedSourceNode.id && edge.sourceHandle === LOOP_LINKAGE_FIELD); + type LoopNode = { id: string; type: string }; type LoopEdge = { id: string; @@ -182,11 +194,17 @@ export const getForLoopBodyBoundaries = (nodes: WorkflowNode[], edges: WorkflowE const linkedReturnByForId = new Map(); const linkedForByReturnId = new Map(); - let duplicateLinkage = false; + const duplicateForIds = new Set(); + const duplicateReturnIds = new Set(); for (const edge of canonicalEdges.filter((candidate) => candidate.type === 'loop_linkage')) { - if (linkedReturnByForId.has(edge.source.node_id) || linkedForByReturnId.has(edge.destination.node_id)) { - duplicateLinkage = true; + const existingForId = linkedForByReturnId.get(edge.destination.node_id); + if (linkedReturnByForId.has(edge.source.node_id) || existingForId !== undefined) { + duplicateForIds.add(edge.source.node_id); + if (existingForId !== undefined) { + duplicateForIds.add(existingForId); + duplicateReturnIds.add(edge.destination.node_id); + } continue; } linkedReturnByForId.set(edge.source.node_id, edge.destination.node_id); @@ -209,7 +227,7 @@ export const getForLoopBodyBoundaries = (nodes: WorkflowNode[], edges: WorkflowE const returnNodeId = linkedReturnId ?? (reachableReturns.length === 1 ? reachableReturns[0] : undefined); let status: LoopBodyBoundaryStatus; - if (duplicateLinkage && linkedReturnId !== undefined) { + if (duplicateForIds.has(forNode.id) || (linkedReturnId !== undefined && duplicateReturnIds.has(linkedReturnId))) { status = 'duplicate_linkage'; } else if (linkedReturnId === undefined) { status = 'missing_linkage'; @@ -259,8 +277,7 @@ export const validateForLoopGraph = ( allEdges.some( (edge) => edge.type === 'default' && - edge.source.field === LOOP_LINKAGE_FIELD && - edge.destination.field === LOOP_LINKAGE_FIELD && + (edge.source.field === LOOP_LINKAGE_FIELD || edge.destination.field === LOOP_LINKAGE_FIELD) && nodesById.has(edge.source.node_id) && nodesById.has(edge.destination.node_id) ) diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/WorkflowWidgetChrome.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/WorkflowWidgetChrome.tsx index d8bc78a56cb..a74f5ff7032 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/WorkflowWidgetChrome.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/WorkflowWidgetChrome.tsx @@ -16,6 +16,7 @@ import { getCompatibleOutputTemplate, LOOP_LINKAGE_FIELD, resolveConnectorSource, + shouldAddForReturnLoopLinkage, parseWorkflowJson, serializeWorkflowJson, } from '@features/workflow/utility'; @@ -451,16 +452,12 @@ export const WorkflowDialogHost = () => { ? { fieldName: addNodeConnection.sourceHandle, nodeId: sourceNode.id, type: null } : null; const resolvedSourceNode = currentGraph.nodes.find((candidate) => candidate.id === resolvedSource?.nodeId); - const shouldAddLoopLinkage = - template.type === 'for_return' && - addNodeConnection.sourceHandle !== LOOP_LINKAGE_FIELD && - ['item', 'index', 'total', 'state'].includes(addNodeConnection.sourceHandle) && - ['item', 'index', 'total', 'state'].includes(resolvedSource?.fieldName ?? '') && - resolvedSourceNode?.type === 'invocation' && - resolvedSourceNode.data.type === 'for' && - !currentGraph.edges.some( - (candidate) => candidate.source === resolvedSourceNode.id && candidate.sourceHandle === LOOP_LINKAGE_FIELD - ); + const shouldAddLoopLinkage = shouldAddForReturnLoopLinkage( + template.type, + resolvedSource, + resolvedSourceNode, + currentGraph.edges + ); if (shouldAddLoopLinkage && resolvedSourceNode?.type === 'invocation') { editGraph({ diff --git a/tests/app/invocations/test_loop_nodes.py b/tests/app/invocations/test_loop_nodes.py index 7ba0d27cf34..6fb38255854 100644 --- a/tests/app/invocations/test_loop_nodes.py +++ b/tests/app/invocations/test_loop_nodes.py @@ -73,6 +73,13 @@ def test_state_get_output_schema_exposes_any_value_output() -> None: assert schema["properties"]["value"]["ui_type"] == "AnyField" +def test_state_get_output_schema_describes_configured_default() -> None: + schema = LoopStateValueOutput.model_json_schema() + + assert StateGetInvocation.UIConfig.version == "1.0.3" + assert "configured default" in schema["properties"]["value"]["description"] + + def test_state_merge_invocation_schema_exposes_any_values_input() -> None: schema = StateMergeInvocation.model_json_schema() diff --git a/tests/app/services/test_for_loop_session_runner.py b/tests/app/services/test_for_loop_session_runner.py index 4ec8ba32f89..39f0e14cf19 100644 --- a/tests/app/services/test_for_loop_session_runner.py +++ b/tests/app/services/test_for_loop_session_runner.py @@ -110,6 +110,7 @@ def _build_nested_for_graph( collection: list[list[int]] | None = None, break_inner_after_first: bool = False, break_outer_after_first: bool = False, + include_after: bool = True, ) -> Graph: graph = Graph() graph.add_node( @@ -131,7 +132,8 @@ def _build_nested_for_graph( graph.add_node(ForRunnerConditionInvocation(id="outer_condition", continue_condition=not break_outer_after_first)) graph.add_node(ForRunnerCollectionInvocation(id="outer_output")) graph.add_node(ForReturnInvocation(id="outer_return")) - graph.add_node(ForRunnerCollectionInvocation(id="after")) + if include_after: + graph.add_node(ForRunnerCollectionInvocation(id="after")) graph.add_edge(create_edge("outer_for", "item", "inner_collection", "value")) graph.add_edge(create_edge("inner_collection", "collection", "inner_for", "collection")) graph.add_edge(create_edge("inner_for", "item", "inner_body", "value")) @@ -141,7 +143,8 @@ def _build_nested_for_graph( graph.add_edge(create_edge("inner_for", "output_collection", "outer_condition", "value")) graph.add_edge(create_edge("outer_condition", "value", "outer_return", "continue_condition")) graph.add_edge(create_edge("outer_for", "state", "outer_return", "state")) - graph.add_edge(create_edge("outer_for", "output_collection", "after", "collection")) + if include_after: + graph.add_edge(create_edge("outer_for", "output_collection", "after", "collection")) graph.add_edge(create_loop_linkage("outer_for", "outer_return")) graph.add_edge(create_loop_linkage("inner_for", "inner_return")) return graph @@ -407,6 +410,35 @@ def test_session_runner_completes_nested_for_and_releases_outer_final_outputs( assert session.results[after_exec_id] == ForRunnerCollectionOutput(collection=[[1, 2], [3, 4]]) +def test_session_runner_completes_nested_for_without_downstream_consumer( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = GraphExecutionState(graph=_build_nested_for_graph(include_after=False)) + runner, _cancel_event, session_queue, _events = _build_runner(monkeypatch) + queue_item = _build_queue_item(session) + session_queue.add_queue_item(queue_item) + + runner.run(queue_item) + + assert queue_item.status == "completed" + assert session_queue.completed_item_ids == [queue_item.item_id] + assert session.is_complete() + + +def test_session_runner_does_not_duplicate_empty_nested_loop_history( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = GraphExecutionState(graph=_build_nested_for_graph(collection=[[], []])) + runner, _cancel_event, session_queue, _events = _build_runner(monkeypatch) + queue_item = _build_queue_item(session) + session_queue.add_queue_item(queue_item) + + runner.run(queue_item) + + assert queue_item.status == "completed" + assert len(session.executed_history) == len(set(session.executed_history)) + + def test_session_runner_completes_nested_for_with_empty_inner_collection( monkeypatch: pytest.MonkeyPatch, ) -> None: