diff --git a/js/shared/widget_kv.mjs b/js/shared/widget_kv.mjs new file mode 100644 index 0000000..be30f79 --- /dev/null +++ b/js/shared/widget_kv.mjs @@ -0,0 +1,173 @@ +// Name-keyed widget serialization: pure mapping and migration logic. +// +// Workflows historically store widgets_values as a positional array, which +// breaks whenever a widget is added or reordered. These helpers build a +// {widgetName: value} object for onSerialize and plan the restore for +// onConfigure, migrating legacy arrays by widget order with per-widget +// validation. The queue/API path is untouched: graphToPrompt reads +// widget.value by name and never consults widgets_values. +// +// Restore timing: the frontend's base LGraphNode.configure() runs first and +// blindly assigns widgets_values[i] to every widget whose `serialize` is not +// false — with a dict that means every such widget briefly holds undefined. +// The plan produced here repairs that: each widget gets its stored value when +// it fits, or its captured creation default when it does not. + +function jsonClone(value) { + if (value === undefined) return null; + if (value === null || typeof value !== "object") return value; + try { + return JSON.parse(JSON.stringify(value)); + } catch (_error) { + return null; + } +} + +// Widgets the frontend's base configure() assigns stored values to. +export function isRestorableWidget(widget) { + return !!widget + && typeof widget.name === "string" + && widget.name.length > 0 + && widget.serialize !== false; +} + +// Widgets worth persisting by name: restorable, not opted out through +// options, and not a button (buttons carry UI labels, not state). +export function isPersistedWidget(widget) { + return isRestorableWidget(widget) + && widget.options?.serialize !== false + && widget.type !== "button"; +} + +// {name: value} snapshot for onSerialize. Object values are JSON-cloned so +// undo snapshots never alias live widget state. +export function widgetsToDict(widgets) { + const out = {}; + for (const widget of widgets ?? []) { + if (!isPersistedWidget(widget)) continue; + out[widget.name] = jsonClone(widget.value); + } + return out; +} + +// Creation-time defaults, captured before configure() can overwrite them. +export function captureWidgetDefaults(widgets) { + const out = {}; + for (const widget of widgets ?? []) { + if (!isRestorableWidget(widget)) continue; + out[widget.name] = jsonClone(widget.value); + } + return out; +} + +function comboValues(widget) { + const values = widget.options?.values; + return Array.isArray(values) ? values : null; +} + +// Does a stored value fit this widget? +// "apply" - value is valid for the widget. +// "keep" - plausible but unverifiable (e.g. a combo string missing from +// a dynamic file list); positional migration leaves it alone, +// a name match applies it. +// "reject" - value cannot belong to this widget. +export function widgetValueVerdict(widget, value) { + if (value === undefined || value === null) return "reject"; + switch (widget?.type) { + case "number": + case "slider": { + if (typeof value !== "number" || !Number.isFinite(value)) return "reject"; + const { min, max } = widget.options ?? {}; + if (typeof min === "number" && value < min) return "reject"; + if (typeof max === "number" && value > max) return "reject"; + return "apply"; + } + case "toggle": + return typeof value === "boolean" ? "apply" : "reject"; + case "combo": { + if (typeof value !== "string" && typeof value !== "number") return "reject"; + const values = comboValues(widget); + if (!values || values.length === 0) return typeof value === "string" ? "apply" : "keep"; + if (values.includes(value)) return "apply"; + // A non-member matching the list's element type stays a "keep": file + // lists are point-in-time snapshots, so an absent name is not proof + // the value is wrong. A type mismatch is. + return values.some((entry) => typeof entry === typeof value) ? "keep" : "reject"; + } + case "text": + case "string": + case "customtext": + return typeof value === "string" ? "apply" : "reject"; + default: + return "keep"; + } +} + +function defaultAssignment(widget, defaults) { + if (!defaults || !(widget.name in defaults)) return null; + return { name: widget.name, value: jsonClone(defaults[widget.name]) }; +} + +function planFromLegacyArray(widgets, stored, defaults) { + const assignments = []; + let index = 0; + for (const widget of widgets) { + if (!isRestorableWidget(widget)) continue; + // Base configure() stops at the array end, leaving later widgets on + // their defaults — mirror that by planning nothing for them. Trailing + // array entries beyond the widget list are ignored the same way. + if (index >= stored.length) break; + const value = stored[index++]; + const verdict = widgetValueVerdict(widget, value); + if (verdict === "apply") { + assignments.push({ name: widget.name, value: jsonClone(value) }); + } else if (verdict === "reject") { + // The blind positional assignment already landed; undo it. + const fallback = defaultAssignment(widget, defaults); + if (fallback) assignments.push(fallback); + } + // "keep": leave whatever the positional pass applied. + } + return { mode: "legacy", assignments }; +} + +function planFromDict(widgets, stored, defaults) { + const assignments = []; + for (const widget of widgets) { + if (!isRestorableWidget(widget)) continue; + if (widget.name in stored) { + const value = stored[widget.name]; + // A name match is strong evidence, so "keep" applies here too. + if (widgetValueVerdict(widget, value) !== "reject") { + assignments.push({ name: widget.name, value: jsonClone(value) }); + continue; + } + } + // Missing or unusable: repair the undefined left by base configure(). + const fallback = defaultAssignment(widget, defaults); + if (fallback) assignments.push(fallback); + } + return { mode: "dict", assignments }; +} + +function planAllDefaults(widgets, defaults) { + const assignments = []; + for (const widget of widgets) { + if (!isRestorableWidget(widget)) continue; + const fallback = defaultAssignment(widget, defaults); + if (fallback) assignments.push(fallback); + } + return { mode: "invalid", assignments }; +} + +// Restore plan for onConfigure. `stored` is info.widgets_values in either +// format; `defaults` comes from captureWidgetDefaults at creation time. +// Never throws: an unreadable payload yields mode "invalid" with a +// defaults-only plan so the caller can warn once and keep loading. +export function planWidgetRestore(widgets, stored, defaults) { + const list = Array.isArray(widgets) ? widgets : []; + if (stored === undefined || stored === null) return { mode: "none", assignments: [] }; + if (Array.isArray(stored)) return planFromLegacyArray(list, stored, defaults); + if (typeof stored === "object") return planFromDict(list, stored, defaults); + return planAllDefaults(list, defaults); +} diff --git a/js/widget_kv/index.js b/js/widget_kv/index.js new file mode 100644 index 0000000..4504675 --- /dev/null +++ b/js/widget_kv/index.js @@ -0,0 +1,70 @@ +// Name-keyed widgets_values for AusBoss nodes. +// +// Positional widgets_values arrays break the moment a widget is added or +// reordered, so these nodes serialize {widgetName: value} objects instead +// and restore by name on configure. Legacy positional arrays — every +// workflow saved before this change — keep loading through the order-based +// migration in shared/widget_kv.mjs. The queue/API path is untouched: +// graphToPrompt reads widget.value directly and never sees widgets_values. +// +// The LoRA loader is deliberately absent: its single JSON widget already +// survives reordering. + +import { app } from "/scripts/app.js"; +import { chainCallback } from "../shared/index.mjs"; +import { + captureWidgetDefaults, + planWidgetRestore, + widgetsToDict, +} from "../shared/widget_kv.mjs"; + +const NODE_TYPES = new Set([ + "AUSBOSS_NODES_ImageCropRotatePad", + "AUSBOSS_NODES_VideoCropRotatePad", + "AUSBOSS_NODES_LoadVideo", + "AUSBOSS_NODES_SaveVideo", +]); + +app.registerExtension({ + name: "ausboss.widget_kv", + beforeRegisterNodeDef(nodeType, nodeData) { + const nodeName = nodeData?.name; + if (!NODE_TYPES.has(nodeName)) return; + + chainCallback(nodeType.prototype, "onNodeCreated", function () { + // Snapshot creation defaults before configure() can overwrite them; + // the restore plan falls back to these for values that do not fit. + this.__ausbossWidgetDefaults ??= captureWidgetDefaults(this.widgets); + }); + + chainCallback(nodeType.prototype, "onSerialize", function (info) { + if (!info || !Array.isArray(this.widgets)) return; + try { + info.widgets_values = widgetsToDict(this.widgets); + } catch (_error) { + // Keep the positional array the base serializer already wrote. + } + }); + + chainCallback(nodeType.prototype, "onConfigure", function (info) { + // Never throw during workflow load: a bad payload degrades to + // defaults with a single warning instead of killing the graph. + try { + const plan = planWidgetRestore( + this.widgets, + info?.widgets_values, + this.__ausbossWidgetDefaults, + ); + if (plan.mode === "invalid") { + console.warn(`[AusBoss] ${nodeName}: stored widget values were unreadable; using defaults.`); + } + for (const { name, value } of plan.assignments) { + const widget = this.widgets?.find((item) => item.name === name); + if (widget) widget.value = value; + } + } catch (error) { + console.warn(`[AusBoss] ${nodeName}: widget restore failed (${error}); keeping current values.`); + } + }); + }, +}); diff --git a/tests/widget_kv.test.mjs b/tests/widget_kv.test.mjs new file mode 100644 index 0000000..f87a9a9 --- /dev/null +++ b/tests/widget_kv.test.mjs @@ -0,0 +1,303 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + captureWidgetDefaults, + isPersistedWidget, + planWidgetRestore, + widgetValueVerdict, + widgetsToDict, +} from "../js/shared/widget_kv.mjs"; + +// --- Fixtures ------------------------------------------------------------- +// Widget order is the workflow-compatibility contract for legacy positional +// arrays. Each fixture mirrors the exact INPUT_TYPES order of the Python +// node file at the time this format shipped — if these tests break because +// a widget moved, the migration mapping below must learn the old order, not +// the tests the new one. + +function widget(name, type, value, options = {}) { + return { name, type, value, options }; +} + +const number = (name, value, min, max) => widget(name, "number", value, { min, max }); +const combo = (name, value, values) => widget(name, "combo", value, values ? { values } : {}); +const text = (name, value) => widget(name, "text", value); + +// nodes/_transform_inputs.py transform_inputs() +const ASPECT_RATIOS = ["free", "source", "1:1", "9:16", "16:9", "2:3", "3:2", "3:4", "4:3", "9:21", "21:9"]; +const transformWidgets = () => [ + number("rotation_degrees", 0.0, -180.0, 180.0), + combo("crop_aspect_ratio", "free", ASPECT_RATIOS), + number("crop_x", 0, 0, 65536), + number("crop_y", 0, 0, 65536), + number("crop_width", 0, 0, 65536), + number("crop_height", 0, 0, 65536), + number("pad_left", 0, 0, 32768), + number("pad_top", 0, 0, 32768), + number("pad_right", 0, 0, 32768), + number("pad_bottom", 0, 0, 32768), + number("feather", 24, 0, 4096), + number("canvas_multiple", 1, 1, 4096), + text("fill_color", "#808080"), +]; + +// nodes/node_load_video.py — video, start_seconds, end_seconds, +// custom_width, custom_height. The video combo is a dynamic file list. +const loadVideoWidgets = (files = []) => [ + combo("video", files[0] ?? "", files), + number("start_seconds", 0.0, 0.0, 86400.0), + number("end_seconds", 0.0, 0.0, 86400.0), + number("custom_width", 0, 0, 16384), + number("custom_height", 0, 0, 16384), +]; + +// nodes/node_save_video.py — fps, filename_prefix, crf (frames and audio +// are connections, prompt/extra_pnginfo hidden). +const saveVideoWidgets = () => [ + number("fps", 16.0, 0.01, 240.0), + text("filename_prefix", "AusBoss/video"), + number("crf", 19, 0, 51), +]; + +// nodes/node_image_crop_rotate_pad.py — image, then the transform block. +const imageCropWidgets = (files = []) => [ + combo("image", files[0] ?? "", files), + ...transformWidgets(), +]; + +// nodes/node_video_crop_rotate_pad.py — video, source_mode, local_path, +// seek_mode, frame_index, frame_time, then the transform block. +const videoCropWidgets = (files = []) => [ + combo("video", files[0] ?? "", files), + combo("source_mode", "input folder", ["input folder", "local path"]), + text("local_path", ""), + combo("seek_mode", "frame index", ["frame index", "time seconds"]), + number("frame_index", 0, 0, 100000000), + number("frame_time", 0.0, 0.0, 86400.0), + ...transformWidgets(), +]; + +const names = (widgets) => widgets.map((item) => item.name); + +// --- Widget order contracts ---------------------------------------------- + +test("fixtures capture the exact INPUT_TYPES widget order of each node", () => { + assert.deepEqual(names(loadVideoWidgets()), [ + "video", "start_seconds", "end_seconds", "custom_width", "custom_height", + ]); + assert.deepEqual(names(saveVideoWidgets()), ["fps", "filename_prefix", "crf"]); + assert.deepEqual(names(imageCropWidgets()), [ + "image", "rotation_degrees", "crop_aspect_ratio", "crop_x", "crop_y", + "crop_width", "crop_height", "pad_left", "pad_top", "pad_right", + "pad_bottom", "feather", "canvas_multiple", "fill_color", + ]); + assert.deepEqual(names(videoCropWidgets()), [ + "video", "source_mode", "local_path", "seek_mode", "frame_index", + "frame_time", "rotation_degrees", "crop_aspect_ratio", "crop_x", "crop_y", + "crop_width", "crop_height", "pad_left", "pad_top", "pad_right", + "pad_bottom", "feather", "canvas_multiple", "fill_color", + ]); +}); + +// --- Dict serialization --------------------------------------------------- + +test("widgets serialize to a name-keyed object and round-trip by name", () => { + const widgets = saveVideoWidgets(); + widgets[0].value = 24.0; + widgets[1].value = "AusBoss/render"; + widgets[2].value = 15; + const dict = widgetsToDict(widgets); + assert.deepEqual(dict, { fps: 24.0, filename_prefix: "AusBoss/render", crf: 15 }); + + const restored = saveVideoWidgets(); + const plan = planWidgetRestore(restored, dict, captureWidgetDefaults(restored)); + assert.equal(plan.mode, "dict"); + assert.deepEqual(plan.assignments, [ + { name: "fps", value: 24.0 }, + { name: "filename_prefix", value: "AusBoss/render" }, + { name: "crf", value: 15 }, + ]); +}); + +test("dict restore survives widget reordering", () => { + const widgets = loadVideoWidgets(["clip.mp4"]); + widgets[1].value = 3.86; + widgets[2].value = 10.11; + const dict = widgetsToDict(widgets); + const reordered = loadVideoWidgets(["clip.mp4"]).reverse(); + const plan = planWidgetRestore(reordered, dict, captureWidgetDefaults(reordered)); + const byName = Object.fromEntries(plan.assignments.map((item) => [item.name, item.value])); + assert.equal(byName.start_seconds, 3.86); + assert.equal(byName.end_seconds, 10.11); + assert.equal(byName.video, "clip.mp4"); +}); + +test("buttons, opt-outs, and unnamed widgets never enter the dict", () => { + const widgets = [ + ...saveVideoWidgets(), + widget("upload", "button", "image"), + { ...widget("ausboss_viewer", "custom", ""), options: { serialize: false } }, + { ...widget("hidden_flag", "toggle", true), serialize: false }, + widget("", "text", "nameless"), + ]; + assert.deepEqual(Object.keys(widgetsToDict(widgets)), ["fps", "filename_prefix", "crf"]); + assert.equal(isPersistedWidget(widgets[3]), false); + assert.equal(isPersistedWidget(widgets[4]), false); +}); + +test("a dict missing a widget repairs it to the captured default", () => { + // Base configure() leaves such widgets holding undefined; the plan must + // put the creation default back. + const widgets = saveVideoWidgets(); + const defaults = captureWidgetDefaults(widgets); + const plan = planWidgetRestore(widgets, { fps: 30.0 }, defaults); + assert.deepEqual(plan.assignments, [ + { name: "fps", value: 30.0 }, + { name: "filename_prefix", value: "AusBoss/video" }, + { name: "crf", value: 19 }, + ]); +}); + +test("a dict value that cannot fit its widget falls back to the default", () => { + const widgets = saveVideoWidgets(); + const defaults = captureWidgetDefaults(widgets); + const plan = planWidgetRestore( + widgets, + { fps: "fast", filename_prefix: "kept", crf: 999 }, + defaults, + ); + assert.deepEqual(plan.assignments, [ + { name: "fps", value: 16.0 }, + { name: "filename_prefix", value: "kept" }, + { name: "crf", value: 19 }, + ]); +}); + +test("a combo string absent from a dynamic file list still restores by name", () => { + // File lists are point-in-time snapshots; a name match outranks them. + const widgets = loadVideoWidgets(["other.mp4"]); + const plan = planWidgetRestore(widgets, { video: "missing.mp4" }, captureWidgetDefaults(widgets)); + assert.deepEqual(plan.assignments[0], { name: "video", value: "missing.mp4" }); +}); + +// --- Legacy positional arrays -------------------------------------------- + +test("the shipped Load Video example array migrates by widget order", () => { + // example_workflows/simple_video_watermark_remover.json — the trailing + // "image" and "" belong to frontend-added upload/preview widgets and must + // be ignored, not force-fed into anything. + const widgets = loadVideoWidgets(["input.mp4"]); + const plan = planWidgetRestore( + widgets, + ["input.mp4", 0.0, 0.0, 0, 0, "image", ""], + captureWidgetDefaults(widgets), + ); + assert.equal(plan.mode, "legacy"); + assert.deepEqual(plan.assignments, [ + { name: "video", value: "input.mp4" }, + { name: "start_seconds", value: 0.0 }, + { name: "end_seconds", value: 0.0 }, + { name: "custom_width", value: 0 }, + { name: "custom_height", value: 0 }, + ]); +}); + +test("the shipped Video Crop example array migrates every widget", () => { + // example_workflows/video_crop_rotate_pad.json + const stored = ["", "input folder", "", "frame index", 0, 0, 0, "free", 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, "#808080"]; + const widgets = videoCropWidgets([""]); + const plan = planWidgetRestore(widgets, stored, captureWidgetDefaults(widgets)); + assert.equal(plan.mode, "legacy"); + assert.equal(plan.assignments.length, widgets.length); + assert.deepEqual( + plan.assignments.map((item) => item.value), + stored, + ); +}); + +test("legacy values that do not fit are skipped back to defaults", () => { + const widgets = saveVideoWidgets(); + widgets[0].value = 999; // base configure already applied the bad array + const plan = planWidgetRestore( + widgets, + ["not-a-rate", "AusBoss/render", 500], + captureWidgetDefaults(saveVideoWidgets()), + ); + assert.deepEqual(plan.assignments, [ + { name: "fps", value: 16.0 }, // string in a number slot + { name: "filename_prefix", value: "AusBoss/render" }, + { name: "crf", value: 19 }, // 500 is outside 0..51 + ]); +}); + +test("a legacy combo string missing from the file list is left in place", () => { + // Today's positional load keeps a stale filename visible (shown invalid); + // the migration must not silently erase it. + const widgets = loadVideoWidgets(["other.mp4"]); + const plan = planWidgetRestore( + widgets, + ["missing.mp4", 1.0, 2.0, 0, 0], + captureWidgetDefaults(widgets), + ); + assert.equal(plan.assignments.some((item) => item.name === "video"), false); + assert.equal(plan.assignments.length, 4); +}); + +test("a short legacy array leaves later widgets on their defaults", () => { + const widgets = imageCropWidgets(["photo.png"]); + const plan = planWidgetRestore(widgets, ["photo.png", 45.0], captureWidgetDefaults(widgets)); + assert.deepEqual(plan.assignments, [ + { name: "image", value: "photo.png" }, + { name: "rotation_degrees", value: 45.0 }, + ]); +}); + +test("legacy nulls left by unserialized widget slots repair to defaults", () => { + const widgets = saveVideoWidgets(); + const plan = planWidgetRestore(widgets, [24.0, null, 15], captureWidgetDefaults(widgets)); + assert.deepEqual(plan.assignments, [ + { name: "fps", value: 24.0 }, + { name: "filename_prefix", value: "AusBoss/video" }, + { name: "crf", value: 15 }, + ]); +}); + +// --- Verdicts and failure paths ------------------------------------------ + +test("verdicts enforce number ranges, combo membership, and booleans", () => { + assert.equal(widgetValueVerdict(number("crf", 19, 0, 51), 19), "apply"); + assert.equal(widgetValueVerdict(number("crf", 19, 0, 51), -1), "reject"); + assert.equal(widgetValueVerdict(number("crf", 19, 0, 51), Number.NaN), "reject"); + assert.equal(widgetValueVerdict(combo("mode", "a", ["a", "b"]), "b"), "apply"); + assert.equal(widgetValueVerdict(combo("mode", "a", ["a", "b"]), "c"), "keep"); + assert.equal(widgetValueVerdict(combo("mode", "a", ["a", "b"]), 3), "reject"); + assert.equal(widgetValueVerdict(widget("flag", "toggle", true), false), "apply"); + assert.equal(widgetValueVerdict(widget("flag", "toggle", true), "true"), "reject"); + assert.equal(widgetValueVerdict(text("label", ""), "hello"), "apply"); + assert.equal(widgetValueVerdict(text("label", ""), 5), "reject"); +}); + +test("an unreadable payload degrades to a defaults-only plan, never a throw", () => { + const widgets = saveVideoWidgets(); + const defaults = captureWidgetDefaults(widgets); + for (const garbage of ["oops", 42, true]) { + const plan = planWidgetRestore(widgets, garbage, defaults); + assert.equal(plan.mode, "invalid"); + assert.deepEqual(plan.assignments, [ + { name: "fps", value: 16.0 }, + { name: "filename_prefix", value: "AusBoss/video" }, + { name: "crf", value: 19 }, + ]); + } +}); + +test("absent widgets_values plans nothing", () => { + const widgets = saveVideoWidgets(); + for (const stored of [undefined, null]) { + assert.deepEqual(planWidgetRestore(widgets, stored, captureWidgetDefaults(widgets)), { + mode: "none", + assignments: [], + }); + } +});