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.
344 lines
13 KiB
JavaScript
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 }];
|
|
},
|
|
});
|