Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7047412d3 | ||
|
|
854ea97e02 | ||
|
|
0de81b2ff8 | ||
|
|
effb2443e7 | ||
|
|
b711fa8f07 | ||
|
|
33d3bb4f49 |
@@ -362,3 +362,10 @@ MigrationBackup/
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
/.claude
|
||||
/.codex
|
||||
|
||||
# Others
|
||||
/.ruff_cache
|
||||
|
||||
# env
|
||||
.env
|
||||
@@ -4,15 +4,20 @@ ComfyUI-Subworkflow adds reusable workflow boundaries to ComfyUI. It lets one wo
|
||||
|
||||

|
||||
|
||||
## Subworkflow custom nodes
|
||||
----------------
|
||||
|
||||
To control the workflow boundaries, this extension adds several custom nodes: `Subworkflow`, `Subworkflow Input`, and `Subworkflow Output`. These nodes work together to load and execute inner workflows while exposing their inputs and outputs on the outer workflow.
|
||||
# Subworkflow custom nodes
|
||||
|
||||
### Subworkflow
|
||||
To control the workflow boundaries, this extension adds several custom nodes: `Subworkflow`, `Subworkflow Input`, and `Subworkflow Output`.
|
||||
These nodes work together to load and execute inner workflows while exposing their inputs and outputs on the outer workflow.
|
||||
|
||||
To resolve circular reference problems when an inner workflow output needs to be modified and passed back into the inner workflow input, `Subworkflow Modifier` and `Subworkflow Modifier Source` nodes are added as well.
|
||||
|
||||
## Node: Subworkflow
|
||||
|
||||
Loads a workflow from `ComfyUI/user/default/workflows` and expands it into the current prompt at execution time. The selected inner workflow determines the visible input and output slots on the node.
|
||||
|
||||
Inputs:
|
||||
**Inputs:**
|
||||
- `workflow`: workflow file to execute (the inner workflow).
|
||||
- `at execution`: controls whether the inner workflow file is reloaded on every execution or a loaded workflow instance is kept.
|
||||
|
||||
@@ -23,18 +28,18 @@ In the example below you can see several workflows are used, and the image to vi
|
||||

|
||||
*Example workflow with several Subworkflow nodes with loaded inner workflows*
|
||||
|
||||
### Subworkflow (from URL)
|
||||
## Node: Subworkflow (from URL)
|
||||
|
||||
Loads a workflow from a URL and expands it into the current prompt at execution time. The selected inner workflow determines the visible input and output slots on the node.
|
||||
|
||||
Inputs:
|
||||
**Inputs:**
|
||||
- `url`: absolute `http://` or `https://` URL that returns a workflow JSON file.
|
||||
- `at execution`: controls whether the inner workflow URL is reloaded on every execution or a loaded workflow instance is kept.
|
||||
- `verify ssl`: controls whether HTTPS certificate validation is enforced while loading from URL.
|
||||
- `verify ssl`: controls whether HTTPS certificate validation is enforced while loading from URL (in case of Python SSL certificate issues with certain endpoints, such as GitHub raw URLs). Use with caution and only for trusted sources.
|
||||
|
||||
Outputs are dynamic and are inferred from the inner workflow's `Subworkflow Output` nodes.
|
||||
**Outputs:** dynamic and are inferred from the inner workflow's `Subworkflow Output` nodes.
|
||||
|
||||
### Subworkflow Input
|
||||
## Node: Subworkflow Input
|
||||
|
||||
Marks an input boundary inside a reusable workflow. The `slot_name` widget controls the label shown on the outer `Subworkflow` node. The value type is inferred from the connected node.
|
||||
|
||||
@@ -43,21 +48,114 @@ That linked node is ignored when used as inner workflow inside `Subworkflow`, an
|
||||
If no linked node is attached to the `Subworkflow` input, the linked node of the `Subworkflow Input` is used as fallback. This allows for optional inputs on the inner workflow.
|
||||
This structure allows for default values to be set on the input, and to use the same workflow as standalone or as inner workflow without changes.
|
||||
|
||||
### Subworkflow Output
|
||||
## Node: Subworkflow Output
|
||||
|
||||
Marks an output boundary inside a reusable workflow. The `slot_name` widget controls the label shown on the outer `Subworkflow` node. The output type is inferred from the connected value.
|
||||
|
||||
`Subworkflow Output` also behaves as a passthrough when other nodes inside the same inner workflow consume its output.
|
||||
|
||||
## Subworkflow vs Subgraph
|
||||
## Node: Subworkflow Modifier
|
||||
|
||||
The `Subworkflow Modifier` node allows for modifying a value from the inner workflow and passing it back into the inner workflow without creating circular reference problems.
|
||||
Used inside the inner workflow and accessed from outer workflow by the `Subworkflow Modifier Source` node.
|
||||
|
||||
## Node: Subworkflow Modifier Source
|
||||
|
||||
The `Subworkflow Modifier Source` node is used in combination with the `Subworkflow Modifier` node.
|
||||
Use the native dropdown at the top of the node to select the loaded `Subworkflow` or `Subworkflow (from URL)` whose modifier values should be exposed. The selector is a widget only and has no input socket. The source shares that target node's parsed workflow data and execution settings instead of loading a second independent copy.
|
||||
This remains a separate outer node to avoid the graph cycle that would arise if the modified value were passed directly from the target `Subworkflow` output back into its input.
|
||||
The older `Subworkflow Modifier Source (from URL)` node remains available only for compatibility with saved workflows; new workflows should select a URL-backed target from the regular modifier source.
|
||||
See Features section below for an example of how to use these nodes together.
|
||||
|
||||
----------------
|
||||
|
||||
# Installation
|
||||
|
||||
How get the Subworkflow custom nodes in ComfyUI.
|
||||
|
||||
## From ComfyUI Custom Nodes UI
|
||||
|
||||
1. Open the ComfyUI Manager
|
||||
2. Open the Custom Nodes Manager
|
||||
3. Make sure to remove filters
|
||||
4. Search for "Subworkflow (re-useable workflows)" and click install.
|
||||
5. Restart ComfyUI after installation. Browser also require a hard refresh.
|
||||
|
||||
## From GitHub Repository
|
||||
|
||||
Clone this repository into your ComfyUI custom nodes directory:
|
||||
|
||||
```bash
|
||||
cd ComfyUI/custom_nodes
|
||||
git clone https://github.com/eniewold/ComfyUI-Subworkflow.git
|
||||
```
|
||||
|
||||
Restart ComfyUI after installation or after Python changes. Browser-side updates also require a hard refresh.
|
||||
Note that there are no library dependencies outside of the standard Python environment bundled with ComfyUI.
|
||||
|
||||
**FEEDBACK WELCOME**: This is an early release and work in progress. Expect bugs and breaking changes as I continue development.
|
||||
Please report any issues you encounter, especially those unrelated to the known issues below, and share your use cases and feedback.
|
||||
Check below on how to get debug messages in the log files.
|
||||
|
||||
## Simple Step-by-Step Usage Example
|
||||
|
||||
1. Adjust an (inner) workflow and add one or more `Subworkflow Input` nodes where external values should enter.
|
||||
2. Add one or more `Subworkflow Output` nodes where values should leave the inner workflow.
|
||||
3. Set meaningful `slot_name` values on each input and output boundary.
|
||||
4. Save the inner workflow (keep default folder `ComfyUI/user/default/workflows`).
|
||||
5. In another workflow, add a `Subworkflow` node and select the saved workflow file (now called the inner workflow).
|
||||
6. Connect or set value for the input and output slots.
|
||||
|
||||
----------------
|
||||
|
||||
# Features
|
||||
|
||||
Highlights of the current features of this extension. See the example workflows and notes below for more details and use cases.
|
||||
|
||||
## Modifier Nodes 
|
||||
|
||||
If you want to expose an output from the inner workflow, modify it (for example, add loras to a model), and then pass it back into the inner workflow, you can use the `Subworkflow Modifier` node in combination with `Subworkflow Modifier Source` nodes.
|
||||
The modifier node takes an input value, passes it through to its output, and also makes it available as a source for modifier sources. On the outer modifier source, select the corresponding loaded `Subworkflow` node. The reference does not add a graph connection, so the source can expose the pre-modifier value without creating a dependency cycle.
|
||||
|
||||

|
||||
|
||||
## User Value Entry 
|
||||
|
||||
For `Subworkflow Input` nodes with primitive number types, users can now set the value directly in the `Subworkflow` node without a link to separate node.
|
||||
This allows for more compact workflows when you simply want to set a number for the inner workflow.
|
||||
When an node is linked to the input of such a node, the linked node value will take precedence over the set value.
|
||||
The value input can also be disabled; the linked input of the inner input will use as fallback value.
|
||||
|
||||

|
||||
|
||||
----------------
|
||||
|
||||
# Notes
|
||||
|
||||
Some notes and observations about the behavior and development of this extension.
|
||||
|
||||
- Developed and tested with ComfyUI version 0.18.2.
|
||||
- The `Subworkflow` node sets the input and output parameters when loading the inner workflow. This load is executed when the outer workflow is loaded. When linked nodes do not match the expected input types provided by `Subworkflow Output` and `Subworkflow Input` from inner workflow, the links are severed silently.
|
||||
- The `Subworkflow Output` node will not pass through values to it's output when it's used as inner workflow. When the workflow is executed standalone, it will pass through values transparently as expected.
|
||||
- The `Subworkflow Input` will ignore any linked nodes to the input values when used as inner workflow. When the workflow is executed standalone, it will use the linked values transparently as expected.
|
||||
- If the input of a corresponding `Subworkflow Input` on the `Subworkflow` node is not linked, the inner workflow will use the linked node of the `Subworkflow Input` node. This allows for optional inputs on the inner workflow.
|
||||
- The `Subworkflow` node loads the values from the selected inner workflow as is; including seed numbers. When `at execution` is set to `keep loaded`, the seed value will be updated by the inner workflow if it has a randomize-after-processing node linked to the `Subworkflow Input`. This allows for workflows that need to update their own input values, such as a seed that should randomize on every execution but also be exposed for linking to other nodes.
|
||||
- The `Subworkflow` node will keep the loaded file untouched, it will never save any changes to the inner workflow back to the file.
|
||||
- A large portion of the source code has been created using AI assistance. Without this, the project would not have been possible for me at this time. I have done my best to review and test the generated code, but there may be edge cases or bugs that I have missed. Please report any issues you encounter.
|
||||
|
||||
### Subworkflow vs Subgraph
|
||||
|
||||
**Note:** *Subworkflows are fundametally different from subgraphs. Subworkflow offers a way to reuse entire workflows as nodes in other workflows, while subgraphs are a way to reuse a group of nodes within the same workflow.*
|
||||
|
||||
## Features
|
||||
### Use Cases
|
||||
|
||||
Create reusable workflow components with named inputs and outputs, then compose them from other workflows. Inner workflows can be loaded fresh for every execution or kept loaded between executions, which allows ComfyUI-style widget state changes such as randomize-after-processing to persist while reload is disabled. UI and API workflow formats are supported, including native ComfyUI subgraphs in UI-format workflows.
|
||||
- Reuse a prompt, sampler, or decode chain across multiple workflows.
|
||||
- Wrap model-specific pipelines behind a small set of named inputs and outputs.
|
||||
- Build compact higher-level workflows from tested lower-level workflows.
|
||||
- Keep experimental graph sections isolated while exposing only the inputs and outputs that matter.
|
||||
- Share common workflow pieces without copying all nodes into every workflow.
|
||||
|
||||
## Supported Shapes
|
||||
### Supported Shapes
|
||||
|
||||
ComfyUI workflow JSON appears in a few different shapes depending on how it was saved and which nodes are used. This extension currently supports:
|
||||
|
||||
@@ -75,78 +173,24 @@ ComfyUI workflow JSON appears in a few different shapes depending on how it was
|
||||
|
||||
`Subworkflow Input` and `Subworkflow Output` boundary names are read from their `slot_name` widget. For UI workflows this can come from either list-style or dictionary-style `widgets_values`; for API workflows it is read from the node's `inputs.slot_name`.
|
||||
|
||||
## Installation
|
||||
|
||||
Clone this repository into your ComfyUI custom nodes directory:
|
||||
|
||||
```bash
|
||||
cd ComfyUI/custom_nodes
|
||||
git clone https://github.com/eniewold/ComfyUI-Subworkflow.git
|
||||
```
|
||||
|
||||
Restart ComfyUI after installation or after Python changes. Browser-side updates also require a hard refresh.
|
||||
Note that there are no library dependencies outside of the standard Python environment bundled with ComfyUI.
|
||||
|
||||
**FEEDBACK WELCOME**: This is an initial release and work in progress. Expect bugs and breaking changes as I continue development. Please report any issues you encounter, especially those related to the known issues below, and share your use cases and feedback. Check below on how to get debug messages in the log files.
|
||||
|
||||
## Step-by-Step Usage
|
||||
|
||||
1. Adjust an (inner) workflow and add one or more `Subworkflow Input` nodes where external values should enter.
|
||||
2. Add one or more `Subworkflow Output` nodes where values should leave the inner workflow.
|
||||
3. Set meaningful `slot_name` values on each input and output boundary.
|
||||
4. Save the inner workflow (to default folder `ComfyUI/user/default/workflows`).
|
||||
5. In another workflow, add a `Subworkflow` node and select the saved workflow file.
|
||||
6. Connect the generated input and output slots.
|
||||
|
||||
Use `at execution` set to `reload` when the inner workflow file should be read fresh every run. Use `keep loaded` when the inner workflow should keep its in-memory state between runs.
|
||||
|
||||
## Load from URL
|
||||
|
||||
Use the `Subworkflow (from URL)` node to load an inner workflow from a URL instead of a local file. The URL must point to a raw JSON workflow file.
|
||||
Since the SSL certificate handling of ComfyUI's Python environment may not support all HTTPS endpoints, there is an option to ignore SSL errors when loading from URL. Use this option with caution and only for trusted sources.
|
||||
|
||||
## Modifier Nodes 
|
||||
|
||||
If you want to expose an output from the inner workflow, modify it (for example, add loras to a model), and then pass it back into the inner workflow, you can use the `Subworkflow Modifier` node in combination with `Subworkflow Modifier Source` nodes.
|
||||
The modifier node takes an input value, passes it through to its output, and also makes it available as a source for modifier sources. The modifier sources can be used in the inner workflow to access the modified value without creating circular reference problems.
|
||||
|
||||

|
||||
|
||||
## Use Cases
|
||||
|
||||
- Reuse a prompt, sampler, or decode chain across multiple workflows.
|
||||
- Wrap model-specific pipelines behind a small set of named inputs and outputs.
|
||||
- Build compact higher-level workflows from tested lower-level workflows.
|
||||
- Keep experimental graph sections isolated while exposing only the inputs and outputs that matter.
|
||||
- Share common workflow pieces without copying all nodes into every workflow.
|
||||
|
||||
## Notes
|
||||
|
||||
- Developed and tested with ComfyUI version 0.18.2.
|
||||
- The `Subworkflow` node sets the input and output parameters when loading the inner workflow. This load is executed when the outer workflow is loaded. When linked nodes do not match the expected input types provided by `Subworkflow Output` and `Subworkflow Input` from inner workflow, the links are severed silently.
|
||||
- The `Subworkflow Output` node will not pass through values to it's output when it's used as inner workflow. When the workflow is executed standalone, it will pass through values transparently as expected.
|
||||
- The `Subworkflow Input` will ignore any linked nodes to the input values when used as inner workflow. When the workflow is executed standalone, it will use the linked values transparently as expected.
|
||||
- If the input of a corresponding `Subworkflow Input` on the `Subworkflow` node is not linked, the inner workflow will use the linked node of the `Subworkflow Input` node. This allows for optional inputs on the inner workflow.
|
||||
- The `Subworkflow` node loads the values from the selected inner workflow as is; including seed numbers. When `at execution` is set to `keep loaded`, the seed value will be updated by the inner workflow if it has a randomize-after-processing node linked to the `Subworkflow Input`. This allows for workflows that need to update their own input values, such as a seed that should randomize on every execution but also be exposed for linking to other nodes.
|
||||
- The `Subworkflow` node will keep the loaded file untouched, it will never save any changes to the inner workflow back to the file.
|
||||
- A large portion of the source code has been created using AI assistance. Without this, the project would not have been possible for me at this time. I have done my best to review and test the generated code, but there may be edge cases or bugs that I have missed. Please report any issues you encounter.
|
||||
|
||||
## Known Issues
|
||||
### Known Issues
|
||||
|
||||
- [ ] The `Subworkflow` node progress can exceed 100%, sometimes reaching about 200%. Check with the upscale workflow example.
|
||||
- [ ] Used paths with macro elements are not formatted currectly when used in inner workflow? (use Video Combine node with %date:yyyy-MM-dd%/WAN/Video)
|
||||
- [ ] Green progress borders appear on more than one node when executing a inner workflow as part of a larger workflow. These borders should be limited to the currently executing node(s) outer workflow.
|
||||
- [ ] The order if inputs/outputs on the `Subworkflow` node is undetermined (probably based on the order of nodes in the inner workflow JSON). Consider adding an option to control this order.
|
||||
- [ ] When a node `Subworkflow Input` is used inside a subgraph, it will not be detected as a boundary node and will not be exposed on the `Subworkflow` node.
|
||||
|
||||
### Version History
|
||||
|
||||
- 1.1.0 - Added `Subworkflow Modifier`, `Subworkflow Modifier Source` (+ from URL) nodes. Combining these nodes in inner and outer workflows allows for circular links between an inner workflow input and output, without circular reference problems.
|
||||
- 1.0.1 - UI only nodes are now supported in inner workflows, no longer raising and error when loading.
|
||||
- 1.0.0 - Initial release of the four custom nodes and workflow loading and execution behavior.
|
||||
|
||||
### Wish List
|
||||
|
||||
- [ ] For each Subworkflow Input node, add an input field in the Subworkflow node for directly setting its value. This also allows for linking of nodes with same type. Similar as subgraphs. Check if this conflicts with unlinked inputs.
|
||||
- v1.3.0 - `Subworkflow Modifier Source` now selects a loaded file- or URL-backed `Subworkflow` through a native dropdown, shares its parsed workflow data per execution, and prunes unrelated sampler/output branches to prevent duplicate renders. The URL-specific source remains as deprecated compatibility support.
|
||||
- v1.2.3 - (re)loading an inner workflow with links updates no longer disconnects all input links; they are now preserved when possible.
|
||||
- v1.2.2 - Fixed a bug where inner node validation exception were raised due to removal of initial static output slots.
|
||||
- v1.2.1 - Removed the maximum number of inputs and outputs on the `Subworkflow` node (was previously set to 8).
|
||||
- v1.2.0 - Added value entry for primitive `Subworkflow Input` input nodes directly on the `Subworkflow` node. Check README features section for details.
|
||||
- v1.1.0 - Added `Subworkflow Modifier`, `Subworkflow Modifier Source` (+ from URL) nodes. Combining these nodes in inner and outer workflows allows for circular links between an inner workflow input and output, without circular reference problems.
|
||||
- v1.0.1 - UI only nodes are now supported in inner workflows, no longer raising and error when loading.
|
||||
- v1.0.0 - Initial release of the four custom nodes and workflow loading and execution behavior.
|
||||
|
||||
### Debug Logging
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
+395
-16
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Frontend extension for the Subworkflow nodes.
|
||||
*
|
||||
* When the source widget changes, this extension queries the backend for the
|
||||
* inner workflow's Subworkflow Input / Subworkflow Output info and dynamically
|
||||
* updates the node's input and output slots.
|
||||
* When a workflow source changes, this extension queries the backend for its
|
||||
* boundary information and dynamically updates the node's slots. Modifier Source
|
||||
* nodes mirror that information from a selected Subworkflow node on the canvas.
|
||||
*
|
||||
* Input slots are named swf_in_0, swf_in_1, ... (matching the Python backend).
|
||||
* Output slots are named out_0, out_1, ... (matching RETURN_NAMES).
|
||||
@@ -20,6 +20,7 @@ const NODE_CONFIGS = {
|
||||
widgetName: "workflow",
|
||||
refreshWidgetNames: ["workflow"],
|
||||
describe: "workflow",
|
||||
staticWidgetCount: 2, // workflow, reload_each_execution
|
||||
infoPath(value) {
|
||||
return `/subworkflow/info?source=file&workflow=${encodeURIComponent(value)}`;
|
||||
},
|
||||
@@ -29,6 +30,7 @@ const NODE_CONFIGS = {
|
||||
widgetName: "url",
|
||||
refreshWidgetNames: ["url", "verify_ssl"],
|
||||
describe: "workflow URL",
|
||||
staticWidgetCount: 3, // url, verify_ssl, reload_each_execution
|
||||
infoPath(value, node) {
|
||||
const verifySsl = node?.widgets?.find(w => w.name === "verify_ssl")?.value !== false;
|
||||
return `/subworkflow/info?source=url&url=${encodeURIComponent(value)}&verify_ssl=${verifySsl ? "true" : "false"}`;
|
||||
@@ -36,12 +38,11 @@ const NODE_CONFIGS = {
|
||||
},
|
||||
SWF_SubworkflowModifierSource: {
|
||||
kind: "modifier_source",
|
||||
widgetName: "workflow",
|
||||
refreshWidgetNames: ["workflow"],
|
||||
describe: "workflow",
|
||||
infoPath(value) {
|
||||
return `/subworkflow/info?source=file&workflow=${encodeURIComponent(value)}`;
|
||||
},
|
||||
widgetName: "target_subworkflow",
|
||||
refreshWidgetNames: ["target_subworkflow"],
|
||||
describe: "Subworkflow node",
|
||||
staticWidgetCount: 1,
|
||||
targetNodeReference: true,
|
||||
},
|
||||
SWF_SubworkflowModifierSourceFromURL: {
|
||||
kind: "modifier_source",
|
||||
@@ -54,7 +55,6 @@ const NODE_CONFIGS = {
|
||||
},
|
||||
},
|
||||
};
|
||||
const MAX_SLOTS = 8;
|
||||
const LOG = "[SWF]";
|
||||
const DEBUG = window.localStorage?.getItem("swf_debug") === "1";
|
||||
let workflowErrorDialog = null;
|
||||
@@ -68,6 +68,70 @@ const debugWarn = (...args) => {
|
||||
if (DEBUG) console.warn(LOG, ...args);
|
||||
};
|
||||
|
||||
|
||||
const TARGET_SEPARATOR = " | ";
|
||||
const TARGET_NODE_TYPES = new Set(["SWF_Subworkflow", "SWF_SubworkflowFromURL"]);
|
||||
|
||||
function targetIdFromValue(value) {
|
||||
return String(value || "").split(TARGET_SEPARATOR, 1)[0].trim();
|
||||
}
|
||||
|
||||
function targetNodes() {
|
||||
return (app.graph?._nodes || []).filter(node => TARGET_NODE_TYPES.has(node?.comfyClass));
|
||||
}
|
||||
|
||||
function targetSourceValue(node) {
|
||||
const config = NODE_CONFIGS[node?.comfyClass];
|
||||
return node?.widgets?.find(widget => widget.name === config?.widgetName)?.value || "";
|
||||
}
|
||||
|
||||
function targetReferenceLabel(node) {
|
||||
const title = node?.title || node?.type || node?.comfyClass || "Subworkflow";
|
||||
const source = targetSourceValue(node);
|
||||
return [String(node?.id ?? ""), title, source].filter(Boolean).join(TARGET_SEPARATOR);
|
||||
}
|
||||
|
||||
function findTargetNode(value) {
|
||||
const targetId = targetIdFromValue(value);
|
||||
return targetNodes().find(node => String(node.id) === targetId) || null;
|
||||
}
|
||||
|
||||
function normalizeTargetReference(value) {
|
||||
const directTarget = findTargetNode(value);
|
||||
if (directTarget) return targetReferenceLabel(directTarget);
|
||||
|
||||
// Migrate legacy Modifier Source nodes that stored a file name directly.
|
||||
const legacyValue = String(value || "");
|
||||
if (!legacyValue || legacyValue.includes(TARGET_SEPARATOR)) return legacyValue;
|
||||
const matches = targetNodes().filter(node => String(targetSourceValue(node)) === legacyValue);
|
||||
return matches.length === 1 ? targetReferenceLabel(matches[0]) : legacyValue;
|
||||
}
|
||||
|
||||
function configureTargetSelector(node) {
|
||||
const widget = node?.widgets?.find(item => item.name === "target_subworkflow");
|
||||
if (!widget) return null;
|
||||
|
||||
|
||||
widget.label = "Subworkflow node";
|
||||
widget.options = {
|
||||
...(widget.options || {}),
|
||||
values: () => ["", ...targetNodes().map(targetReferenceLabel)],
|
||||
};
|
||||
widget.value = normalizeTargetReference(widget.value);
|
||||
return widget;
|
||||
}
|
||||
|
||||
function refreshReferencedModifierSources(targetNode, info) {
|
||||
if (!targetNode || !info) return;
|
||||
for (const sourceNode of (app.graph?._nodes || [])) {
|
||||
if (sourceNode?.comfyClass !== "SWF_SubworkflowModifierSource") continue;
|
||||
const widget = configureTargetSelector(sourceNode);
|
||||
if (!widget || targetIdFromValue(widget.value) !== String(targetNode.id)) continue;
|
||||
widget.value = targetReferenceLabel(targetNode);
|
||||
applyWorkflowInfo(sourceNode, info, NODE_CONFIGS.SWF_SubworkflowModifierSource);
|
||||
}
|
||||
}
|
||||
|
||||
debugLog("extension module loaded", {
|
||||
configuredNodeTypes: Object.keys(NODE_CONFIGS),
|
||||
});
|
||||
@@ -172,6 +236,39 @@ async function fetchWorkflowInfo(config, value, node) {
|
||||
clearWorkflowError();
|
||||
return null;
|
||||
}
|
||||
if (config.targetNodeReference) {
|
||||
const widget = configureTargetSelector(node);
|
||||
const target = findTargetNode(widget?.value || value);
|
||||
if (!target) {
|
||||
showWorkflowError(
|
||||
config,
|
||||
value,
|
||||
"The selected Subworkflow node is missing. Select a loaded Subworkflow node from the dropdown.",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetConfig = NODE_CONFIGS[target.comfyClass];
|
||||
const sourceValue = targetSourceValue(target);
|
||||
if (!targetConfig || !sourceValue) {
|
||||
showWorkflowError(
|
||||
config,
|
||||
value,
|
||||
"The selected Subworkflow node has no workflow source configured.",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const info = target.__swfWorkflowInfo
|
||||
|| await fetchWorkflowInfo(targetConfig, sourceValue, target);
|
||||
if (info) {
|
||||
clearWorkflowError();
|
||||
target.__swfWorkflowInfo = info;
|
||||
if (widget) widget.value = targetReferenceLabel(target);
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
const path = config.infoPath(value, node);
|
||||
debugLog(`fetchWorkflowInfo: fetching info for ${config.describe}`, {
|
||||
value,
|
||||
@@ -223,7 +320,7 @@ function updateInputSlots(node, inputs) {
|
||||
if (node.inputs[i].name?.startsWith("swf_in_")) node.removeInput(i);
|
||||
}
|
||||
}
|
||||
inputs.slice(0, MAX_SLOTS).forEach((inp, i) => {
|
||||
inputs.forEach((inp, i) => {
|
||||
debugLog(`updateInputSlots: adding swf_in_${i}`, inp);
|
||||
node.addInput(`swf_in_${i}`, swfSlotType(inp), { label: inp.slot_name });
|
||||
});
|
||||
@@ -231,7 +328,7 @@ function updateInputSlots(node, inputs) {
|
||||
}
|
||||
|
||||
function updateOutputSlots(node, outputs) {
|
||||
const needCount = Math.min(outputs.length, MAX_SLOTS);
|
||||
const needCount = outputs.length;
|
||||
debugLog("updateOutputSlots: before", slotSummary(node.outputs));
|
||||
|
||||
for (let i = (node.outputs || []).length - 1; i >= 0; i--) {
|
||||
@@ -270,6 +367,196 @@ function updateModifierSourceOutputs(node, modifiers) {
|
||||
updateOutputSlots(node, modifiers || []);
|
||||
}
|
||||
|
||||
const _OW_MARGIN = 15;
|
||||
const _OW_VAL_W = 80; // value area width
|
||||
// Toggle track width = H * _OW_TOG_RATIO (matches ComfyUI boolean widget style)
|
||||
const _OW_TOG_RATIO = 1.5;
|
||||
|
||||
/**
|
||||
* Build a single custom widget that shows a toggle checkbox and a number input
|
||||
* in one row. The widget's .value is { use: bool, val: number } so both pieces
|
||||
* are serialised together as one entry in widgets_values.
|
||||
*/
|
||||
function _makeOverrideWidget(node, slotIndex, inp, useVal, numVal) {
|
||||
const isInt = inp.type === "INT";
|
||||
const M = _OW_MARGIN;
|
||||
const TOG = _OW_TOG_RATIO;
|
||||
const VAL_W = _OW_VAL_W;
|
||||
|
||||
const widget = {
|
||||
name: `swf_override_${slotIndex}`,
|
||||
type: "swf_override",
|
||||
value: { use: Boolean(useVal), val: numVal },
|
||||
options: {},
|
||||
|
||||
computeSize(width) {
|
||||
return [width, LiteGraph.NODE_WIDGET_HEIGHT ?? 20];
|
||||
},
|
||||
|
||||
draw(ctx, node, width, y, H) {
|
||||
const slot = node.inputs?.find(s => s.name === `swf_in_${slotIndex}`);
|
||||
const linked = slot?.link != null;
|
||||
const useOverride = !linked && Boolean(this.value?.use);
|
||||
const val = this.value?.val ?? 0;
|
||||
const togW = H * TOG; // total toggle slot width
|
||||
|
||||
ctx.save();
|
||||
|
||||
// Background pill — symmetric margins and outline to match ComfyUI widgets
|
||||
ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR;
|
||||
ctx.strokeStyle = LiteGraph.WIDGET_OUTLINE_COLOR;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(M, y, width - 2 * M, H, [H * 0.5]);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
ctx.textBaseline = "middle";
|
||||
|
||||
if (linked) {
|
||||
ctx.fillStyle = LiteGraph.WIDGET_SECONDARY_TEXT_COLOR;
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillText(inp.slot_name, M + 8, y + H * 0.5);
|
||||
ctx.textAlign = "right";
|
||||
ctx.fillText("linked", width - M - 8, y + H * 0.5);
|
||||
} else {
|
||||
// Toggle switch (ComfyUI boolean widget style)
|
||||
const togX = M + 6;
|
||||
const knobR = H * 0.3;
|
||||
const trackX = togX + 4;
|
||||
const trackY = y + 4;
|
||||
const trackW = togW - 8;
|
||||
const trackH = H - 8;
|
||||
|
||||
// Track: semi-transparent white background
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(trackX, trackY, trackW, trackH, [trackH * 0.5]);
|
||||
ctx.globalAlpha = 0.25;
|
||||
ctx.fillStyle = "rgba(255,255,255,0.45)";
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// Knob
|
||||
const knobX = useOverride ? togX + H : togX + H * 0.5;
|
||||
ctx.beginPath();
|
||||
ctx.arc(knobX, y + H * 0.5, knobR, 0, Math.PI * 2);
|
||||
ctx.fillStyle = useOverride ? "#89B" : "#888";
|
||||
ctx.fill();
|
||||
|
||||
// Label (slot name, capped so it doesn't overflow into value area)
|
||||
const valAreaX = width - M - VAL_W - 4;
|
||||
const labelX = M + 6 + togW + 5;
|
||||
ctx.fillStyle = LiteGraph.WIDGET_SECONDARY_TEXT_COLOR;
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillText(inp.slot_name, labelX, y + H * 0.5, valAreaX - labelX - 4);
|
||||
|
||||
// Value area
|
||||
if (useOverride) {
|
||||
ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(valAreaX, y + 2, VAL_W, H - 4, [H * 0.5 - 2]);
|
||||
ctx.fill();
|
||||
|
||||
const displayStr = isInt
|
||||
? String(Math.round(val))
|
||||
: String(parseFloat(val.toPrecision(4)));
|
||||
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
|
||||
ctx.textAlign = "right";
|
||||
ctx.fillText(displayStr, valAreaX + VAL_W - 6, y + H * 0.5, VAL_W - 10);
|
||||
} else {
|
||||
ctx.fillStyle = LiteGraph.WIDGET_SECONDARY_TEXT_COLOR;
|
||||
ctx.textAlign = "right";
|
||||
ctx.fillText("(inner fallback)", width - M - 8, y + H * 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
},
|
||||
|
||||
mouse(event, pos, node) {
|
||||
const [x] = pos;
|
||||
const slot = node.inputs?.find(s => s.name === `swf_in_${slotIndex}`);
|
||||
if (slot?.link != null) return false;
|
||||
|
||||
const H = LiteGraph.NODE_WIDGET_HEIGHT ?? 20;
|
||||
const togX = M + 6;
|
||||
const togEnd = togX + H * TOG + 4;
|
||||
const valAreaX = node.size[0] - M - VAL_W - 4;
|
||||
|
||||
if (event.type === "mousedown" || event.type === "pointerdown") {
|
||||
if (x >= togX && x < togEnd) {
|
||||
// Toggle
|
||||
this.value = { ...this.value, use: !this.value.use };
|
||||
app.graph.setDirtyCanvas(true, false);
|
||||
if (node.onWidgetChanged) node.onWidgetChanged(this.name, this.value, null, this);
|
||||
return true;
|
||||
}
|
||||
if (x >= valAreaX && this.value?.use) {
|
||||
app.canvas.prompt(inp.slot_name, this.value?.val ?? 0, (v) => {
|
||||
let num = parseFloat(v);
|
||||
if (!isNaN(num)) {
|
||||
if (isInt) num = Math.round(num);
|
||||
this.value = { ...this.value, val: num };
|
||||
app.graph.setDirtyCanvas(true, false);
|
||||
if (node.onWidgetChanged) node.onWidgetChanged(this.name, this.value, null, this);
|
||||
}
|
||||
}, event);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
node.widgets ??= [];
|
||||
node.widgets.push(widget);
|
||||
return widget;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronise override widgets for all INT/FLOAT input slots.
|
||||
*
|
||||
* Removes stale swf_override_* widgets, then for each primitive-typed slot adds one
|
||||
* combined custom widget (swf_override_i) and links the swf_in_i input slot to it so
|
||||
* the connection dot appears inline at the widget row instead of in a separate inputs
|
||||
* section above the widgets.
|
||||
*
|
||||
* pendingOverrideValues: flat array of saved widget VALUES — one entry per primitive
|
||||
* slot, each being the serialised { use, val } object. Pass null when no saved state.
|
||||
*/
|
||||
function _syncOverrideWidgets(node, inputs, config, pendingOverrideValues) {
|
||||
if (config?.kind !== "main") return;
|
||||
|
||||
// Remove stale override widgets.
|
||||
if (node.widgets) {
|
||||
for (let i = node.widgets.length - 1; i >= 0; i--) {
|
||||
if (node.widgets[i].name?.startsWith("swf_override_")) {
|
||||
node.widgets.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let pendingOffset = 0;
|
||||
|
||||
inputs.forEach((inp, i) => {
|
||||
if (inp.type !== "INT" && inp.type !== "FLOAT") return;
|
||||
|
||||
const defaultVal = (inp.default != null) ? inp.default : (inp.type === "INT" ? 0 : 0.0);
|
||||
const savedOverride = pendingOverrideValues?.[pendingOffset];
|
||||
pendingOffset += 1;
|
||||
|
||||
const useVal = savedOverride?.use ?? false;
|
||||
const numVal = savedOverride?.val ?? defaultVal;
|
||||
|
||||
const widget = _makeOverrideWidget(node, i, inp, useVal, numVal);
|
||||
|
||||
// Link the input slot to this widget: LiteGraph then draws the connection dot
|
||||
// at the widget's y-position rather than as a separate slot in the inputs list.
|
||||
const slot = node.inputs?.find(s => s.name === `swf_in_${i}`);
|
||||
if (slot) slot.widget = { name: widget.name };
|
||||
});
|
||||
}
|
||||
|
||||
function applyWorkflowInfo(node, info, config) {
|
||||
if (!info) {
|
||||
debugWarn("applyWorkflowInfo: no workflow info to apply", { nodeId: node.id });
|
||||
@@ -288,12 +575,16 @@ function applyWorkflowInfo(node, info, config) {
|
||||
} else {
|
||||
updateInputSlots(node, inputs);
|
||||
updateOutputSlots(node, outputs);
|
||||
_syncOverrideWidgets(node, inputs, config, null);
|
||||
}
|
||||
setNodeSizeAtLeast(node, node.computeSize());
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
if (TARGET_NODE_TYPES.has(node?.comfyClass)) {
|
||||
refreshReferencedModifierSources(node, info);
|
||||
}
|
||||
}
|
||||
|
||||
function applyWorkflowInfoOnLoad(node, info, savedInputCount, savedSize, config) {
|
||||
function applyWorkflowInfoOnLoad(node, info, savedInputCount, savedSize, config, pendingOverrideValues) {
|
||||
if (!info) {
|
||||
debugWarn("applyWorkflowInfoOnLoad: no workflow info to apply", { nodeId: node.id, savedInputCount });
|
||||
return;
|
||||
@@ -333,9 +624,33 @@ function applyWorkflowInfoOnLoad(node, info, savedInputCount, savedSize, config)
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
// Override widgets are not yet present even on the fast path — add them now.
|
||||
_syncOverrideWidgets(node, inputs, config, pendingOverrideValues);
|
||||
} else {
|
||||
debugLog(`applyWorkflowInfoOnLoad: input count changed (${savedInputCount}->${inputs.length}), full refresh`);
|
||||
updateInputSlots(node, inputs);
|
||||
debugLog(`applyWorkflowInfoOnLoad: input count changed (${savedInputCount}->${inputs.length}), smart update`);
|
||||
// Update existing slots in-place to preserve links, then add/remove only what changed.
|
||||
let idx = 0;
|
||||
for (const inp of (node.inputs || [])) {
|
||||
if (inp.name?.startsWith("swf_in_") && idx < inputs.length) {
|
||||
inp.label = inputs[idx].slot_name;
|
||||
inp.type = swfSlotType(inputs[idx]);
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
if (inputs.length > savedInputCount) {
|
||||
for (let i = savedInputCount; i < inputs.length; i++) {
|
||||
node.addInput(`swf_in_${i}`, swfSlotType(inputs[i]), { label: inputs[i].slot_name });
|
||||
}
|
||||
} else {
|
||||
if (node.inputs) {
|
||||
for (let i = node.inputs.length - 1; i >= 0; i--) {
|
||||
const nm = node.inputs[i].name;
|
||||
if (!nm?.startsWith("swf_in_")) continue;
|
||||
if (parseInt(nm.slice(7)) >= inputs.length) node.removeInput(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
_syncOverrideWidgets(node, inputs, config, pendingOverrideValues);
|
||||
}
|
||||
|
||||
if (config?.kind !== "modifier_source") {
|
||||
@@ -349,6 +664,9 @@ function applyWorkflowInfoOnLoad(node, info, savedInputCount, savedSize, config)
|
||||
setNodeSizeAtLeast(node, node.computeSize());
|
||||
}
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
if (TARGET_NODE_TYPES.has(node?.comfyClass)) {
|
||||
refreshReferencedModifierSources(node, info);
|
||||
}
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
@@ -381,7 +699,54 @@ app.registerExtension({
|
||||
const savedSize = cloneSize(serializedNode.size);
|
||||
debugLog(`onConfigure: serialized node has ${savedInputCount} dynamic input(s)`);
|
||||
|
||||
// Stash override widget values that follow the static widgets in widgets_values.
|
||||
// These are restored after the async workflow-info fetch completes.
|
||||
const staticCount = config.staticWidgetCount ?? 0;
|
||||
const pendingOverrideValues = (serializedNode.widgets_values || []).slice(staticCount);
|
||||
debugLog(`onConfigure: stashed ${pendingOverrideValues.length} pending override value(s)`, {
|
||||
staticCount,
|
||||
pendingOverrideValues,
|
||||
});
|
||||
|
||||
// Prepare INT/FLOAT slots so origOnConfigure sees both the link AND slot.widget
|
||||
// together in the serialized data. ComfyUI handles that combination correctly
|
||||
// (same as a freshly-saved new workflow). Without this patch, old workflows
|
||||
// (saved before override widgets existed) arrive with no slot.widget; when we
|
||||
// set it later via _syncOverrideWidgets ComfyUI treats it as a "convert widget
|
||||
// to input" operation and silently drops the existing link.
|
||||
if (config?.kind === "main") {
|
||||
// Remove any stale stubs first.
|
||||
if (this.widgets) {
|
||||
for (let i = this.widgets.length - 1; i >= 0; i--) {
|
||||
if (this.widgets[i].name?.startsWith("swf_override_")) {
|
||||
this.widgets.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
let primIdx = 0;
|
||||
for (const sinp of (serializedNode.inputs || [])) {
|
||||
if (!sinp.name?.startsWith("swf_in_")) continue;
|
||||
if (sinp.type !== "INT" && sinp.type !== "FLOAT") continue;
|
||||
const slotIdx = parseInt(sinp.name.slice(7)); // "swf_in_".length === 7
|
||||
const saved = pendingOverrideValues[primIdx++];
|
||||
// Create the stub widget so the name exists when origOnConfigure validates it.
|
||||
_makeOverrideWidget(this, slotIdx,
|
||||
{ type: sinp.type, slot_name: sinp.label || sinp.name, default: null },
|
||||
saved?.use ?? false,
|
||||
saved?.val ?? 0,
|
||||
);
|
||||
// Patch the serialized input so origOnConfigure sees slot.widget in the JSON.
|
||||
// New workflows already have this; for old ones we add it here so the
|
||||
// link+widget pair is restored atomically rather than link-first then widget.
|
||||
if (!sinp.widget) {
|
||||
sinp.widget = { name: `swf_override_${slotIdx}` };
|
||||
}
|
||||
}
|
||||
debugLog(`onConfigure: prepared ${primIdx} override stub(s) before origOnConfigure`);
|
||||
}
|
||||
|
||||
if (origOnConfigure) origOnConfigure.call(this, serializedNode);
|
||||
if (config.targetNodeReference) configureTargetSelector(this);
|
||||
debugLog("onConfigure: after original handler", {
|
||||
nodeType: nodeData.name,
|
||||
nodeId: this.id,
|
||||
@@ -405,7 +770,7 @@ app.registerExtension({
|
||||
|
||||
if (sourceValue) {
|
||||
fetchWorkflowInfo(config, sourceValue, this).then(info => {
|
||||
applyWorkflowInfoOnLoad(this, info, savedInputCount, savedSize, config);
|
||||
applyWorkflowInfoOnLoad(this, info, savedInputCount, savedSize, config, pendingOverrideValues);
|
||||
});
|
||||
} else {
|
||||
clearWorkflowError();
|
||||
@@ -413,6 +778,19 @@ app.registerExtension({
|
||||
}
|
||||
};
|
||||
|
||||
const origOnConnectionsChange = nodeType.prototype.onConnectionsChange;
|
||||
nodeType.prototype.onConnectionsChange = function (type, slotIndex, connected, linkInfo) {
|
||||
if (origOnConnectionsChange) origOnConnectionsChange.call(this, type, slotIndex, connected, linkInfo);
|
||||
if (config?.kind !== "main") return;
|
||||
if (type !== LiteGraph.INPUT) return;
|
||||
|
||||
const inp = (this.inputs || [])[slotIndex];
|
||||
if (!inp?.name?.startsWith("swf_in_")) return;
|
||||
|
||||
// The custom widget's draw() checks the link state itself — just redraw.
|
||||
app.graph.setDirtyCanvas(true, false);
|
||||
};
|
||||
|
||||
const origOnWidgetChanged = nodeType.prototype.onWidgetChanged;
|
||||
nodeType.prototype.onWidgetChanged = function (name, value, oldValue, widget) {
|
||||
if (origOnWidgetChanged) origOnWidgetChanged.call(this, name, value, oldValue, widget);
|
||||
@@ -439,6 +817,7 @@ app.registerExtension({
|
||||
const origOnAdded = nodeType.prototype.onAdded;
|
||||
nodeType.prototype.onAdded = function () {
|
||||
if (origOnAdded) origOnAdded.call(this);
|
||||
if (config.targetNodeReference) configureTargetSelector(this);
|
||||
const widget = this.widgets?.find(w => w.name === config.widgetName);
|
||||
const val = widget?.value;
|
||||
debugLog(`onAdded: node placed on canvas, ${config.widgetName} =`, val, {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "comfyui-subworkflow"
|
||||
version = "1.1.0"
|
||||
version = "1.3.0"
|
||||
description = "Use ComfyUI workflows as reusable subworkflows via Subworkflow Input, Subworkflow Output, and Subworkflow nodes"
|
||||
license = { file = "LICENSE" }
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import importlib.util
|
||||
import logging
|
||||
import pathlib
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _load_workflow_node_module():
|
||||
package_name = "swf_isolated_test"
|
||||
package = types.ModuleType(package_name)
|
||||
package.__path__ = [str(REPO_ROOT)]
|
||||
sys.modules[package_name] = package
|
||||
|
||||
latest = types.ModuleType("comfy_api.latest")
|
||||
latest.io = SimpleNamespace(
|
||||
ComfyNode=object,
|
||||
NodeOutput=lambda *args, **kwargs: (args, kwargs),
|
||||
Hidden=SimpleNamespace(unique_id="UNIQUE_ID", dynprompt="DYNPROMPT"),
|
||||
)
|
||||
comfy_api = types.ModuleType("comfy_api")
|
||||
comfy_api.latest = latest
|
||||
sys.modules["comfy_api"] = comfy_api
|
||||
sys.modules["comfy_api.latest"] = latest
|
||||
|
||||
debug_utils = types.ModuleType(f"{package_name}.debug_utils")
|
||||
debug_utils.configure_logger = lambda name: logging.getLogger(name)
|
||||
sys.modules[debug_utils.__name__] = debug_utils
|
||||
|
||||
workflow_utils = types.ModuleType(f"{package_name}.workflow_utils")
|
||||
for name in (
|
||||
"list_workflow_files",
|
||||
"load_workflow_file",
|
||||
"load_workflow_url",
|
||||
"build_expansion",
|
||||
"build_modifier_source_expansion",
|
||||
"apply_control_after_generate",
|
||||
"validate_workflow_nodes_installed",
|
||||
"get_workflow_interface",
|
||||
):
|
||||
setattr(workflow_utils, name, lambda *args, **kwargs: None)
|
||||
sys.modules[workflow_utils.__name__] = workflow_utils
|
||||
|
||||
module_name = f"{package_name}.workflow_node"
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
module_name,
|
||||
REPO_ROOT / "workflow_node.py",
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
workflow_node = _load_workflow_node_module()
|
||||
|
||||
|
||||
class FakePrompt:
|
||||
def __init__(self, nodes=None):
|
||||
self.nodes = nodes or {}
|
||||
|
||||
def has_node(self, node_id):
|
||||
return node_id in self.nodes
|
||||
|
||||
def get_node(self, node_id):
|
||||
return self.nodes[node_id]
|
||||
|
||||
|
||||
class ModifierSourceTargetTests(unittest.TestCase):
|
||||
def test_resolves_file_and_url_subworkflow_targets(self):
|
||||
prompt = FakePrompt({
|
||||
"12": {
|
||||
"class_type": "SWF_Subworkflow",
|
||||
"inputs": {
|
||||
"workflow": "inner.json",
|
||||
"reload_each_execution": False,
|
||||
},
|
||||
},
|
||||
"13": {
|
||||
"class_type": "SWF_SubworkflowFromURL",
|
||||
"inputs": {
|
||||
"url": "https://example.test/workflow.json",
|
||||
"verify_ssl": False,
|
||||
"reload_each_execution": True,
|
||||
},
|
||||
},
|
||||
})
|
||||
workflow_node.SubworkflowModifierSource.hidden = SimpleNamespace(
|
||||
dynprompt=prompt
|
||||
)
|
||||
|
||||
file_target = workflow_node.SubworkflowModifierSource._resolve_target(
|
||||
"12 | Local | inner.json"
|
||||
)
|
||||
url_target = workflow_node.SubworkflowModifierSource._resolve_target(
|
||||
"13 | Remote | https://example.test/workflow.json"
|
||||
)
|
||||
|
||||
self.assertIs(file_target[1], workflow_node.Subworkflow)
|
||||
self.assertEqual(file_target[2], {"workflow": "inner.json"})
|
||||
self.assertIs(file_target[3], False)
|
||||
self.assertIs(url_target[1], workflow_node.SubworkflowFromURL)
|
||||
self.assertEqual(url_target[2]["verify_ssl"], False)
|
||||
self.assertIs(url_target[3], True)
|
||||
|
||||
def test_rejects_non_subworkflow_target(self):
|
||||
prompt = FakePrompt({
|
||||
"20": {"class_type": "KSampler", "inputs": {}},
|
||||
})
|
||||
workflow_node.SubworkflowModifierSource.hidden = SimpleNamespace(
|
||||
dynprompt=prompt
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "not a Subworkflow"):
|
||||
workflow_node.SubworkflowModifierSource._resolve_target("20 | Sampler")
|
||||
|
||||
|
||||
class WorkflowDataCacheTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
workflow_node.BaseSubworkflow._loaded_workflows.clear()
|
||||
workflow_node.BaseSubworkflow._loaded_workflow_instances.clear()
|
||||
workflow_node.BaseSubworkflow._prompt_workflows.clear()
|
||||
self.loads = []
|
||||
self.original_loader = workflow_node.Subworkflow.__dict__["_load_source"]
|
||||
loads = self.loads
|
||||
workflow_node.Subworkflow._load_source = classmethod(
|
||||
lambda cls, workflow, **kwargs: (
|
||||
loads.append(workflow) or {"load": len(loads)}
|
||||
)
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
workflow_node.Subworkflow._load_source = self.original_loader
|
||||
|
||||
def test_reload_loads_once_per_prompt(self):
|
||||
prompt = FakePrompt()
|
||||
first = workflow_node.Subworkflow._get_workflow_data(
|
||||
True,
|
||||
instance_id="12",
|
||||
dynprompt=prompt,
|
||||
workflow="inner.json",
|
||||
)
|
||||
second = workflow_node.Subworkflow._get_workflow_data(
|
||||
True,
|
||||
instance_id="12",
|
||||
dynprompt=prompt,
|
||||
workflow="inner.json",
|
||||
)
|
||||
self.assertIs(first, second)
|
||||
self.assertEqual(len(self.loads), 1)
|
||||
|
||||
workflow_node.Subworkflow._get_workflow_data(
|
||||
True,
|
||||
instance_id="12",
|
||||
dynprompt=FakePrompt(),
|
||||
workflow="inner.json",
|
||||
)
|
||||
self.assertEqual(len(self.loads), 2)
|
||||
|
||||
def test_keep_loaded_is_isolated_by_target_node(self):
|
||||
kept = workflow_node.Subworkflow._get_workflow_data(
|
||||
False,
|
||||
instance_id="12",
|
||||
dynprompt=FakePrompt(),
|
||||
workflow="inner.json",
|
||||
)
|
||||
kept_again = workflow_node.Subworkflow._get_workflow_data(
|
||||
False,
|
||||
instance_id="12",
|
||||
dynprompt=FakePrompt(),
|
||||
workflow="inner.json",
|
||||
)
|
||||
self.assertIs(kept, kept_again)
|
||||
self.assertEqual(len(self.loads), 1)
|
||||
|
||||
workflow_node.Subworkflow._get_workflow_data(
|
||||
False,
|
||||
instance_id="99",
|
||||
dynprompt=FakePrompt(),
|
||||
workflow="inner.json",
|
||||
)
|
||||
self.assertEqual(len(self.loads), 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,75 @@
|
||||
import importlib.util
|
||||
import logging
|
||||
import pathlib
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _load_workflow_utils_module():
|
||||
package_name = "swf_utils_isolated_test"
|
||||
package = types.ModuleType(package_name)
|
||||
package.__path__ = [str(REPO_ROOT)]
|
||||
sys.modules[package_name] = package
|
||||
|
||||
graph_utils = types.ModuleType("comfy_execution.graph_utils")
|
||||
graph_utils.GraphBuilder = object
|
||||
comfy_execution = types.ModuleType("comfy_execution")
|
||||
comfy_execution.graph_utils = graph_utils
|
||||
sys.modules["comfy_execution"] = comfy_execution
|
||||
sys.modules["comfy_execution.graph_utils"] = graph_utils
|
||||
|
||||
debug_utils = types.ModuleType(f"{package_name}.debug_utils")
|
||||
debug_utils.configure_logger = lambda name: logging.getLogger(name)
|
||||
sys.modules[debug_utils.__name__] = debug_utils
|
||||
|
||||
module_name = f"{package_name}.workflow_utils"
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
module_name,
|
||||
REPO_ROOT / "workflow_utils.py",
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
workflow_utils = _load_workflow_utils_module()
|
||||
|
||||
|
||||
class FakeNode:
|
||||
def __init__(self, **inputs):
|
||||
self.inputs = inputs
|
||||
|
||||
|
||||
class FakeGraph:
|
||||
def __init__(self, nodes):
|
||||
self.nodes = nodes
|
||||
|
||||
|
||||
class ModifierSourcePruningTests(unittest.TestCase):
|
||||
def test_keeps_only_selected_output_ancestors(self):
|
||||
graph = FakeGraph({
|
||||
"source": FakeNode(value=1),
|
||||
"modifier_value": FakeNode(value=["source", 0]),
|
||||
"sampler": FakeNode(model=["modifier_value", 0]),
|
||||
"save": FakeNode(images=["sampler", 0]),
|
||||
"selected": FakeNode(nested={"value": ["modifier_value", 0]}),
|
||||
})
|
||||
|
||||
workflow_utils._prune_graph_to_output_refs(
|
||||
graph,
|
||||
[["selected", 0]],
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
set(graph.nodes),
|
||||
{"source", "modifier_value", "selected"},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+205
-50
@@ -3,6 +3,7 @@ Subworkflow: loads an inner workflow (UI or API format), infers its I/O from
|
||||
Subworkflow Input / Subworkflow Output nodes, and executes it as a subgraph expansion.
|
||||
"""
|
||||
import logging
|
||||
import weakref
|
||||
|
||||
from comfy_api.latest import io
|
||||
from .debug_utils import configure_logger
|
||||
@@ -15,12 +16,56 @@ from .workflow_utils import (
|
||||
build_modifier_source_expansion,
|
||||
apply_control_after_generate,
|
||||
validate_workflow_nodes_installed,
|
||||
MAX_SLOTS,
|
||||
get_workflow_interface,
|
||||
)
|
||||
|
||||
log = configure_logger("ComfyUI-Subworkflow")
|
||||
|
||||
|
||||
class _UnboundedOutputTypes:
|
||||
"""Returns '*' for any slot index so validation never IndexErrors on dynamic outputs."""
|
||||
def __getitem__(self, idx):
|
||||
return "*"
|
||||
def __len__(self):
|
||||
return 0
|
||||
def __iter__(self):
|
||||
return iter(())
|
||||
|
||||
|
||||
class _UnboundedFalseSeq:
|
||||
"""Infinite False iterator so merge_result_data zips correctly for any output count."""
|
||||
def __getitem__(self, idx):
|
||||
return False
|
||||
def __len__(self):
|
||||
return 0
|
||||
def __iter__(self):
|
||||
while True:
|
||||
yield False
|
||||
|
||||
|
||||
def _apply_primitive_overrides(data: dict, kwargs: dict) -> dict:
|
||||
"""
|
||||
For INT/FLOAT inputs where the outer slot is not connected and the override
|
||||
widget has its switch enabled, substitute the widget value so build_expansion
|
||||
uses it instead of the inner fallback node.
|
||||
|
||||
The frontend sends a single widget named swf_override_i whose value is a dict
|
||||
{"use": bool, "val": number}.
|
||||
"""
|
||||
info = get_workflow_interface(data)
|
||||
effective = dict(kwargs)
|
||||
for i, inp_info in enumerate(info["inputs"]):
|
||||
if inp_info.get("type") not in ("INT", "FLOAT"):
|
||||
continue
|
||||
key = f"swf_in_{i}"
|
||||
if effective.get(key) is not None:
|
||||
continue # connected node takes priority
|
||||
override = effective.get(f"swf_override_{i}")
|
||||
if isinstance(override, dict) and override.get("use"):
|
||||
effective[key] = override.get("val")
|
||||
return effective
|
||||
|
||||
|
||||
class BaseSubworkflow(io.ComfyNode):
|
||||
"""
|
||||
Shared execution behavior for subworkflow nodes that load workflow JSON from
|
||||
@@ -49,6 +94,8 @@ class BaseSubworkflow(io.ComfyNode):
|
||||
return float("NaN")
|
||||
|
||||
_loaded_workflows: dict[str, dict] = {}
|
||||
_loaded_workflow_instances: dict[str, tuple[str, dict]] = {}
|
||||
_prompt_workflows = weakref.WeakKeyDictionary()
|
||||
|
||||
@classmethod
|
||||
def _source_label(cls, **kwargs) -> str:
|
||||
@@ -63,16 +110,72 @@ class BaseSubworkflow(io.ComfyNode):
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def _get_workflow_data(cls, reload_each_execution: bool, **kwargs) -> dict:
|
||||
def _get_workflow_data(
|
||||
cls,
|
||||
reload_each_execution: bool,
|
||||
*,
|
||||
instance_id: str | None = None,
|
||||
dynprompt=None,
|
||||
**kwargs,
|
||||
) -> dict:
|
||||
cache_key = cls._source_cache_key(**kwargs)
|
||||
label = cls._source_label(**kwargs)
|
||||
if reload_each_execution or cache_key not in cls._loaded_workflows:
|
||||
|
||||
prompt_cache = None
|
||||
prompt_cache_key = None
|
||||
if dynprompt is not None and instance_id is not None:
|
||||
try:
|
||||
prompt_cache = cls._prompt_workflows.setdefault(dynprompt, {})
|
||||
prompt_cache_key = (str(instance_id), cache_key)
|
||||
if prompt_cache_key in prompt_cache:
|
||||
log.debug(
|
||||
"[Subworkflow] reusing prompt-scoped inner workflow %r for node %s",
|
||||
label,
|
||||
instance_id,
|
||||
)
|
||||
return prompt_cache[prompt_cache_key]
|
||||
except TypeError:
|
||||
# Some test and mock prompt objects may not support weak references.
|
||||
prompt_cache = None
|
||||
prompt_cache_key = None
|
||||
|
||||
instance_key = str(instance_id) if instance_id is not None else None
|
||||
instance_cached = (
|
||||
cls._loaded_workflow_instances.get(instance_key)
|
||||
if instance_key is not None
|
||||
else None
|
||||
)
|
||||
instance_cache_hit = (
|
||||
instance_cached is not None and instance_cached[0] == cache_key
|
||||
)
|
||||
|
||||
if reload_each_execution or (
|
||||
instance_key is not None and not instance_cache_hit
|
||||
) or (
|
||||
instance_key is None and cache_key not in cls._loaded_workflows
|
||||
):
|
||||
reason = "reload_each_execution enabled" if reload_each_execution else "cache miss"
|
||||
log.info("[Subworkflow] loading inner workflow %r (%s)", label, reason)
|
||||
cls._loaded_workflows[cache_key] = cls._load_source(**kwargs)
|
||||
log.info(
|
||||
"[Subworkflow] loading inner workflow %r for node %s (%s)",
|
||||
label,
|
||||
instance_id or "shared source",
|
||||
reason,
|
||||
)
|
||||
data = cls._load_source(**kwargs)
|
||||
if instance_key is not None:
|
||||
if not reload_each_execution:
|
||||
cls._loaded_workflow_instances[instance_key] = (cache_key, data)
|
||||
else:
|
||||
cls._loaded_workflows[cache_key] = data
|
||||
elif instance_key is not None:
|
||||
data = instance_cached[1]
|
||||
else:
|
||||
log.debug("[Subworkflow] reusing cached inner workflow %r", label)
|
||||
return cls._loaded_workflows[cache_key]
|
||||
data = cls._loaded_workflows[cache_key]
|
||||
|
||||
if prompt_cache is not None and prompt_cache_key is not None:
|
||||
prompt_cache[prompt_cache_key] = data
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def _execute_source(cls, reload_each_execution=True, **kwargs):
|
||||
@@ -83,36 +186,27 @@ class BaseSubworkflow(io.ComfyNode):
|
||||
reload_each_execution,
|
||||
sorted(k for k in kwargs if k.startswith("swf_in_")),
|
||||
)
|
||||
data = cls._get_workflow_data(reload_each_execution, **kwargs)
|
||||
data = cls._get_workflow_data(
|
||||
reload_each_execution,
|
||||
instance_id=cls.hidden.unique_id,
|
||||
dynprompt=cls.hidden.dynprompt,
|
||||
**kwargs,
|
||||
)
|
||||
validate_workflow_nodes_installed(data)
|
||||
|
||||
output_refs, graph = build_expansion(data, kwargs)
|
||||
return cls._finalize_execution(output_refs, graph, data, reload_each_execution, pad_outputs=MAX_SLOTS)
|
||||
effective_inputs = _apply_primitive_overrides(data, kwargs)
|
||||
output_refs, graph = build_expansion(data, effective_inputs)
|
||||
return cls._finalize_execution(output_refs, graph, data, reload_each_execution)
|
||||
|
||||
@classmethod
|
||||
def _finalize_execution(
|
||||
cls,
|
||||
output_refs,
|
||||
graph,
|
||||
data: dict,
|
||||
reload_each_execution: bool,
|
||||
pad_outputs: int | None = None,
|
||||
):
|
||||
def _finalize_execution(cls, output_refs, graph, data: dict, reload_each_execution: bool):
|
||||
if not reload_each_execution:
|
||||
apply_control_after_generate(data)
|
||||
|
||||
if pad_outputs is not None:
|
||||
while len(output_refs) < pad_outputs:
|
||||
output_refs.append(None)
|
||||
|
||||
return io.NodeOutput(*output_refs, expand=graph.finalize())
|
||||
|
||||
|
||||
def _subworkflow_outputs():
|
||||
return [
|
||||
io.AnyType.Output(id=f"out_{i}", display_name=f"out_{i}")
|
||||
for i in range(MAX_SLOTS)
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def _reload_input():
|
||||
@@ -146,6 +240,7 @@ class Subworkflow(BaseSubworkflow):
|
||||
io.Combo.Input("workflow", options=list_workflow_files()),
|
||||
_reload_input(),
|
||||
],
|
||||
hidden=[io.Hidden.unique_id, io.Hidden.dynprompt],
|
||||
outputs=_subworkflow_outputs(),
|
||||
enable_expand=True,
|
||||
accept_all_inputs=True,
|
||||
@@ -206,6 +301,7 @@ class SubworkflowFromURL(BaseSubworkflow):
|
||||
),
|
||||
_reload_input(),
|
||||
],
|
||||
hidden=[io.Hidden.unique_id, io.Hidden.dynprompt],
|
||||
outputs=_subworkflow_outputs(),
|
||||
enable_expand=True,
|
||||
accept_all_inputs=True,
|
||||
@@ -270,42 +366,90 @@ class SubworkflowModifierSource(BaseSubworkflow):
|
||||
display_name="Subworkflow Modifier Source",
|
||||
category="Subworkflow",
|
||||
description=(
|
||||
"Exposes all modifier source slots from a selected inner workflow. "
|
||||
"Use this together with the main Subworkflow node to break outer "
|
||||
"modifier dependency loops."
|
||||
"Exposes modifier source slots from another Subworkflow node in "
|
||||
"this workflow. Use this together with that Subworkflow node to "
|
||||
"break outer modifier dependency loops."
|
||||
),
|
||||
inputs=[
|
||||
io.Combo.Input("workflow", options=list_workflow_files()),
|
||||
io.Combo.Input(
|
||||
"target_subworkflow",
|
||||
display_name="Subworkflow node",
|
||||
options=[""],
|
||||
),
|
||||
],
|
||||
hidden=[io.Hidden.unique_id, io.Hidden.dynprompt],
|
||||
outputs=_subworkflow_outputs(),
|
||||
enable_expand=True,
|
||||
accept_all_inputs=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _source_label(cls, workflow: str, **kwargs) -> str:
|
||||
return workflow
|
||||
def _target_node_id(cls, target_subworkflow: str) -> str:
|
||||
return str(target_subworkflow or "").split(" | ", 1)[0].strip()
|
||||
|
||||
@classmethod
|
||||
def _source_cache_key(cls, workflow: str, **kwargs) -> str:
|
||||
return f"file:{workflow}"
|
||||
def _resolve_target(cls, target_subworkflow: str):
|
||||
target_id = cls._target_node_id(target_subworkflow)
|
||||
if not target_id:
|
||||
raise ValueError("No Subworkflow node selected.")
|
||||
|
||||
dynprompt = cls.hidden.dynprompt
|
||||
if dynprompt is None or not dynprompt.has_node(target_id):
|
||||
raise ValueError(
|
||||
f"Selected Subworkflow node {target_id!r} is not present in the queued workflow."
|
||||
)
|
||||
|
||||
target = dynprompt.get_node(target_id)
|
||||
inputs = target.get("inputs") or {}
|
||||
class_type = target.get("class_type")
|
||||
if class_type == "SWF_Subworkflow":
|
||||
workflow = inputs.get("workflow")
|
||||
if not isinstance(workflow, str) or not workflow:
|
||||
raise ValueError(
|
||||
f"Selected Subworkflow node {target_id!r} has no workflow selected."
|
||||
)
|
||||
source_class = Subworkflow
|
||||
source_kwargs = {"workflow": workflow}
|
||||
elif class_type == "SWF_SubworkflowFromURL":
|
||||
url = inputs.get("url")
|
||||
verify_ssl = inputs.get("verify_ssl", True)
|
||||
if not isinstance(url, str) or not url:
|
||||
raise ValueError(
|
||||
f"Selected Subworkflow node {target_id!r} has no workflow URL."
|
||||
)
|
||||
if not isinstance(verify_ssl, bool):
|
||||
raise ValueError(
|
||||
f"Selected Subworkflow node {target_id!r} has an invalid verify-SSL setting."
|
||||
)
|
||||
source_class = SubworkflowFromURL
|
||||
source_kwargs = {"url": url, "verify_ssl": verify_ssl}
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Selected node {target_id!r} is not a Subworkflow node."
|
||||
)
|
||||
|
||||
reload_each_execution = inputs.get("reload_each_execution", True)
|
||||
if not isinstance(reload_each_execution, bool):
|
||||
raise ValueError(
|
||||
f"Selected Subworkflow node {target_id!r} has an invalid at-execution setting."
|
||||
)
|
||||
return target_id, source_class, source_kwargs, reload_each_execution
|
||||
|
||||
@classmethod
|
||||
def _load_source(cls, workflow: str, **kwargs) -> dict:
|
||||
if not workflow:
|
||||
raise ValueError("No workflow selected. Choose a workflow file from the dropdown.")
|
||||
try:
|
||||
return load_workflow_file(workflow)
|
||||
except FileNotFoundError:
|
||||
raise ValueError(f"Workflow file not found: {workflow!r}") from None
|
||||
|
||||
@classmethod
|
||||
def execute(cls, workflow: str, **kwargs):
|
||||
reload_each_execution = True
|
||||
data = cls._get_workflow_data(reload_each_execution, workflow=workflow, **kwargs)
|
||||
def execute(cls, target_subworkflow: str, **kwargs):
|
||||
target_id, source_class, source_kwargs, reload_each_execution = (
|
||||
cls._resolve_target(target_subworkflow)
|
||||
)
|
||||
data = source_class._get_workflow_data(
|
||||
reload_each_execution,
|
||||
instance_id=target_id,
|
||||
dynprompt=cls.hidden.dynprompt,
|
||||
**source_kwargs,
|
||||
)
|
||||
validate_workflow_nodes_installed(data)
|
||||
output_refs, graph = build_modifier_source_expansion(data, kwargs)
|
||||
return cls._finalize_execution(output_refs, graph, data, reload_each_execution, pad_outputs=MAX_SLOTS)
|
||||
# The selected Subworkflow owns control-after-generate mutations.
|
||||
return io.NodeOutput(*output_refs, expand=graph.finalize())
|
||||
|
||||
|
||||
class SubworkflowModifierSourceFromURL(BaseSubworkflow):
|
||||
@@ -315,9 +459,10 @@ class SubworkflowModifierSourceFromURL(BaseSubworkflow):
|
||||
node_id="SWF_SubworkflowModifierSourceFromURL",
|
||||
display_name="Subworkflow Modifier Source (from URL)",
|
||||
category="Subworkflow",
|
||||
is_deprecated=True,
|
||||
description=(
|
||||
"Exposes all modifier source slots from a workflow loaded from an "
|
||||
"HTTP(S) URL."
|
||||
"Deprecated compatibility node. Use Subworkflow Modifier Source "
|
||||
"and select a Subworkflow (from URL) node instead."
|
||||
),
|
||||
inputs=[
|
||||
io.String.Input("url", multiline=False, default=""),
|
||||
@@ -359,4 +504,14 @@ class SubworkflowModifierSourceFromURL(BaseSubworkflow):
|
||||
)
|
||||
validate_workflow_nodes_installed(data)
|
||||
output_refs, graph = build_modifier_source_expansion(data, kwargs)
|
||||
return cls._finalize_execution(output_refs, graph, data, reload_each_execution, pad_outputs=MAX_SLOTS)
|
||||
return cls._finalize_execution(output_refs, graph, data, reload_each_execution)
|
||||
|
||||
|
||||
_DYNAMIC_RETURN = _UnboundedOutputTypes()
|
||||
for _cls in (Subworkflow, SubworkflowFromURL, SubworkflowModifierSource, SubworkflowModifierSourceFromURL):
|
||||
# Pre-set so GET_SCHEMA() doesn't overwrite with the empty list that outputs=[] produces.
|
||||
# Returning '*' for any slot index lets validation accept connections to any output slot.
|
||||
_cls._RETURN_TYPES = _DYNAMIC_RETURN
|
||||
_cls._RETURN_NAMES = []
|
||||
_cls._OUTPUT_IS_LIST = _UnboundedFalseSeq()
|
||||
_cls._OUTPUT_TOOLTIPS = []
|
||||
|
||||
+115
-8
@@ -23,7 +23,6 @@ _UUID_RE = re.compile(
|
||||
SWF_SUBWORKFLOW_INPUT = "SWF_SubworkflowInput"
|
||||
SWF_SUBWORKFLOW_OUTPUT = "SWF_SubworkflowOutput"
|
||||
SWF_SUBWORKFLOW_MODIFIER = "SWF_SubworkflowModifier"
|
||||
MAX_SLOTS = 8
|
||||
PLACEHOLDER = ""
|
||||
MAX_URL_WORKFLOW_BYTES = 50 * 1024 * 1024
|
||||
URL_WORKFLOW_TIMEOUT = 20
|
||||
@@ -193,11 +192,14 @@ def get_workflow_interface(data: dict) -> dict:
|
||||
|
||||
|
||||
def _copy_slot_info(slot: dict) -> dict:
|
||||
return {
|
||||
result = {
|
||||
"node_id": slot["node_id"],
|
||||
"slot_name": slot["slot_name"],
|
||||
"type": slot.get("type") or "*",
|
||||
}
|
||||
if "default" in slot:
|
||||
result["default"] = slot["default"]
|
||||
return result
|
||||
|
||||
|
||||
def _analyze_workflow_io(
|
||||
@@ -237,21 +239,74 @@ def _analyze_workflow_io(
|
||||
}
|
||||
|
||||
|
||||
def _extract_ui_input_default(
|
||||
node_id: str,
|
||||
nodes_by_id: dict,
|
||||
link_map: dict,
|
||||
value_sources: dict,
|
||||
slot_type: str,
|
||||
):
|
||||
"""
|
||||
Try to extract a literal default value for a SubworkflowInput node from its
|
||||
fallback connection when that connection resolves to a UI widget-value node.
|
||||
Returns None if no literal default can be determined.
|
||||
"""
|
||||
node = nodes_by_id.get(str(node_id))
|
||||
if not node:
|
||||
return None
|
||||
value_link = None
|
||||
for inp in node.get("inputs") or []:
|
||||
if inp.get("name") == "value" and inp.get("link") is not None:
|
||||
value_link = inp["link"]
|
||||
break
|
||||
if value_link is None:
|
||||
return None
|
||||
src = link_map.get(str(value_link))
|
||||
if src is None:
|
||||
return None
|
||||
src_node_id, src_slot = str(src[0]), int(src[1])
|
||||
if src_node_id not in value_sources:
|
||||
return None
|
||||
raw = value_sources[src_node_id].get(src_slot)
|
||||
if raw is None:
|
||||
return None
|
||||
if slot_type == "INT":
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if slot_type == "FLOAT":
|
||||
try:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _discover_workflow_io_ui(data: dict) -> tuple[list[dict], list[dict], list[dict]]:
|
||||
nodes_list = data.get("nodes", [])
|
||||
subgraph_defs = _build_subgraph_defs(data)
|
||||
parsed_links = [_parse_link(lnk) for lnk in data.get("links", [])]
|
||||
link_map = {lid: (src, ss) for lid, src, ss, _, _ in parsed_links}
|
||||
value_sources = _build_widget_value_sources(nodes_list, subgraph_defs)
|
||||
nodes_by_id = {str(node.get("id")): node for node in nodes_list}
|
||||
|
||||
inputs, outputs, modifiers = [], [], []
|
||||
for node in data.get("nodes", []):
|
||||
for node in nodes_list:
|
||||
ntype = _node_class_type(node)
|
||||
nid = str(node.get("id"))
|
||||
if ntype == SWF_SUBWORKFLOW_INPUT:
|
||||
slot_name = _boundary_slot_name(node, nid)
|
||||
slot_type = _boundary_output_type(node)
|
||||
default = _extract_ui_input_default(nid, nodes_by_id, link_map, value_sources, slot_type)
|
||||
log.debug(
|
||||
"[Subworkflow] found UI input boundary node=%s slot=%r type=%s",
|
||||
"[Subworkflow] found UI input boundary node=%s slot=%r type=%s default=%r",
|
||||
nid,
|
||||
slot_name,
|
||||
slot_type,
|
||||
default,
|
||||
)
|
||||
inputs.append({"node_id": nid, "slot_name": slot_name, "type": slot_type})
|
||||
inputs.append({"node_id": nid, "slot_name": slot_name, "type": slot_type, "default": default})
|
||||
elif ntype == SWF_SUBWORKFLOW_MODIFIER:
|
||||
slot_name = _boundary_slot_name(node, nid)
|
||||
slot_type = _boundary_output_type(node) or _boundary_value_input_type(node)
|
||||
@@ -317,8 +372,17 @@ def _discover_workflow_io_api(data: dict) -> tuple[list[dict], list[dict], list[
|
||||
ct = node.get("class_type", "")
|
||||
slot = node.get("inputs", {}).get("slot_name", nid)
|
||||
if ct == SWF_SUBWORKFLOW_INPUT:
|
||||
log.debug("[Subworkflow] found API input boundary node=%s slot=%r type=*", nid, slot)
|
||||
inputs.append({"node_id": nid, "slot_name": slot, "type": "*"})
|
||||
value_input = node.get("inputs", {}).get("value")
|
||||
# Only treat as a literal default when it is not a graph link reference.
|
||||
default = None
|
||||
if not (
|
||||
isinstance(value_input, list)
|
||||
and len(value_input) == 2
|
||||
and isinstance(value_input[0], (str, int))
|
||||
):
|
||||
default = value_input
|
||||
log.debug("[Subworkflow] found API input boundary node=%s slot=%r type=* default=%r", nid, slot, default)
|
||||
inputs.append({"node_id": nid, "slot_name": slot, "type": "*", "default": default})
|
||||
elif ct == SWF_SUBWORKFLOW_MODIFIER:
|
||||
log.debug("[Subworkflow] found API modifier boundary node=%s slot=%r type=*", nid, slot)
|
||||
modifier_info = {"node_id": nid, "slot_name": slot, "type": "*"}
|
||||
@@ -882,7 +946,7 @@ def build_modifier_source_expansion(data: dict, outer_inputs: dict):
|
||||
item for item in info["raw_outputs"]
|
||||
if item["node_id"] in {modifier["output_node_id"] for modifier in info["modifiers"]}
|
||||
]
|
||||
return build_expansion(
|
||||
output_refs, graph = build_expansion(
|
||||
data,
|
||||
outer_inputs,
|
||||
runtime_inputs_info=[],
|
||||
@@ -890,6 +954,49 @@ def build_modifier_source_expansion(data: dict, outer_inputs: dict):
|
||||
boundary_inputs_info=info["raw_inputs"],
|
||||
boundary_outputs_info=info["raw_outputs"],
|
||||
)
|
||||
_prune_graph_to_output_refs(graph, output_refs)
|
||||
return output_refs, graph
|
||||
|
||||
|
||||
def _iter_graph_link_node_ids(value):
|
||||
"""Yield internal graph node IDs referenced by a possibly nested input value."""
|
||||
if _is_graph_link(value):
|
||||
yield value[0]
|
||||
return
|
||||
if isinstance(value, dict):
|
||||
values = value.values()
|
||||
elif isinstance(value, (list, tuple)):
|
||||
values = value
|
||||
else:
|
||||
return
|
||||
for item in values:
|
||||
yield from _iter_graph_link_node_ids(item)
|
||||
|
||||
|
||||
def _prune_graph_to_output_refs(graph: GraphBuilder, output_refs: list):
|
||||
"""Keep only nodes required to produce the selected expansion outputs."""
|
||||
required: set[str] = set()
|
||||
pending = list(_iter_graph_link_node_ids(output_refs))
|
||||
|
||||
while pending:
|
||||
node_id = pending.pop()
|
||||
if node_id in required:
|
||||
continue
|
||||
node = graph.nodes.get(node_id)
|
||||
if node is None:
|
||||
continue
|
||||
required.add(node_id)
|
||||
for value in node.inputs.values():
|
||||
pending.extend(_iter_graph_link_node_ids(value))
|
||||
|
||||
removed = set(graph.nodes) - required
|
||||
for node_id in removed:
|
||||
del graph.nodes[node_id]
|
||||
if removed:
|
||||
log.debug(
|
||||
"[Subworkflow] pruned %d unrelated node(s) from modifier source expansion",
|
||||
len(removed),
|
||||
)
|
||||
|
||||
|
||||
def _is_graph_link(value) -> bool:
|
||||
|
||||
Reference in New Issue
Block a user