Preserve missing-node widget values
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
+9
-16
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user