diff --git a/README.md b/README.md index 3e9dde3..c2f831c 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,9 @@ ComfyUI keeps each missing node as a placeholder that remembers its original type and wiring, so UTFCN can still match and swap it — via a curated rule (by name) or by matching the node's *serialized* signature against your core nodes. Both "Replace…" and the right-click item work on them; the bulk dialog labels -them `⚠ not installed`. (Widget values aren't carried for a node whose -definition you don't have — links are.) +them `⚠ not installed`. When ComfyUI preserved `widgets_values`, UTFCN carries +those values into the same target widget slots; anything it cannot carry is +reported in the preview. ### Popular missing-node signatures diff --git a/tests/utfcn_widget_transfer.test.mjs b/tests/utfcn_widget_transfer.test.mjs new file mode 100644 index 0000000..7a7490e --- /dev/null +++ b/tests/utfcn_widget_transfer.test.mjs @@ -0,0 +1,102 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { applyWidgetTransfers, planWidgetTransfers } from "../web/utfcn_widget_transfer.js"; + +test("missing nodes carry serialized widget values by widget slot", () => { + const source = { + widgets: [], + last_serialization: { + widgets_values: ["first prompt\nsecond prompt", "secondary text"], + }, + }; + const target = { + widgets: [ + { name: "text", value: "" }, + { name: "fallback", value: "" }, + ], + }; + const shape = { + widgets: target.widgets.map((w) => ({ name: w.name, type: w.type })), + widgetNames: target.widgets.map((w) => w.name), + }; + + const result = planWidgetTransfers(source, shape, {}, { allowSerializedIndexFallback: true }); + + assert.deepEqual(result.warns, []); + assert.deepEqual(result.wMap, [ + { fromIndex: 0, toIndex: 0, to: "text", serialized: true }, + { fromIndex: 1, toIndex: 1, to: "fallback", serialized: true }, + ]); + + assert.equal(applyWidgetTransfers(source, target, result.wMap), 2); + assert.equal(target.widgets[0].value, "first prompt\nsecond prompt"); + assert.equal(target.widgets[1].value, "secondary text"); +}); + +test("live named widgets do not fall back to index unless explicitly allowed", () => { + const source = { + widgets: [{ name: "strength", value: 0.4 }], + last_serialization: { widgets_values: [0.4] }, + }; + const shape = { + widgets: [{ name: "text", type: "text" }], + widgetNames: ["text"], + }; + + const result = planWidgetTransfers(source, shape, {}, { allowSerializedIndexFallback: false }); + + assert.deepEqual(result.wMap, []); + assert.deepEqual(result.warns, ["widget \"strength\" value not carried"]); +}); + +test("named widget plans can read serialized value when live value is absent", () => { + const source = { + widgets: [{ name: "text" }], + last_serialization: { widgets_values: ["saved from workflow"] }, + }; + const target = { widgets: [{ name: "text", value: "" }] }; + const shape = { + widgets: [{ name: "text", type: "text" }], + widgetNames: ["text"], + }; + + const result = planWidgetTransfers(source, shape, {}, { allowSerializedIndexFallback: false }); + + assert.deepEqual(result.wMap, [{ from: "text", fromIndex: 0, to: "text", toIndex: 0 }]); + assert.equal(applyWidgetTransfers(source, target, result.wMap), 1); + assert.equal(target.widgets[0].value, "saved from workflow"); +}); + +test("serialized fallback suppresses live-name warnings when it carries the same slot", () => { + const source = { + widgets: [{ name: "custom_text", value: "placeholder text" }], + last_serialization: { widgets_values: ["placeholder text"] }, + }; + const shape = { + widgets: [{ name: "text", type: "text" }], + widgetNames: ["text"], + }; + + const result = planWidgetTransfers(source, shape, {}, { allowSerializedIndexFallback: true }); + + assert.deepEqual(result.warns, []); + assert.deepEqual(result.wMap, [{ fromIndex: 0, toIndex: 0, to: "text", serialized: true }]); +}); + +test("serialized transfers prefer saved workflow values over live placeholder defaults", () => { + const source = { + widgets: [{ name: "custom_text", value: "" }], + last_serialization: { widgets_values: ["preserved text"] }, + }; + const target = { widgets: [{ name: "text", value: "" }] }; + const shape = { + widgets: [{ name: "text", type: "text" }], + widgetNames: ["text"], + }; + + const result = planWidgetTransfers(source, shape, {}, { allowSerializedIndexFallback: true }); + + assert.equal(applyWidgetTransfers(source, target, result.wMap), 1); + assert.equal(target.widgets[0].value, "preserved text"); +}); diff --git a/web/utfcn.js b/web/utfcn.js index 443a272..4ae5f7f 100644 --- a/web/utfcn.js +++ b/web/utfcn.js @@ -1,4 +1,5 @@ import { app } from "../../scripts/app.js"; +import { applyWidgetTransfers, planWidgetTransfers } from "./utfcn_widget_transfer.js"; /* * UTFCN — Use The F***ing Core Nodes (frontend). @@ -20,7 +21,7 @@ import { app } from "../../scripts/app.js"; const EXT = "UTFCN"; let INDEX = null; // { sources, candidates, stats } -const shapeCache = new Map(); // targetType -> { inputs, outputs, widgetNames } | null +const shapeCache = new Map(); // targetType -> { inputs, outputs, widgets, widgetNames } | null /* -------------------------------------------------------------------------- */ /* data */ @@ -96,18 +97,17 @@ function typeOk(a, b) { return A.some((x) => B.includes(x)); } -/** A widget the user converted into an input slot — its value lives on the input, not the widget. */ -const isConvertedWidget = (w) => w?.type === "converted-widget" || w?.type === "hidden"; - /** Inspect a target type's slot/widget layout once (creating a throwaway node) and cache it. */ function targetShape(type) { if (shapeCache.has(type)) return shapeCache.get(type); let node = null; try { node = window.LiteGraph.createNode(type); } catch { /* unregistered */ } + const widgets = (node?.widgets || []).map((w) => ({ name: w.name, type: w.type })); const shape = node && { inputs: (node.inputs || []).map((s) => ({ name: s.name, type: s.type })), outputs: (node.outputs || []).map((s) => ({ name: s.name, type: s.type })), - widgetNames: (node.widgets || []).map((w) => w.name), + widgets, + widgetNames: widgets.map((w) => w.name), }; shapeCache.set(type, shape || null); return shape || null; @@ -146,12 +146,9 @@ function planSwap(node, targetType, rule) { usedOut.add(j); outMap.push({ src: i, dst: j }); }); - (node.widgets || []).forEach((w) => { - if (w.name == null || isConvertedWidget(w)) return; - const want = rule?.widgets?.[w.name] ?? w.name; - if (shape.widgetNames.includes(want)) wMap.push({ from: w.name, to: want }); - else if (w.value !== undefined && w.value !== null && w.value !== "") warns.push(`widget “${w.name}” value not carried`); - }); + const widgetPlan = planWidgetTransfers(node, shape, rule, { allowSerializedIndexFallback: isMissing(node) }); + wMap.push(...widgetPlan.wMap); + warns.push(...widgetPlan.warns); return { ok: problems.length === 0, problems, warns, inMap, outMap, wMap, targetType }; } @@ -170,11 +167,7 @@ function applySwap(node, plan, rule) { if (node.bgcolor) t.bgcolor = node.bgcolor; // widget values first (setting them may lay out extra widgets) - plan.wMap.forEach((m) => { - const sw = (node.widgets || []).find((w) => w.name === m.from); - const tw = (t.widgets || []).find((w) => w.name === m.to); - if (sw && tw && sw.value !== undefined) { tw.value = sw.value; try { tw.callback?.(tw.value); } catch {} } - }); + applyWidgetTransfers(node, t, plan.wMap); // snapshot link records BEFORE we start mutating the graph const inLinks = plan.inMap diff --git a/web/utfcn_widget_transfer.js b/web/utfcn_widget_transfer.js new file mode 100644 index 0000000..8851528 --- /dev/null +++ b/web/utfcn_widget_transfer.js @@ -0,0 +1,121 @@ +/** A widget the user converted into an input slot -- its value lives on the input, not the widget. */ +export const isConvertedWidget = (w) => w?.type === "converted-widget" || w?.type === "hidden"; + +const hasStoredValue = (v) => v !== undefined; +const hasReportableValue = (v) => v !== undefined && v !== null && v !== ""; + +function shapeWidgets(shape) { + if (Array.isArray(shape?.widgets)) return shape.widgets; + return (shape?.widgetNames || []).map((name) => ({ name })); +} + +export function serializedWidgetValues(node) { + const values = node?.last_serialization?.widgets_values; + return Array.isArray(values) ? values : []; +} + +function findTargetWidgetIndex(widgets, name, used) { + return widgets.findIndex((w, i) => !used.has(i) && !isConvertedWidget(w) && w.name === name); +} + +/** + * Plan widget value transfers. + * + * Installed nodes can match by widget name (plus curated widget remaps). Missing + * nodes often have no live widget objects, only ComfyUI's ordered + * last_serialization.widgets_values array, so they may also opt into same-index + * fallback. + */ +export function planWidgetTransfers(node, shape, rule = {}, options = {}) { + const targetWidgets = shapeWidgets(shape); + const liveWidgets = Array.isArray(node?.widgets) ? node.widgets : []; + const serializedValues = serializedWidgetValues(node); + const allowSerializedIndexFallback = !!options.allowSerializedIndexFallback; + + const usedTargets = new Set(); + const mappedSources = new Set(); + const pendingWarns = []; + const pendingWarnSources = new Set(); + const wMap = []; + const warns = []; + + liveWidgets.forEach((w, sourceIndex) => { + if (w?.name == null || isConvertedWidget(w)) return; + const want = rule?.widgets?.[w.name] ?? w.name; + const targetIndex = findTargetWidgetIndex(targetWidgets, want, usedTargets); + if (targetIndex >= 0) { + usedTargets.add(targetIndex); + mappedSources.add(sourceIndex); + wMap.push({ from: w.name, fromIndex: sourceIndex, to: targetWidgets[targetIndex].name, toIndex: targetIndex }); + } else if (hasReportableValue(w.value)) { + pendingWarns.push({ sourceIndex, message: `widget "${w.name}" value not carried` }); + pendingWarnSources.add(sourceIndex); + } + }); + + if (allowSerializedIndexFallback) { + serializedValues.forEach((value, sourceIndex) => { + if (!hasStoredValue(value) || mappedSources.has(sourceIndex)) return; + const target = targetWidgets[sourceIndex]; + if (target && !usedTargets.has(sourceIndex) && !isConvertedWidget(target) && target.name != null) { + usedTargets.add(sourceIndex); + mappedSources.add(sourceIndex); + wMap.push({ fromIndex: sourceIndex, toIndex: sourceIndex, to: target.name, serialized: true }); + } else if (hasReportableValue(value) && !pendingWarnSources.has(sourceIndex)) { + warns.push(`serialized widget #${sourceIndex + 1} value not carried`); + } + }); + } + + pendingWarns.forEach((warn) => { + if (!mappedSources.has(warn.sourceIndex)) warns.push(warn.message); + }); + + return { wMap, warns }; +} + +function widgetByName(widgets, name) { + return widgets.find((w) => w?.name === name); +} + +function sourceWidgetValue(node, transfer) { + const widgets = Array.isArray(node?.widgets) ? node.widgets : []; + const values = serializedWidgetValues(node); + + if (transfer.serialized && Number.isInteger(transfer.fromIndex)) { + if (transfer.fromIndex < values.length && hasStoredValue(values[transfer.fromIndex])) return values[transfer.fromIndex]; + } + if (transfer.from != null) { + const widget = widgetByName(widgets, transfer.from); + if (hasStoredValue(widget?.value)) return widget.value; + } + if (Number.isInteger(transfer.fromIndex)) { + const widget = widgets[transfer.fromIndex]; + if (hasStoredValue(widget?.value)) return widget.value; + if (transfer.fromIndex < values.length && hasStoredValue(values[transfer.fromIndex])) return values[transfer.fromIndex]; + } + return undefined; +} + +function targetWidget(node, transfer) { + const widgets = Array.isArray(node?.widgets) ? node.widgets : []; + if (transfer.to != null) { + const widget = widgetByName(widgets, transfer.to); + if (widget) return widget; + } + if (Number.isInteger(transfer.toIndex)) return widgets[transfer.toIndex]; + return null; +} + +export function applyWidgetTransfers(source, target, transfers) { + let applied = 0; + (transfers || []).forEach((transfer) => { + const value = sourceWidgetValue(source, transfer); + const widget = targetWidget(target, transfer); + if (!widget || !hasStoredValue(value)) return; + widget.value = value; + try { widget.callback?.(widget.value); } catch {} + applied++; + }); + return applied; +}