Files
MariusKMandClaude Opus 4.7 e353aae491 Add Color Match, Chroma Clean, and Tiled Background Removal nodes
Ports four nodes from the EmpireTitan pack into the Badman naming
convention: Color Match (with preset save/load + live preview), Color
Match Combine Presets, Chroma Clean matte refinement, and a tiled
BiRefNet/RMBG wrapper that recovers fine-edge detail. Updates README,
bumps version to 1.3.0, adds .gitignore, and fixes a stale display-name
key for IO Config.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 20:41:32 +02:00

290 lines
11 KiB
JavaScript

import { app } from "../../scripts/app.js";
const NODE_NAME = "BadmanColorMatch";
const COMBINE_NODE_NAME = "BadmanColorMatchCombine";
// Widgets only present in build mode.
const BUILD_WIDGETS = ["method", "save_as", "overwrite"];
// Widgets only present in preset mode.
const PRESET_WIDGETS = ["preset_name"];
// Optional input sockets only present in build mode.
// Order matters: we re-add in this order so the node looks consistent.
const BUILD_INPUTS = [
{ name: "source_ref", type: "IMAGE" },
{ name: "target_ref", type: "IMAGE" },
{ name: "source_mask", type: "MASK" },
{ name: "target_mask", type: "MASK" },
];
function findWidget(node, name) {
return node.widgets?.find((w) => w.name === name);
}
function setWidgetVisible(widget, visible) {
if (!widget) return;
if (visible) {
if (widget._origType !== undefined) widget.type = widget._origType;
widget.computeSize = widget._origComputeSize || null;
widget.hidden = false;
} else {
if (widget._origType === undefined) widget._origType = widget.type;
if (!widget._origComputeSize) widget._origComputeSize = widget.computeSize;
widget.type = "hidden";
widget.computeSize = () => [0, -4];
widget.hidden = true;
}
}
function findInputIndex(node, name) {
return node.inputs?.findIndex((i) => i.name === name) ?? -1;
}
function removeInputByName(node, name) {
const idx = findInputIndex(node, name);
if (idx >= 0) node.removeInput(idx); // handles link disconnect internally
}
function ensureInputByName(node, name, type) {
if (findInputIndex(node, name) >= 0) return;
node.addInput(name, type);
}
function applyMode(node) {
const toggle = findWidget(node, "use_preset");
if (!toggle) return;
const usePreset = !!toggle.value;
for (const n of BUILD_WIDGETS) setWidgetVisible(findWidget(node, n), !usePreset);
for (const n of PRESET_WIDGETS) setWidgetVisible(findWidget(node, n), usePreset);
if (usePreset) {
for (const { name } of BUILD_INPUTS) removeInputByName(node, name);
} else {
for (const { name, type } of BUILD_INPUTS) ensureInputByName(node, name, type);
}
node.setSize(node.computeSize());
node.setDirtyCanvas(true, true);
}
async function refreshPresets(node) {
try {
const r = await fetch("/badman/color_match/presets");
if (!r.ok) return;
const data = await r.json();
if (!Array.isArray(data.presets)) return;
// Update every widget whose name starts with "preset" (preset_name, preset_1..N).
for (const w of node.widgets || []) {
if (!w?.name?.startsWith("preset")) continue;
if (!Array.isArray(w.options?.values)) continue;
const current = w.value;
w.options.values = data.presets;
if (!data.presets.includes(current)) w.value = data.presets[0];
}
} catch (e) {
console.warn("[Badman ColorMatch] preset refresh failed", e);
}
}
// ---------- live preview ----------
function installPreview(node) {
const wrap = document.createElement("div");
wrap.style.cssText =
"display:flex;flex-direction:column;gap:2px;padding:4px;box-sizing:border-box;";
const img = document.createElement("img");
img.style.cssText =
"width:100%;display:block;background:#222;border-radius:4px;" +
"image-rendering:auto;object-fit:contain;min-height:80px;";
const status = document.createElement("div");
status.style.cssText =
"font-size:10px;color:#888;min-height:12px;font-family:monospace;";
status.textContent = "run workflow once to seed preview";
wrap.appendChild(img);
wrap.appendChild(status);
node.addDOMWidget("preview", "preview", wrap, {
serialize: false,
getHeight: () => 240,
});
node._emtdPreview = { img, status };
}
function schedulePreview(node, delay = 180) {
clearTimeout(node._emtdPreviewTimer);
node._emtdPreviewTimer = setTimeout(() => fetchPreview(node), delay);
}
async function fetchPreview(node) {
const refs = node._emtdPreview;
if (!refs) return;
const { img, status } = refs;
const getVal = (name) => findWidget(node, name)?.value;
const body = {
node_id: String(node.id),
use_preset: !!getVal("use_preset"),
preset_name: getVal("preset_name"),
method: getVal("method"),
strength: getVal("strength"),
};
status.textContent = "updating…";
try {
const r = await fetch("/badman/color_match/preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await r.json().catch(() => ({}));
if (!r.ok) {
status.textContent = data.error || `error ${r.status}`;
return;
}
img.src = data.image;
status.textContent = "";
} catch (e) {
status.textContent = String(e);
}
}
function wireLiveWidgets(node) {
const names = ["use_preset", "preset_name", "method", "strength"];
for (const n of names) {
const w = findWidget(node, n);
if (!w) continue;
const orig = w.callback;
w.callback = function (v) {
orig?.apply(this, arguments);
schedulePreview(node);
};
}
}
app.registerExtension({
name: "Badman.ColorMatch",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name === COMBINE_NODE_NAME) {
const MIN_SLOTS = 2;
// Count declared preset_N widgets on the node (matches MAX_SLOTS in py).
const countPresetWidgets = (node) =>
(node.widgets || []).filter((w) => /^preset_\d+$/.test(w.name)).length;
const applyVisibleCount = (node) => {
const total = countPresetWidgets(node);
const n = Math.max(
MIN_SLOTS,
Math.min(total, node.properties.visible_presets ?? MIN_SLOTS),
);
node.properties.visible_presets = n;
for (let i = 1; i <= total; i++) {
const w = findWidget(node, `preset_${i}`);
setWidgetVisible(w, i <= n);
}
node.setSize(node.computeSize());
node.setDirtyCanvas(true, true);
};
const onCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
onCreated?.apply(this, arguments);
this.properties = this.properties || {};
if (this.properties.visible_presets == null) {
this.properties.visible_presets = MIN_SLOTS;
}
// Add + / - control buttons placed just below the last preset slot.
this.addWidget("button", "+ preset slot", null, () => {
const total = countPresetWidgets(this);
this.properties.visible_presets = Math.min(
total,
(this.properties.visible_presets ?? MIN_SLOTS) + 1,
);
applyVisibleCount(this);
});
this.addWidget("button", "- preset slot", null, () => {
this.properties.visible_presets = Math.max(
MIN_SLOTS,
(this.properties.visible_presets ?? MIN_SLOTS) - 1,
);
// Reset hidden slot back to <none> so it doesn't contribute.
const n = this.properties.visible_presets;
const w = findWidget(this, `preset_${n + 1}`);
if (w && w.options?.values?.length) w.value = w.options.values[0];
applyVisibleCount(this);
});
refreshPresets(this);
applyVisibleCount(this);
};
// Re-apply visibility after deserialization picks up widget values.
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
onConfigure?.apply(this, arguments);
// If the user had slots with real values, expand to cover them.
const total = countPresetWidgets(this);
let lastUsed = MIN_SLOTS;
for (let i = 1; i <= total; i++) {
const w = findWidget(this, `preset_${i}`);
if (w && w.value && w.value !== "<none>") lastUsed = i;
}
this.properties = this.properties || {};
this.properties.visible_presets = Math.max(
this.properties.visible_presets ?? MIN_SLOTS,
lastUsed,
);
applyVisibleCount(this);
};
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function () {
onExecuted?.apply(this, arguments);
refreshPresets(this);
};
// Refresh this node's dropdowns whenever any workflow run completes,
// so newly-saved presets from the main node show up immediately.
const api = app.api;
if (api && !nodeType._emtdCombineHooked) {
nodeType._emtdCombineHooked = true;
api.addEventListener("executed", () => {
for (const n of app.graph?._nodes || []) {
if (n.type === COMBINE_NODE_NAME) refreshPresets(n);
}
});
}
return;
}
if (nodeData.name !== NODE_NAME) return;
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
onNodeCreated?.apply(this, arguments);
const toggle = findWidget(this, "use_preset");
if (toggle) {
const orig = toggle.callback;
toggle.callback = (v) => {
orig?.call(toggle, v);
applyMode(this);
schedulePreview(this);
};
}
installPreview(this);
wireLiveWidgets(this);
refreshPresets(this).then(() => applyMode(this));
};
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (msg) {
onExecuted?.apply(this, arguments);
// Cache is fresh after execute, pull both preset list and preview.
refreshPresets(this);
schedulePreview(this, 50);
};
},
});