Files
ausboss-ComfyUI-AusBoss/js/shared/compare_erase.mjs
ausboss 8837c75553 Keep see-through parts in Crop/Stitch Inpaint and Image Compare
Stitch Inpaint takes an RGB patch over an RGBA picture and keeps the
picture's own alpha, instead of stopping on a channel mismatch. The halo
fix spreads colours only.

Image Compare keeps alpha in a preview that has see-through parts and
flags it, so the panel shows them over a checkerboard (Slide, A/B and
Erase). The Erase mix lays the top picture over the other, the saved PNG
is RGBA, and the PSD carries each layer's transparency plus a merged
alpha, its flat colours stored over white as Photoshop does. A see-through
B is resized premultiplied. Align measures over gray and warps all four
channels. Solid pictures give byte-identical files.

Refs #232
2026-10-26 18:40:49 -05:00

198 lines
8.1 KiB
JavaScript

// Pure logic for Image Compare A/B's Erase mode. No DOM in here: the canvas
// and the buttons live in compare_erase_stage.mjs, so this stays testable
// under node:test.
//
// In Erase mode one picture lies on top of the other and a brush rubs the
// top one away. What is kept is the strokes, not a painted bitmap: the panel
// draws its preview from them, and the server builds the saved mix from the
// same strokes at the picture's full size (nodes/_compare_helpers.py holds
// the same numbers; tests/test_compare_erase.py keeps the two equal).
//
// A stroke is { o, d, s, p }: o 1 rubs the top picture away and 0 brings it
// back, d is the brush's width in pixels of the picture, s its softness in
// percent, and p the path as x0, y0, x1, y1 ... in pixels of the picture.
import { BRUSH_SOFT_DEFAULT, clampBrushSize, clampSoft, steppedBrushSize } from "./brush.mjs";
import { thinStroke } from "./touch_math.mjs";
// The brush itself is the pack's shared one (brush.mjs): Mask by Touch
// paints with the same.
export {
ADJUST_DEAD_ZONE,
ADJUST_TURN,
adjustAxis,
adjustedBrush,
brushCore,
brushSigma,
clampSoft,
moveAdjust,
startAdjust,
steppedSoft,
} from "./brush.mjs";
export const ERASE_SIZE = [2, 2000];
export const ERASE_MAX_STROKES = 400;
export const ERASE_MAX_STROKE_POINTS = 600;
export const ERASE_MAX_POINTS = 40000;
// A path may leave the picture while the button is held, but not by miles.
export const ERASE_OUTSIDE = 4096;
export const ERASE_SOFT_DEFAULT = BRUSH_SOFT_DEFAULT;
// The longest side of the mask the panel paints its preview from. The saved
// mix does not depend on it: the server draws the strokes again at full size.
export const PREVIEW_SIDE = 2048;
const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
export function clampSize(value) {
return clampBrushSize(value, ERASE_SIZE);
}
// The brush a picture opens with: about a twelfth of its longer side.
export function defaultBrushSize(width, height) {
const side = Math.max(Number(width) || 0, Number(height) || 0);
return side > 0 ? clampSize(Math.round(side / 24) * 2) : 64;
}
export function normalizeTop(value) {
return value === "B" ? "B" : "A";
}
// One stored stroke, checked. Null for anything that is not a stroke: what a
// workflow holds is somebody else's text.
export function cleanStroke(raw, width, height) {
const path = raw?.p;
if (!Array.isArray(path) || path.length < 2 || path.length % 2) return null;
if (path.length / 2 > ERASE_MAX_STROKE_POINTS) return null;
const points = [];
for (let index = 0; index < path.length; index += 2) {
const x = Number(path[index]);
const y = Number(path[index + 1]);
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
points.push(
clamp(Math.round(x), -ERASE_OUTSIDE, width + ERASE_OUTSIDE),
clamp(Math.round(y), -ERASE_OUTSIDE, height + ERASE_OUTSIDE),
);
}
return { o: raw.o === 0 ? 0 : 1, d: clampSize(raw.d), s: clampSoft(raw.s), p: points };
}
export function pointCount(strokes) {
return strokes.reduce((total, stroke) => total + stroke.p.length / 2, 0);
}
// Does one more stroke still fit in the mix?
export function strokeFits(strokes, stroke) {
if (strokes.length + 1 > ERASE_MAX_STROKES) return false;
return pointCount(strokes) + stroke.p.length / 2 <= ERASE_MAX_POINTS;
}
// The paint a node keeps with its workflow: the strokes and the size of the
// picture they were made on. Paint made on a picture of another size is
// dropped, since its places no longer mean anything.
export function readPaint(raw, width, height) {
const empty = { w: width, h: height, strokes: [] };
if (!raw || raw.w !== width || raw.h !== height || !Array.isArray(raw.strokes)) return empty;
const strokes = [];
for (const item of raw.strokes.slice(0, ERASE_MAX_STROKES)) {
const stroke = cleanStroke(item, width, height);
if (stroke && strokeFits(strokes, stroke)) strokes.push(stroke);
}
return { w: width, h: height, strokes };
}
// A finished stroke from the points the pointer left, in picture pixels.
// Points that add nothing to the line are dropped, more of them for a wide
// brush, which hides a wobble a thin one would show.
export function makeStroke(points, { erase = true, size, soft }, width, height) {
const d = clampSize(size);
const thinned = thinStroke(points, Math.max(0.75, d / 40), ERASE_MAX_STROKE_POINTS);
return cleanStroke({ o: erase ? 1 : 0, d, s: clampSoft(soft), p: thinned.flat() }, width, height);
}
// Where a picture sits in a box when it is shown whole and centred.
export function fitRect(boxWidth, boxHeight, width, height) {
if (!(boxWidth > 0) || !(boxHeight > 0) || !(width > 0) || !(height > 0)) {
return { left: 0, top: 0, width: 0, height: 0, scale: 0 };
}
const scale = Math.min(boxWidth / width, boxHeight / height);
const fitWidth = width * scale;
const fitHeight = height * scale;
return { left: (boxWidth - fitWidth) / 2, top: (boxHeight - fitHeight) / 2, width: fitWidth, height: fitHeight, scale };
}
// A see-through picture shows over a checkerboard, as in an editor: its
// squares' side in CSS pixels and their two colours.
export const CHECKER = { side: 8, light: "#4d4d4d", dark: "#383838" };
// The padding that holds a contained picture's own background to the
// picture: the bars beside it, as CSS. Empty when there is nothing to fit.
export function backdropPadding(boxWidth, boxHeight, width, height) {
const fit = fitRect(boxWidth, boxHeight, width, height);
if (!(fit.scale > 0)) return "";
return `${fit.top}px ${fit.left}px`;
}
// A place in the box as a place on the picture, in picture pixels.
export function toPicture(fit, x, y) {
if (!(fit?.scale > 0)) return null;
return [(x - fit.left) / fit.scale, (y - fit.top) / fit.scale];
}
// How much smaller than the picture the panel's own mask is kept.
export function previewScale(width, height, cap = PREVIEW_SIDE) {
const side = Math.max(width, height);
return side > cap ? cap / side : 1;
}
// The next brush size for the [ and ] keys.
export function steppedSize(size, up) {
return steppedBrushSize(size, up, ERASE_SIZE);
}
// What a saved mix is called when no name is given, and how long a name
// may be (nodes/_compare_helpers.py holds the same two).
export const SAVE_NAME = "compare";
export const SAVE_NAME_MAX = 80;
// The name field as it is typed in: letters, digits, spaces, dots, dashes
// and underscores stay, everything else is left out, so a folder can never
// be part of it. The server checks the same rule and refuses the rest.
export function typedName(text) {
return String(text ?? "").replace(/[^\p{L}\p{N}_ .\-]/gu, "").replace(/\.{2,}/g, ".").replace(/^[ .]+/, "").slice(0, SAVE_NAME_MAX);
}
// What the files will be called, for the hint under the pointer.
export function nameHint(name, layers) {
const base = typedName(name).trim().replace(/[ .]+$/, "") || SAVE_NAME;
return layers ? `${base}_00001.png, with ${base}_00001.psd and ${base}_00001_mask.png` : `${base}_00001.png`;
}
// What the Save button sends: the run's key for its two pictures, never a
// file to read, and the name typed for the new files.
export function saveRequest({ pair, top, strokes, layers, name }) {
return {
pair: typeof pair === "string" ? pair : "",
top: normalizeTop(top),
strokes: strokes ?? [],
layers: layers === true,
name: typedName(name).trim(),
};
}
// The line the panel shows once a save is done.
export function savedMessage(files) {
const names = (files ?? []).map((file) => (file?.subfolder ? `${file.subfolder}/${file.filename}` : file?.filename)).filter(Boolean);
if (!names.length) return "Saved.";
return names.length > 1 ? `Saved ${names[0]}, with its PSD and mask, in the output folder.` : `Saved ${names[0]} in the output folder.`;
}
// The saved files as short links for the row under the picture, so a mix
// made on another computer can be downloaded from the node.
export function savedLinks(files) {
return (files ?? []).filter((file) => file?.filename).map((file) => {
const name = String(file.filename).toLowerCase();
const label = name.endsWith("_mask.png") ? "MASK" : name.endsWith(".psd") ? "PSD" : "PNG";
return { label, file };
});
}