Files
eniewold-ComfyUI-Subworkflow/js/subworkflow.js
T
2026-04-23 10:44:01 +02:00

405 lines
15 KiB
JavaScript

/**
* 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.
*
* 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).
* Both use display labels from the inner workflow's slot_name values.
*/
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { $el, ComfyDialog } from "../../scripts/ui.js";
const NODE_CONFIGS = {
SWF_Subworkflow: {
widgetName: "workflow",
refreshWidgetNames: ["workflow"],
describe: "workflow",
infoPath(value) {
return `/subworkflow/info?source=file&workflow=${encodeURIComponent(value)}`;
},
},
SWF_SubworkflowFromURL: {
widgetName: "url",
refreshWidgetNames: ["url", "verify_ssl"],
describe: "workflow URL",
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"}`;
},
},
};
const MAX_SLOTS = 8;
const LOG = "[SWF]";
const DEBUG = window.localStorage?.getItem("swf_debug") === "1";
let workflowErrorDialog = null;
let lastWorkflowErrorKey = null;
const debugLog = (...args) => {
if (DEBUG) console.log(LOG, ...args);
};
const debugWarn = (...args) => {
if (DEBUG) console.warn(LOG, ...args);
};
debugLog("extension module loaded", {
configuredNodeTypes: Object.keys(NODE_CONFIGS),
});
function slotSummary(slots) {
return (slots || []).map((slot, index) => ({
index,
name: slot?.name,
label: slot?.label,
type: slot?.type,
links: slot?.links,
link: slot?.link,
}));
}
function swfSlotType(info) {
return info?.type || "*";
}
function cloneSize(size) {
return Array.isArray(size) && size.length >= 2 ? [size[0], size[1]] : null;
}
function setNodeSizeAtLeast(node, minSize) {
const current = cloneSize(node.size);
if (!current || !minSize) return;
node.setSize([
Math.max(current[0], minSize[0]),
Math.max(current[1], minSize[1]),
]);
}
function clearWorkflowError() {
lastWorkflowErrorKey = null;
if (
workflowErrorDialog &&
workflowErrorDialog.element &&
workflowErrorDialog.element.style.display !== "none" &&
typeof workflowErrorDialog.close === "function"
) {
workflowErrorDialog.close();
}
}
function showWorkflowError(config, value, message) {
const text = String(message || `Unable to load ${config.describe}`);
const key = `${config.describe}\n${value || ""}\n${text}`;
if (key === lastWorkflowErrorKey) return;
lastWorkflowErrorKey = key;
if (!workflowErrorDialog) {
workflowErrorDialog = new ComfyDialog();
}
workflowErrorDialog.show(
$el("div", {
style: {
display: "flex",
flexDirection: "column",
gap: "10px",
maxWidth: "720px",
},
}, [
$el("h3", {
textContent: "Subworkflow load failed",
style: {
margin: "0",
color: "#fff",
fontFamily: "sans-serif",
},
}),
$el("div", {
textContent: `Could not load ${config.describe}${value ? `: ${value}` : ""}`,
style: {
color: "#ddd",
fontFamily: "sans-serif",
overflowWrap: "anywhere",
},
}),
$el("pre", {
textContent: text,
style: {
margin: "0",
padding: "10px",
whiteSpace: "pre-wrap",
overflowWrap: "anywhere",
maxHeight: "320px",
overflow: "auto",
color: "#fff",
background: "#221111",
border: "1px solid #6f2b2b",
borderRadius: "4px",
},
}),
])
);
}
async function fetchWorkflowInfo(config, value, node) {
if (!value) {
debugLog(`fetchWorkflowInfo: skipping empty ${config.describe} value`);
clearWorkflowError();
return null;
}
const path = config.infoPath(value, node);
debugLog(`fetchWorkflowInfo: fetching info for ${config.describe}`, {
value,
path,
});
try {
const resp = await api.fetchApi(path);
debugLog(`fetchWorkflowInfo: response status ${resp.status} for "${value}"`, {
url: resp.url,
redirected: resp.redirected,
});
let data = null;
try {
data = await resp.json();
debugLog("fetchWorkflowInfo: response payload", data);
} catch (e) {
debugWarn(`fetchWorkflowInfo: failed to parse JSON response for "${value}"`, e);
}
if (!resp.ok) {
debugWarn(`fetchWorkflowInfo: HTTP ${resp.status} for "${value}"`);
showWorkflowError(config, value, data?.error || `HTTP ${resp.status} while loading ${config.describe}`);
return null;
}
if (!data) {
const message = `Invalid response while loading ${config.describe}: expected JSON`;
debugWarn(`fetchWorkflowInfo: ${message}`);
showWorkflowError(config, value, message);
return null;
}
if (data.error) {
debugWarn(`fetchWorkflowInfo: server error for "${value}":`, data.error);
showWorkflowError(config, value, data.error);
return null;
}
clearWorkflowError();
debugLog(`fetchWorkflowInfo: got ${data.inputs.length} input(s), ${data.outputs.length} output(s) for "${value}"`);
return data;
} catch (e) {
console.error(LOG, "fetchWorkflowInfo: fetch failed:", e);
showWorkflowError(config, value, e);
return null;
}
}
function updateInputSlots(node, inputs) {
debugLog("updateInputSlots: before", slotSummary(node.inputs));
if (node.inputs) {
for (let i = node.inputs.length - 1; i >= 0; i--) {
if (node.inputs[i].name?.startsWith("swf_in_")) node.removeInput(i);
}
}
inputs.slice(0, MAX_SLOTS).forEach((inp, i) => {
debugLog(`updateInputSlots: adding swf_in_${i}`, inp);
node.addInput(`swf_in_${i}`, swfSlotType(inp), { label: inp.slot_name });
});
debugLog("updateInputSlots: after", slotSummary(node.inputs));
}
function updateOutputSlots(node, outputs) {
const needCount = Math.min(outputs.length, MAX_SLOTS);
debugLog("updateOutputSlots: before", slotSummary(node.outputs));
for (let i = (node.outputs || []).length - 1; i >= 0; i--) {
const out = node.outputs[i];
if (!/^out_\d+$/.test(out?.name)) continue;
const idx = parseInt(out.name.slice(4));
if (idx >= needCount) {
debugLog(`updateOutputSlots: removing ${out.name} at index ${i}`);
node.outputs.splice(i, 1);
}
}
let n = 0;
for (const out of (node.outputs || [])) {
if (/^out_\d+$/.test(out.name) && n < needCount) {
debugLog(`updateOutputSlots: updating ${out.name}`, outputs[n]);
out.label = outputs[n].slot_name;
out.type = swfSlotType(outputs[n]);
n++;
}
}
for (let i = n; i < needCount; i++) {
debugLog(`updateOutputSlots: adding out_${i}`, outputs[i]);
node.addOutput(`out_${i}`, swfSlotType(outputs[i]), { label: outputs[i].slot_name });
}
debugLog(`updateOutputSlots: ${needCount} slot(s), after`, slotSummary(node.outputs));
}
function applyWorkflowInfo(node, info) {
if (!info) {
debugWarn("applyWorkflowInfo: no workflow info to apply", { nodeId: node.id });
return;
}
const { inputs = [], outputs = [] } = info;
debugLog(`applyWorkflowInfo: ${inputs.length} input(s), ${outputs.length} output(s)`, {
nodeId: node.id,
currentInputs: slotSummary(node.inputs),
currentOutputs: slotSummary(node.outputs),
});
updateInputSlots(node, inputs);
updateOutputSlots(node, outputs);
setNodeSizeAtLeast(node, node.computeSize());
app.graph.setDirtyCanvas(true, true);
}
function applyWorkflowInfoOnLoad(node, info, savedInputCount, savedSize) {
if (!info) {
debugWarn("applyWorkflowInfoOnLoad: no workflow info to apply", { nodeId: node.id, savedInputCount });
return;
}
const { inputs = [], outputs = [] } = info;
debugLog(`applyWorkflowInfoOnLoad: server=${inputs.length}in/${outputs.length}out, savedInputs=${savedInputCount}`, {
nodeId: node.id,
currentInputs: slotSummary(node.inputs),
currentOutputs: slotSummary(node.outputs),
});
if (savedInputCount === inputs.length) {
debugLog("applyWorkflowInfoOnLoad: input count matches, updating labels only");
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++;
}
}
} else {
debugLog(`applyWorkflowInfoOnLoad: input count changed (${savedInputCount}->${inputs.length}), full refresh`);
updateInputSlots(node, inputs);
}
updateOutputSlots(node, outputs);
if (savedSize) {
debugLog("applyWorkflowInfoOnLoad: restoring saved node size", savedSize);
node.setSize(savedSize);
} else {
setNodeSizeAtLeast(node, node.computeSize());
}
app.graph.setDirtyCanvas(true, true);
}
app.registerExtension({
name: "SWF.Subworkflow",
async beforeRegisterNodeDef(nodeType, nodeData) {
const config = NODE_CONFIGS[nodeData.name];
if (!config) {
if (String(nodeData.name || "").startsWith("SWF_")) {
debugLog("beforeRegisterNodeDef: ignoring unconfigured SWF node", nodeData.name);
}
return;
}
debugLog("beforeRegisterNodeDef: patching", nodeData.name, nodeData);
const origOnConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (serializedNode) {
debugLog("onConfigure: restoring node from saved workflow", {
nodeType: nodeData.name,
nodeId: this.id,
serializedWidgetValues: serializedNode?.widgets_values,
serializedProperties: serializedNode?.properties,
serializedInputCount: serializedNode?.inputs?.length,
serializedOutputCount: serializedNode?.outputs?.length,
});
const savedInputCount = (serializedNode.inputs || [])
.filter(i => i.name?.startsWith("swf_in_")).length;
const savedSize = cloneSize(serializedNode.size);
debugLog(`onConfigure: serialized node has ${savedInputCount} dynamic input(s)`);
if (origOnConfigure) origOnConfigure.call(this, serializedNode);
debugLog("onConfigure: after original handler", {
nodeType: nodeData.name,
nodeId: this.id,
widgets: (this.widgets || []).map(w => ({
name: w.name,
value: w.value,
type: w.type,
hasCallback: !!w.callback,
})),
});
const widget = this.widgets?.find(w => w.name === config.widgetName);
const sourceValue = widget?.value;
debugLog(`onConfigure: ${config.widgetName} widget value =`, sourceValue, {
nodeType: nodeData.name,
nodeId: this.id,
widgets: (this.widgets || []).map(w => ({ name: w.name, value: w.value, type: w.type })),
inputs: slotSummary(this.inputs),
outputs: slotSummary(this.outputs),
});
if (sourceValue) {
fetchWorkflowInfo(config, sourceValue, this).then(info => {
applyWorkflowInfoOnLoad(this, info, savedInputCount, savedSize);
});
} else {
clearWorkflowError();
debugWarn(`onConfigure: ${config.widgetName} widget not found or empty`, { nodeId: this.id });
}
};
const origOnWidgetChanged = nodeType.prototype.onWidgetChanged;
nodeType.prototype.onWidgetChanged = function (name, value, oldValue, widget) {
if (origOnWidgetChanged) origOnWidgetChanged.call(this, name, value, oldValue, widget);
debugLog("onWidgetChanged: observed widget change", {
nodeType: nodeData.name,
nodeId: this.id,
name,
value,
oldValue,
watchedWidget: config.widgetName,
widgetType: widget?.type,
});
if (config.refreshWidgetNames.includes(name)) {
const sourceValue = this.widgets?.find(w => w.name === config.widgetName)?.value;
debugLog(`onWidgetChanged: ${config.widgetName} changed from "${oldValue}" to "${value}"`, {
nodeId: this.id,
inputs: slotSummary(this.inputs),
outputs: slotSummary(this.outputs),
});
fetchWorkflowInfo(config, sourceValue, this).then(info => applyWorkflowInfo(this, info));
}
};
const origOnAdded = nodeType.prototype.onAdded;
nodeType.prototype.onAdded = function () {
if (origOnAdded) origOnAdded.call(this);
const widget = this.widgets?.find(w => w.name === config.widgetName);
const val = widget?.value;
debugLog(`onAdded: node placed on canvas, ${config.widgetName} =`, val, {
nodeType: nodeData.name,
nodeId: this.id,
widgets: (this.widgets || []).map(w => ({ name: w.name, value: w.value, type: w.type })),
inputs: slotSummary(this.inputs),
outputs: slotSummary(this.outputs),
});
if (val) {
fetchWorkflowInfo(config, val, this).then(info => applyWorkflowInfo(this, info));
} else {
clearWorkflowError();
debugWarn(`onAdded: ${config.widgetName} widget not found or empty`, { nodeId: this.id });
}
};
},
});