Files
ausboss-ComfyUI-AusBoss/js/shared/replace_map.mjs
ausboss af74774f21 Keep saved widget values when replacing missing nodes
Current frontends give a missing-type placeholder display widgets named
UNKNOWN, UNKNOWN_1, ... Replace with AusBoss nodes read those instead of
the saved widgets_values, so every replacement started from defaults.
Registered nodes still read their live widgets by name.
2026-09-23 23:54:58 -05:00

448 lines
16 KiB
JavaScript

// Replace Missing Nodes — the map and its decision logic.
//
// Kept free of /scripts/app.js imports so node:test can cover it
// (tests/replace_map.test.mjs); js/replace_missing/index.js does the DOM
// and graph wiring. Declarations imitate ComfyUI's official Node
// Replacement API (io.NodeReplace): old_node_id / new_node_id,
// old_widget_ids to bind positional widgets_values to names, input_mapping
// as {new_id, old_id} pairs, output_mapping as {new_idx, old_idx} pairs.
// On top of that shape each entry carries a tier:
//
// "swap" — 1:1 replacement; translate() maps old widget values onto the
// AusBoss node and returns human-readable flags for anything
// that could not carry over losslessly.
// "pair" — needs an atomic multi-node rewrite; declared now, applied in
// phase 2. Shown in the preview with a polite refusal.
// "refuse" — no AusBoss equivalent exists; reason is shown to the user.
//
// Mapping semantics: an entry with NO input_mapping carries links by same
// input name; with a mapping, unmapped old inputs are dropped. Same for
// output_mapping by slot index. Dropping is deliberate — a link wired to
// the wrong socket is worse than a link the preview says was dropped.
function toNumber(value) {
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
// ---------------------------------------------------------------- translators
function translateVhsLoadVideo(widgets, context = {}) {
const out = {};
const flags = [];
if (typeof widgets.video === "string" && widgets.video) out.video = widgets.video;
const width = toNumber(widgets.custom_width);
const height = toNumber(widgets.custom_height);
if (width !== null) out.custom_width = clamp(Math.round(width), 0, 16384);
if (height !== null) out.custom_height = clamp(Math.round(height), 0, 16384);
const nth = toNumber(widgets.select_every_nth);
if (nth !== null && nth >= 1) out.every_nth = Math.round(nth);
// Both sides count kept frames, so the cap crosses without needing fps.
const cap = toNumber(widgets.frame_load_cap);
if (cap !== null && cap > 0) out.max_frames = Math.round(cap);
// The trim start is the one frame-index value that needs seconds. Without
// the source fps there is no honest conversion, so the trim stays at the
// default and the preview says to check it — a silently wrong trim would
// look like data loss.
const skip = toNumber(widgets.skip_first_frames);
if (skip !== null && skip > 0) {
const fps = toNumber(context.fps);
if (fps !== null && fps > 0) {
out.start_seconds = Math.round((skip / fps) * 100) / 100;
} else {
flags.push(
`check trim: skip_first_frames=${Math.round(skip)} needs the source fps ` +
"to become seconds — trim left at 0, set start on the preview timeline"
);
}
}
const forceRate = toNumber(widgets.force_rate);
if (forceRate !== null && forceRate > 0) {
flags.push(
`force_rate=${forceRate} dropped — Load Video 🆎 keeps the source rate ` +
"(every_nth thins frames instead)"
);
}
if (typeof widgets.force_size === "string" && widgets.force_size !== "Disabled") {
flags.push(`force_size="${widgets.force_size}" dropped — use custom_width/custom_height`);
}
return { widgets: out, flags };
}
// VHS format ids → Save Video 🆎 format names (keys of VIDEO_FORMATS in
// nodes/_video_save_helpers.py). Anything unlisted falls back to mp4 h264
// with a flag rather than guessing a codec.
const VHS_FORMAT_MAP = {
"video/h264-mp4": "mp4 h264",
"video/h265-mp4": "mp4 h265",
"video/nvenc_h264-mp4": "mp4 h264 nvenc",
"video/nvenc_hevc-mp4": "mp4 h265 nvenc",
"video/webm": "webm vp9",
"video/av1-webm": "webm av1",
"video/ProRes": "mov prores",
"image/gif": "gif",
"image/webp": "webp",
};
function translateVhsVideoCombine(widgets) {
const out = {};
const flags = [];
const fps = toNumber(widgets.frame_rate);
if (fps !== null && fps > 0) out.fps = clamp(fps, 0.01, 240);
if (typeof widgets.filename_prefix === "string" && widgets.filename_prefix) {
out.filename_prefix = widgets.filename_prefix;
}
if (typeof widgets.format === "string" && widgets.format) {
const mapped = VHS_FORMAT_MAP[widgets.format];
if (mapped) {
out.format = mapped;
} else {
out.format = "mp4 h264";
flags.push(`format "${widgets.format}" has no direct match — set to mp4 h264`);
}
}
const crf = toNumber(widgets.crf);
if (crf !== null) out.crf = clamp(Math.round(crf), 0, 51);
if (typeof widgets.pingpong === "boolean") out.pingpong = widgets.pingpong;
if (typeof widgets.save_metadata === "boolean") out.save_metadata = widgets.save_metadata;
const loops = toNumber(widgets.loop_count);
if (loops !== null && loops > 0) {
flags.push(`loop_count=${Math.round(loops)} dropped — gif/webp loop forever, video players decide for themselves`);
}
if (widgets.save_output === false) {
flags.push("save_output was off (temp save) — Save Video 🆎 always saves to the output folder");
}
return { widgets: out, flags };
}
// KJNodes method ids → Color Match 🆎 methods. reinhard is a per-channel
// mean/std transfer, which lab approximates in a perceptual space; the
// combined hm-* pipelines have no single-method equivalent, so lab is the
// safe default — flagged either way.
const COLOR_METHOD_MAP = { mkl: "mkl", hm: "histogram" };
function translateColorMatch(widgets) {
const out = {};
const flags = [];
const method = typeof widgets.method === "string" ? widgets.method : "";
if (COLOR_METHOD_MAP[method]) {
out.method = COLOR_METHOD_MAP[method];
} else {
out.method = "lab";
if (method && method !== "lab") {
flags.push(`method "${method}" approximated with lab (perceptual mean/std transfer)`);
}
}
const strength = toNumber(widgets.strength);
if (strength !== null) {
out.strength = clamp(strength, 0, 1);
if (strength > 1) flags.push(`strength ${strength} clamped to 1.0 — Color Match 🆎 blends, it does not overdrive`);
}
return { widgets: out, flags };
}
function translateGrowMaskWithBlur(widgets) {
const out = {};
const flags = [];
const expand = toNumber(widgets.expand);
if (expand !== null) out.expand = clamp(Math.round(expand), -1024, 1024);
const blur = toNumber(widgets.blur_radius);
if (blur !== null) out.blur = clamp(blur, 0, 100);
if (typeof widgets.fill_holes === "boolean") out.fill_holes = widgets.fill_holes;
if (widgets.flip_input === true) {
flags.push("flip_input not carried — invert upstream, or wire the mask_inverted output");
}
const increment = toNumber(widgets.incremental_expandrate);
if (increment !== null && increment !== 0) {
flags.push(`incremental_expandrate=${increment} dropped — per-frame expand animation is not supported`);
}
const lerp = toNumber(widgets.lerp_alpha);
if (lerp !== null && lerp < 1) {
flags.push(`lerp_alpha=${lerp} dropped — frame-to-frame mask blending is not supported`);
}
const decay = toNumber(widgets.decay_factor);
if (decay !== null && decay < 1) {
flags.push(`decay_factor=${decay} dropped — frame-to-frame mask decay is not supported`);
}
return { widgets: out, flags };
}
function translateLayerStyleLama(widgets) {
// The model lists differ and LayerStyle's grow/blur edge treatment is a
// pre-process this node does not do, so nothing carries; the flag tells
// the user what to rebuild instead of pretending the swap was lossless.
const flags = [
"check mask: LayerStyle's grow/blur edge treatment is not carried — " +
"reproduce it with Mask Refine 🆎 upstream if the result shows seams",
];
if (widgets.invert_mask === true) {
flags.push("invert_mask was on — invert the mask upstream (e.g. Mask Refine 🆎 mask_inverted)");
}
return { widgets: {}, flags };
}
// ---------------------------------------------------------------- the map
export const REPLACEMENTS = [
{
tier: "swap",
old_node_id: "VHS_LoadVideo",
new_node_id: "AUSBOSS_NODES_LoadVideo",
// Recent VHS serializes widgets_values as a name-keyed object; this
// binding only decodes the older positional arrays.
old_widget_ids: [
"video",
"force_rate",
"custom_width",
"custom_height",
"frame_load_cap",
"skip_first_frames",
"select_every_nth",
],
// VHS: 0 IMAGE, 1 frame_count, 2 audio, 3 video_info.
// AusBoss: 0 frames, 1 audio, 2 frame_count, 3 fps, 4 width, 5 height,
// 6 duration, 7 video. video_info has no equivalent and is dropped.
output_mapping: [
{ new_idx: 0, old_idx: 0 },
{ new_idx: 2, old_idx: 1 },
{ new_idx: 1, old_idx: 2 },
],
translate: translateVhsLoadVideo,
},
{
tier: "swap",
old_node_id: "VHS_VideoCombine",
new_node_id: "AUSBOSS_NODES_SaveVideo",
old_widget_ids: [
"frame_rate",
"loop_count",
"filename_prefix",
"format",
"pingpong",
"save_output",
],
input_mapping: [
{ new_id: "frames", old_id: "images" },
{ new_id: "audio", old_id: "audio" },
],
// The VHS_FILENAMES output has no equivalent; Save Video 🆎 is a sink.
output_mapping: [],
translate: translateVhsVideoCombine,
},
{
tier: "swap",
old_node_id: "ColorMatch",
new_node_id: "AUSBOSS_NODES_ColorMatch",
old_widget_ids: ["method", "strength"],
input_mapping: [
{ new_id: "reference", old_id: "image_ref" },
{ new_id: "image", old_id: "image_target" },
],
translate: translateColorMatch,
},
{
tier: "swap",
old_node_id: "ColorMatchV2",
new_node_id: "AUSBOSS_NODES_ColorMatch",
old_widget_ids: ["method", "strength"],
input_mapping: [
{ new_id: "reference", old_id: "image_ref" },
{ new_id: "image", old_id: "image_target" },
],
translate: translateColorMatch,
},
{
tier: "swap",
old_node_id: "GrowMaskWithBlur",
new_node_id: "AUSBOSS_NODES_RefineMask",
old_widget_ids: [
"expand",
"incremental_expandrate",
"tapered_corners",
"flip_input",
"blur_radius",
"lerp_alpha",
"decay_factor",
"fill_holes",
],
// mask → mask, mask_inverted → mask_inverted line up by name and index.
translate: translateGrowMaskWithBlur,
},
{
tier: "swap",
old_node_id: "LayerUtility: LaMa",
new_node_id: "AUSBOSS_NODES_LaMaInpaint",
// No old_widget_ids on purpose: the positional layout is not stable
// across LayerStyle versions, and a mislabeled decode would carry a
// wrong value silently. Undecoded widgets just mean defaults + flags.
translate: translateLayerStyleLama,
},
{
tier: "swap",
old_node_id: "easy imageSize",
new_node_id: "AUSBOSS_NODES_ImageSize",
output_mapping: [
{ new_idx: 0, old_idx: 0 },
{ new_idx: 1, old_idx: 1 },
],
},
{
tier: "swap",
old_node_id: "DF_Get_image_size",
new_node_id: "AUSBOSS_NODES_ImageSize",
output_mapping: [
{ new_idx: 0, old_idx: 0 },
{ new_idx: 1, old_idx: 1 },
],
},
{
tier: "pair",
old_node_id: "GetImageSizeAndCount",
new_node_id: "AUSBOSS_NODES_ImageSize",
reason:
"needs Image Size 🆎 plus rewiring its image passthrough to the upstream " +
"source — atomic multi-node swaps land in phase 2",
},
{
tier: "refuse",
old_node_id: "UnetLoaderGGUF",
reason: "model loader — no AusBoss equivalent",
},
{
tier: "refuse",
old_node_prefix: "WanVideo",
reason: "WanVideoWrapper pipeline node — no AusBoss equivalent",
},
{
tier: "refuse",
old_node_suffix: "Preprocessor",
reason: "controlnet preprocessor — no AusBoss equivalent",
},
];
// ---------------------------------------------------------------- decisions
// Exact ids win over prefix/suffix rules, so a specific swap can always be
// declared for a node a broad refuse rule would otherwise catch.
export function findReplacement(classType) {
const type = String(classType ?? "");
if (!type || type.startsWith("AUSBOSS_NODES_")) return null;
let byPattern = null;
for (const entry of REPLACEMENTS) {
if (entry.old_node_id === type) return entry;
if (!byPattern && entry.old_node_prefix && type.startsWith(entry.old_node_prefix)) {
byPattern = entry;
}
if (!byPattern && entry.old_node_suffix && type.endsWith(entry.old_node_suffix)) {
byPattern = entry;
}
}
return byPattern;
}
// The widget values to translate for one candidate node. A registered node's
// live widgets carry their real names. A missing-type placeholder's widgets
// are the frontend's display stand-ins, named UNKNOWN, UNKNOWN_1, ..., so its
// saved widgets_values (object or positional array) are the real record.
export function candidateWidgetValues(node, registered) {
if (registered && Array.isArray(node?.widgets) && node.widgets.length) {
const values = {};
for (const widget of node.widgets) {
if (widget?.name != null) values[widget.name] = widget.value;
}
return values;
}
return node?.widgets_values ?? null;
}
// widgets_values arrives as a name-keyed object (VHS and other packs with
// custom serialization), a positional array (LiteGraph's default — decoded
// through old_widget_ids), or nothing at all. Always returns an object;
// undecodable input returns {} so translators fall back to defaults.
export function decodeWidgetValues(entry, serialized) {
if (serialized && typeof serialized === "object" && !Array.isArray(serialized)) {
return { ...serialized };
}
if (Array.isArray(serialized) && Array.isArray(entry?.old_widget_ids)) {
const widgets = {};
entry.old_widget_ids.forEach((name, index) => {
if (index < serialized.length) widgets[name] = serialized[index];
});
return widgets;
}
return {};
}
export function translateWidgets(entry, widgets, context = {}) {
if (typeof entry?.translate !== "function") return { widgets: {}, flags: [] };
const result = entry.translate(widgets ?? {}, context) ?? {};
return { widgets: result.widgets ?? {}, flags: result.flags ?? [] };
}
// Old input name → new input name. No mapping declared means same-name
// carry; a declared mapping drops anything it does not list (null).
export function mapInputName(entry, oldName) {
if (!Array.isArray(entry?.input_mapping)) return oldName;
const hit = entry.input_mapping.find((pair) => pair.old_id === oldName);
return hit ? hit.new_id : null;
}
// Old output slot → new output slot, same convention by index.
export function mapOutputSlot(entry, oldSlot) {
if (!Array.isArray(entry?.output_mapping)) return oldSlot;
const hit = entry.output_mapping.find((pair) => pair.old_idx === oldSlot);
return hit ? hit.new_idx : null;
}
// Build the preview rows for a set of candidate nodes. Each input row is
// {id, type, title, registered, widgetValues}; healthy nodes the map does
// not know are skipped, unregistered ones it does not know become tier
// "none" so the user sees why nothing is offered for them.
export function planReplacements(nodes, context = {}) {
const rows = [];
for (const node of nodes ?? []) {
const type = String(node?.type ?? "");
if (!type) continue;
const entry = findReplacement(type);
if (!entry && node?.registered !== false) continue;
const base = {
id: node.id,
type,
title: node.title && node.title !== type ? String(node.title) : null,
entry: entry ?? null,
target: entry?.new_node_id ?? null,
widgets: {},
flags: [],
reason: null,
};
if (!entry) {
rows.push({ ...base, tier: "none", reason: "no AusBoss replacement declared for this type" });
} else if (entry.tier === "refuse") {
rows.push({ ...base, tier: "refuse", target: null, reason: entry.reason });
} else if (entry.tier === "pair") {
rows.push({ ...base, tier: "pair", reason: entry.reason });
} else {
const decoded = decodeWidgetValues(entry, node.widgetValues);
const { widgets, flags } = translateWidgets(entry, decoded, context);
rows.push({ ...base, tier: "swap", widgets, flags });
}
}
return rows;
}