Files
ausboss bf2f75d487 Compare and Save Image: step through several pictures
A list of pictures runs these nodes once per picture, and the panels
only showed one of them (#204). Arrows (2 / 5) now appear on the node
when a run brings more than one picture: Compare steps through its
pairs, A and B together, and Erase saves the pair on screen. The shared
preview panel gets the same arrows, so Save Image, Mask Refine, Mask by
Name, LaMa Inpaint and Select Frame step through their results. A node
too narrow for the arrows widens to fit while they show.
2026-10-18 17:50:53 +00:00

169 lines
7.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Pure decision logic for the Image Compare A/B panel. No DOM in here — the
// wiring lives in js/compare/index.js so this module stays testable
// under node:test.
// "erase" is the brush mode: its canvas and tools are in compare_erase_stage.mjs.
export const COMPARE_MODES = ["slide", "toggle", "erase"];
// "hold" was the press-and-hold mode that toggle replaced. Mapping it keeps a
// workflow saved with it on the button it became, instead of quietly dropping
// back to slide - the panel would still work, but not the way it was left.
const LEGACY_MODES = { hold: "toggle" };
export function normalizeCompareMode(value) {
const mapped = LEGACY_MODES[value] ?? value;
return COMPARE_MODES.includes(mapped) ? mapped : COMPARE_MODES[0];
}
// The caption under the stage: the compared resolution, or "" when nothing
// has been loaded yet. Both sides are normally the same size, so A speaks for
// the pair; when they differ, both are named rather than one quietly winning.
export function compareSizeLabel(refs) {
const a = refs?.a;
const b = refs?.b;
if (!(a?.width > 0) || !(a?.height > 0)) return "";
if (b?.width > 0 && b?.height > 0 && (b.width !== a.width || b.height !== a.height)) {
return `A ${a.width}×${a.height} · B ${b.width}×${b.height}`;
}
return `${a.width}×${a.height}`;
}
// Fraction of the panel width covered by the pointer, clamped to 0..1.
// Degenerate rectangles and non-finite input resolve to 0 (no left overlay).
export function clipFraction(pointerX, rectLeft, rectWidth) {
const width = Number(rectWidth);
if (!Number.isFinite(width) || width <= 0) return 0;
const fraction = (Number(pointerX) - Number(rectLeft)) / width;
if (!Number.isFinite(fraction)) return 0;
return Math.max(0, Math.min(1, fraction));
}
// Give both edges a forgiving 6% landing zone. Leaving through any side
// settles to the nearest full image instead of stranding a partial split.
export function slideFraction(pointerX, rectLeft, rectWidth, leaving = false) {
const fraction = clipFraction(pointerX, rectLeft, rectWidth);
if (leaving) return fraction < 0.5 ? 0 : 1;
if (fraction <= 0.06) return 0;
if (fraction >= 0.94) return 1;
return fraction;
}
// CSS for a given reveal fraction: the overlay keeps its left portion up to the
// seam, the rest is clipped away. The seam only shows while both images are
// partially visible.
//
// A picture that is wholly covered is not drawn (showA, showB), and a
// picture shown whole has no clip. The browser draws a picture on whole
// pixels but cuts a clip where it falls, so on a stage whose width is not a
// whole number the clipped A ended up to a pixel short of B, and B showed as
// a line down the right edge of A.
export function compareClip(fraction) {
const value = Number(fraction);
const clamped = Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0;
return {
clipPath: clamped >= 1 ? "none" : `inset(0 ${((1 - clamped) * 100).toFixed(2)}% 0 0)`,
seamLeft: `${(clamped * 100).toFixed(2)}%`,
seamVisible: clamped > 0 && clamped < 1,
showA: clamped > 0,
showB: clamped < 1,
};
}
// Pull the two preview references out of an onExecuted payload. Both must
// be present for the panel to have anything meaningful to show. `pair` is
// the run's key for Erase's Save, which sends it back in place of file
// names, and `name` is the save name the run was wired with, if any.
export function findCompareImages(message) {
return findComparePairs(message)[0] ?? null;
}
// Every pair of a run. A list of pictures runs the node once per picture,
// and ComfyUI hands the panel all their previews in one message, in order.
export function findComparePairs(message) {
const count = Math.min(message?.a_images?.length ?? 0, message?.b_images?.length ?? 0);
const pairs = [];
for (let index = 0; index < count; index += 1) {
const a = message.a_images[index];
const b = message.b_images[index];
if (!a?.filename || !b?.filename) continue;
const pair = typeof message?.pair?.[index] === "string" ? message.pair[index] : null;
const name = typeof message?.save_name?.[index] === "string" ? message.save_name[index] : "";
pairs.push({ a, b, pair, name });
}
return pairs;
}
// Where the mode switch stands for a mode and a locked side.
export function compareSwitchValue(mode, showingB) {
if (mode === "erase") return "erase";
if (mode === "toggle") return showingB ? "b" : "a";
return "slide";
}
// Erase grows a node too small for its tool rows; leaving Erase gives that
// size back. `record` holds the size before ({before, grown}); a side the
// user has resized since keeps the new size. Null when nothing changes.
export function sizeAfterErase(size, record) {
const pair = (value) => Array.isArray(value) && value.length >= 2
&& value.slice(0, 2).every((side) => Number.isFinite(side) && side > 0);
if (!pair(size) || !pair(record?.before) || !pair(record?.grown)) return null;
const next = [0, 1].map((axis) => (
Math.abs(size[axis] - record.grown[axis]) < 1 ? record.before[axis] : size[axis]
));
return next[0] === size[0] && next[1] === size[1] ? null : next;
}
// Where the A and B labels sit: each on its own side of the split line, so
// they move with it; one label, centred, while only one picture shows. A
// side too narrow for its label drops the label rather than covering the
// line. Pixel offsets from the stage's left edge, or null for no label.
export function compareBadges(fraction, stageWidth, badgeWidth = 22, gap = 5) {
const width = Number(stageWidth);
const value = Number(fraction);
if (!Number.isFinite(width) || width <= 0) return { a: null, b: null };
const clamped = Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0;
const centred = Math.round((width - badgeWidth) / 2);
if (clamped <= 0) return { a: null, b: centred };
if (clamped >= 1) return { a: centred, b: null };
const seam = clamped * width;
const a = Math.round(seam - gap - badgeWidth);
const b = Math.round(seam + gap);
return { a: a >= 4 ? a : null, b: b + badgeWidth <= width - 4 ? b : null };
}
// Is `target` somewhere upstream of `start`? `feeds(id)` lists the ids of
// the nodes wired into node `id`.
function isUpstream(target, start, feeds) {
const seen = new Set([start]);
const queue = [start];
while (queue.length) {
for (const id of feeds(queue.pop()) ?? []) {
if (id == null || seen.has(id)) continue;
if (id === target) return true;
seen.add(id);
queue.push(id);
}
}
return false;
}
// Which input holds the result, the picture the panel rests on. B is the
// "after" picture by convention. When B's picture feeds the node that makes
// A, they are wired the other way round and the result is A.
//
// `a` and `b` say where the two pictures come from: { id, slot } of the node
// and output wired to each input. `graph` reads the links: feeds(id) lists
// the ids of the nodes wired into node `id`, and inside(id) opens a
// subgraph: the same two calls for the graph in it, plus output(slot),
// where that output comes from in there.
export function compareResultSide(a, b, graph) {
// Both pictures out of one subgraph: the answer is inside it.
for (let depth = 0; a && b && a.id === b.id && depth < 16; depth += 1) {
const inner = graph.inside?.(a.id);
if (!inner) return "B";
[a, b, graph] = [inner.output(a.slot), inner.output(b.slot), inner];
}
if (!a || !b || a.id === b.id) return "B";
return isUpstream(b.id, a.id, graph.feeds) ? "A" : "B";
}