/** * Frontend extension for the Subworkflow nodes. * * 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). * 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: { kind: "main", widgetName: "workflow", refreshWidgetNames: ["workflow"], describe: "workflow", staticWidgetCount: 2, // workflow, reload_each_execution infoPath(value) { return `/subworkflow/info?source=file&workflow=${encodeURIComponent(value)}`; }, }, SWF_SubworkflowFromURL: { kind: "main", 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"}`; }, }, SWF_SubworkflowModifierSource: { kind: "modifier_source", widgetName: "target_subworkflow", refreshWidgetNames: ["target_subworkflow"], describe: "Subworkflow node", staticWidgetCount: 1, targetNodeReference: true, }, SWF_SubworkflowModifierSourceFromURL: { kind: "modifier_source", 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 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); }; 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), }); 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; } 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, 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.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 = outputs.length; 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 getModifierSourceInputs(info, selectedModifier) { return []; } 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 }); return; } node.__swfWorkflowInfo = info; 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), }); if (config?.kind === "modifier_source") { updateInputSlots(node, []); updateModifierSourceOutputs(node, info.modifiers || []); } 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, pendingOverrideValues) { if (!info) { debugWarn("applyWorkflowInfoOnLoad: no workflow info to apply", { nodeId: node.id, savedInputCount }); return; } node.__swfWorkflowInfo = info; 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 (config?.kind === "modifier_source") { const sourceInputs = []; if (savedInputCount === sourceInputs.length) { debugLog("applyWorkflowInfoOnLoad: modifier source input count matches, updating labels only"); let idx = 0; for (const inp of (node.inputs || [])) { if (inp.name?.startsWith("swf_in_") && idx < sourceInputs.length) { inp.label = sourceInputs[idx].slot_name; inp.type = swfSlotType(sourceInputs[idx]); idx++; } } } else { debugLog(`applyWorkflowInfoOnLoad: modifier source input count changed (${savedInputCount}->${sourceInputs.length}), full refresh`); updateInputSlots(node, sourceInputs); } updateModifierSourceOutputs(node, info.modifiers || []); } else 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++; } } // 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}), 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") { 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); if (TARGET_NODE_TYPES.has(node?.comfyClass)) { refreshReferencedModifierSources(node, info); } } 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)`); // 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, 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, config, pendingOverrideValues); }); } else { clearWorkflowError(); debugWarn(`onConfigure: ${config.widgetName} widget not found or empty`, { nodeId: this.id }); } }; 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); 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, config)); } }; 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, { 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, config)); } else { clearWorkflowError(); debugWarn(`onAdded: ${config.widgetName} widget not found or empty`, { nodeId: this.id }); } }; }, });