/** * 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 }); } }; }, });