Files
ausboss 216c885b75 Show every pack message through one toast helper
Four extensions each carried their own copy of the toast-or-console
fallback, transform upload failures used a blocking alert(), and a
failed video drop onto Load Video only logged to the console. They all
go through showToast now, so every message looks and behaves the same.
2026-09-24 00:13:30 -05:00

344 lines
13 KiB
JavaScript

// Replace Missing Nodes 🆎 — swap known third-party nodes for AusBoss ones.
//
// Detection finds nodes whose type is unregistered (missing-node
// placeholders after a workflow load) or registered third-party types the
// replace map knows. The command shows a preview dialog — what would
// change, per-node tier and flags, per-node opt-out — and only a confirmed
// apply rewrites the graph, recreate-style: snapshot, build the AusBoss
// node, carry translated widgets, remap links, and roll back on failure.
// This is purely a graph rewrite; no third-party class name is ever
// registered on the backend.
import { app } from "/scripts/app.js";
import { linkSlots, restoreOutputLinks, snapshotLinks } from "../shared/graph_links.mjs";
import { BRAND, notifyAusbossChange, showToast } from "../shared/index.mjs";
import {
candidateWidgetValues,
findReplacement,
mapInputName,
mapOutputSlot,
planReplacements,
} from "../shared/replace_map.mjs";
const COMMAND_LABEL = "Replace with AusBoss nodes 🆎";
function notify(detail, severity = "info") {
showToast({ detail, severity });
}
function isRegistered(type) {
return Boolean(globalThis.LiteGraph?.registered_node_types?.[type]);
}
function collectCandidates(graph) {
const candidates = [];
for (const node of graph?._nodes ?? []) {
const type = String(node?.comfyClass || node?.type || "");
if (!type) continue;
const registered = isRegistered(type);
if (registered && !findReplacement(type)) continue;
candidates.push({
id: node.id,
type,
title: node.title,
registered,
widgetValues: candidateWidgetValues(node, registered),
node,
});
}
return candidates;
}
function displayNameFor(type) {
return globalThis.LiteGraph?.registered_node_types?.[type]?.title ?? type;
}
// ------------------------------------------------------------------- apply
function snapshotNode(node, graph) {
return {
...snapshotLinks(node, graph),
pos: [...node.pos],
size: [...node.size],
title: node.title,
color: node.color,
bgcolor: node.bgcolor,
flags: { ...(node.flags || {}) },
mode: node.mode,
};
}
// Swap one node for its AusBoss replacement. Returns the names of links
// that could not carry across; throws (after rolling back) on hard failure.
function swapNode(row, graph) {
const node = row.node;
const entry = row.entry;
let fresh = null;
let shot = null;
const dropped = [];
try {
shot = snapshotNode(node, graph);
fresh = globalThis.LiteGraph.createNode(row.target);
if (!fresh) throw new Error("no definition for " + row.target);
graph.add(fresh);
fresh.pos = [...shot.pos];
// A custom title carries; the old node's default title must not — the
// replacement keeps its own AusBoss display name. Placeholders title
// themselves with the missing type, which counts as a default too.
if (shot.title && shot.title !== row.type && shot.title !== node.constructor?.title) {
fresh.title = shot.title;
}
// Assign undefined rather than delete so the properties stay reactive
// under the Nodes 2.0 renderer.
fresh.color = shot.color ?? undefined;
fresh.bgcolor = shot.bgcolor ?? undefined;
fresh.flags = { ...(fresh.flags || {}), ...shot.flags };
if (typeof shot.mode === "number") fresh.mode = shot.mode;
for (const widget of fresh.widgets || []) {
if (widget.name in row.widgets) widget.value = row.widgets[widget.name];
}
for (const input of shot.inputs) {
const newName = mapInputName(entry, input.name);
const slot = newName != null ? fresh.findInputSlot?.(newName) : -1;
const origin = graph.getNodeById(input.originId);
// linkSlots runs LiteGraph's own type validation, so an incompatible
// pair fails into the dropped list instead of mis-wiring the graph.
const link =
origin && slot != null && slot >= 0 ? linkSlots(origin, input.originSlot, fresh, slot) : null;
if (!link) dropped.push(`input ${input.name}`);
}
for (const output of shot.outputs) {
const newSlot = mapOutputSlot(entry, output.slot);
const valid = newSlot != null && newSlot >= 0 && (fresh.outputs || [])[newSlot];
for (const target of output.targets) {
const targetNode = graph.getNodeById(target.nodeId);
const link = valid && targetNode ? linkSlots(fresh, newSlot, targetNode, target.slot) : null;
if (!link) dropped.push(`output ${output.name}`);
}
}
// Size last, once widgets exist, and never below the definition minimum.
const minimum = fresh.computeSize?.() || [0, 0];
const size = [Math.max(shot.size[0], minimum[0]), Math.max(shot.size[1], minimum[1])];
if (fresh.setSize) fresh.setSize(size);
else fresh.size = size;
graph.remove(node);
return dropped;
} catch (error) {
try {
if (fresh) graph.remove(fresh);
if (shot) restoreOutputLinks(node, shot.outputs, graph);
} catch (rollbackError) {
// Nothing more can be done safely; the caller still reports.
}
throw error;
}
}
function applyPlan(rows) {
const graph = app.graph;
const replaced = [];
const failed = [];
let droppedCount = 0;
// One gesture, one undo step: batch every swap between a single
// beforeChange/afterChange pair, then let the tracker snapshot once.
graph.beforeChange?.();
try {
for (const row of rows) {
try {
const dropped = swapNode(row, graph);
replaced.push(row.type);
droppedCount += dropped.length;
if (dropped.length) {
console.warn(
`[AusBoss] Replace: ${row.type} #${row.id} carried over without ` +
`${dropped.join(", ")} — reconnect those by hand.`
);
}
} catch (error) {
failed.push(row.type);
console.warn(
`[AusBoss] Replace rolled back ${row.type} #${row.id}; the original ` +
`node was kept. ${error?.message || error}`
);
}
}
} finally {
graph.afterChange?.();
app.canvas?.setDirty?.(true, true);
notifyAusbossChange();
}
let detail = `Replaced ${replaced.length} node${replaced.length === 1 ? "" : "s"}.`;
if (droppedCount) detail += ` ${droppedCount} link${droppedCount === 1 ? "" : "s"} dropped (see console).`;
if (failed.length) detail += ` ${failed.length} rolled back (see console).`;
notify(detail, failed.length ? "warn" : "success");
}
// ------------------------------------------------------------------ dialog
const TIER_BADGE = {
swap: { text: "swap", color: BRAND },
pair: { text: "phase 2", color: "#8a7a3a" },
refuse: { text: "no match", color: "#8a4a3a" },
none: { text: "unknown", color: "#555" },
};
function ensureStyles() {
if (document.getElementById("ausboss-replace-style")) return;
const style = document.createElement("style");
style.id = "ausboss-replace-style";
style.textContent = `
.ausboss-replace-overlay { position: fixed; inset: 0; z-index: 10000;
background: rgba(0,0,0,0.55); display: flex; align-items: center;
justify-content: center; }
.ausboss-replace-dialog { background: #181818; color: #ddd;
border: 1px solid #333; border-top: 2px solid ${BRAND};
border-radius: 8px; width: min(640px, 92vw); max-height: 82vh;
display: flex; flex-direction: column;
font: 13px/1.45 sans-serif; box-shadow: 0 12px 40px rgba(0,0,0,0.5); }
.ausboss-replace-dialog header { padding: 12px 16px 4px; font-size: 15px;
font-weight: 600; }
.ausboss-replace-dialog .ausboss-replace-sub { padding: 0 16px 10px;
color: #999; }
.ausboss-replace-list { overflow-y: auto; padding: 0 8px;
border-top: 1px solid #2a2a2a; border-bottom: 1px solid #2a2a2a; }
.ausboss-replace-row { display: flex; gap: 10px; padding: 10px 8px;
border-bottom: 1px solid #222; align-items: flex-start; }
.ausboss-replace-row:last-child { border-bottom: none; }
.ausboss-replace-row input { margin-top: 3px; accent-color: ${BRAND}; }
.ausboss-replace-badge { flex: none; margin-top: 1px; padding: 1px 7px;
border-radius: 9px; font-size: 11px; color: #101010; font-weight: 600; }
.ausboss-replace-what { min-width: 0; }
.ausboss-replace-arrow { color: ${BRAND}; }
.ausboss-replace-note { color: #b09a55; }
.ausboss-replace-reason { color: #999; }
.ausboss-replace-dialog footer { padding: 12px 16px; display: flex;
gap: 10px; justify-content: flex-end; }
.ausboss-replace-dialog button { padding: 6px 16px; border-radius: 5px;
border: 1px solid #444; background: #262626; color: #ddd;
cursor: pointer; font: inherit; }
.ausboss-replace-dialog button.ausboss-replace-apply {
background: ${BRAND}; border-color: ${BRAND}; color: #081413;
font-weight: 600; }
.ausboss-replace-dialog button:disabled { opacity: 0.45; cursor: default; }
`;
document.head.appendChild(style);
}
function line(parent, className, text) {
const div = document.createElement("div");
div.className = className;
div.textContent = text;
parent.appendChild(div);
return div;
}
function showPlanDialog(rows) {
ensureStyles();
const overlay = document.createElement("div");
overlay.className = "ausboss-replace-overlay";
const dialog = document.createElement("div");
dialog.className = "ausboss-replace-dialog";
overlay.appendChild(dialog);
const header = document.createElement("header");
header.textContent = COMMAND_LABEL;
dialog.appendChild(header);
line(dialog, "ausboss-replace-sub", "Preview — nothing changes until you apply. Untick a row to keep that node.");
const list = document.createElement("div");
list.className = "ausboss-replace-list";
dialog.appendChild(list);
const checks = [];
for (const row of rows) {
const rowEl = document.createElement("label");
rowEl.className = "ausboss-replace-row";
const check = document.createElement("input");
check.type = "checkbox";
check.checked = row.tier === "swap";
check.disabled = row.tier !== "swap";
rowEl.appendChild(check);
checks.push({ check, row });
const badgeSpec = TIER_BADGE[row.tier] ?? TIER_BADGE.none;
const badge = document.createElement("span");
badge.className = "ausboss-replace-badge";
badge.textContent = badgeSpec.text;
badge.style.background = badgeSpec.color;
rowEl.appendChild(badge);
const what = document.createElement("div");
what.className = "ausboss-replace-what";
const head = document.createElement("div");
head.append(`#${row.id} ${row.title ? row.title + " — " : ""}${row.type}`);
if (row.target && row.tier === "swap") {
const arrow = document.createElement("span");
arrow.className = "ausboss-replace-arrow";
arrow.textContent = " → ";
head.append(arrow, displayNameFor(row.target));
}
what.appendChild(head);
for (const flag of row.flags) line(what, "ausboss-replace-note", "⚠ " + flag);
if (row.reason) line(what, "ausboss-replace-reason", row.reason);
rowEl.appendChild(what);
list.appendChild(rowEl);
}
const footer = document.createElement("footer");
const cancel = document.createElement("button");
cancel.textContent = "Cancel";
const apply = document.createElement("button");
apply.className = "ausboss-replace-apply";
const close = () => overlay.remove();
const refreshApply = () => {
const count = checks.filter((c) => c.check.checked).length;
apply.textContent = count ? `Replace ${count} node${count === 1 ? "" : "s"}` : "Replace";
apply.disabled = !count;
};
for (const { check } of checks) check.addEventListener("change", refreshApply);
refreshApply();
cancel.addEventListener("click", close);
overlay.addEventListener("click", (event) => {
if (event.target === overlay) close();
});
apply.addEventListener("click", () => {
const selected = checks.filter((c) => c.check.checked).map((c) => c.row);
close();
if (selected.length) applyPlan(selected);
});
footer.append(cancel, apply);
dialog.appendChild(footer);
document.body.appendChild(overlay);
}
// ----------------------------------------------------------------- command
function run() {
const candidates = collectCandidates(app.graph);
const rows = planReplacements(candidates, {});
if (!rows.length) {
notify("No replaceable third-party nodes in this workflow.");
return;
}
// planReplacements copies row data; reattach the live nodes for apply.
const byId = new Map(candidates.map((c) => [c.id, c.node]));
for (const row of rows) row.node = byId.get(row.id);
showPlanDialog(rows);
}
app.registerExtension({
name: "ausboss.replace.missing",
commands: [
{ id: "AusBoss.ReplaceMissing", label: "AusBoss: " + COMMAND_LABEL, function: run },
],
getCanvasMenuItems() {
return [{ content: COMMAND_LABEL, callback: run }];
},
});