799 lines
29 KiB
JavaScript
799 lines
29 KiB
JavaScript
import { app } from "/scripts/app.js";
|
|
import { api } from "/scripts/api.js";
|
|
|
|
/* ═══════════════════════════════════════════════════════════════════
|
|
* Image Compare (RMBG) — Frontend
|
|
* ───────────────────────────────────────────────────────────────────
|
|
* ComfyUI v2 compatible — follows official DOM widget patterns:
|
|
* • V1 (LiteGraph): onDrawForeground + LiteGraph mouse hooks
|
|
* • V2 (Vue Nodes): addDOMWidget with <canvas> + ResizeObserver
|
|
*
|
|
* Key v2 rules:
|
|
* 1. Never call setDirtyCanvas in V2 mode
|
|
* 2. Use requestAnimationFrame to throttle DOM renders
|
|
* 3. Only render on actual state changes, not every pointermove
|
|
* 4. Clean up event listeners on node removal
|
|
* ═══════════════════════════════════════════════════════════════════ */
|
|
|
|
// ── Mode string → internal numeric ID ────────────────────────────────
|
|
const MODE_MAP = {
|
|
left_right: 0,
|
|
up_down: 1,
|
|
overlay: 2,
|
|
difference: 3,
|
|
side_by_side: 4,
|
|
highlight_diff: 5,
|
|
};
|
|
|
|
const STATE_KEY = "compareState";
|
|
const CANVAS_BACKING_CAP = 6000;
|
|
const MIN_W = 320;
|
|
const MIN_H = 280;
|
|
const INIT_W = 440;
|
|
const INIT_H = 360;
|
|
|
|
// ── V2 detection ─────────────────────────────────────────────────────
|
|
function isVueNodes() {
|
|
return !!window.LiteGraph?.vueNodesMode;
|
|
}
|
|
|
|
// ── API helpers ──────────────────────────────────────────────────────
|
|
function pixApiUrl(route) {
|
|
try {
|
|
if (typeof api?.apiURL === "function") return api.apiURL(route);
|
|
} catch (_) { }
|
|
return route;
|
|
}
|
|
|
|
function buildCmpUrl(d) {
|
|
return pixApiUrl(
|
|
`/view?filename=${encodeURIComponent(d.filename)}` +
|
|
`&type=${encodeURIComponent(d.type)}` +
|
|
`&subfolder=${encodeURIComponent(d.subfolder || "")}` +
|
|
`&t=${Date.now()}`
|
|
);
|
|
}
|
|
|
|
function loadCmpImage(node, meta, idx) {
|
|
const img = new Image();
|
|
img.crossOrigin = "anonymous";
|
|
img.onload = () => {
|
|
if (idx === 0) node._cmpImg1 = img;
|
|
else node._cmpImg2 = img;
|
|
node.imgs = null;
|
|
// Invalidate diff cache when images change
|
|
node._cmpDiffCacheKey = null;
|
|
node._cmpDiffCanvas = null;
|
|
repaint(node);
|
|
};
|
|
img.src = buildCmpUrl(meta);
|
|
}
|
|
|
|
// ── State persistence (debounced) ────────────────────────────────────
|
|
let _saveTimer = null;
|
|
function saveCompareState(node) {
|
|
if (_saveTimer) clearTimeout(_saveTimer);
|
|
_saveTimer = setTimeout(() => {
|
|
node.properties = node.properties || {};
|
|
const prev = node.properties[STATE_KEY] || {};
|
|
node.properties[STATE_KEY] = {
|
|
opacity: node._cmpOpacity ?? 0.5,
|
|
splitX: node._cmpSplitX ?? 0.5,
|
|
splitY: node._cmpSplitY ?? 0.5,
|
|
sideRatio: node._cmpSideRatio ?? 0.5,
|
|
diffThreshold: node._cmpDiffThreshold ?? 38,
|
|
images: prev.images || [],
|
|
};
|
|
_saveTimer = null;
|
|
}, 300);
|
|
}
|
|
|
|
function saveCompareImagesToProps(node, d1, d2) {
|
|
node.properties = node.properties || {};
|
|
const clean = (d) =>
|
|
d ? { filename: d.filename, subfolder: d.subfolder || "", type: d.type || "temp" } : null;
|
|
node.properties[STATE_KEY] = {
|
|
...node.properties[STATE_KEY],
|
|
images: [clean(d1), clean(d2)],
|
|
};
|
|
}
|
|
|
|
function restoreCompareFromProperties(node) {
|
|
if (node._cmpImg1 || node._cmpImg2) return;
|
|
const s = node.properties?.[STATE_KEY];
|
|
if (!s) return;
|
|
if (typeof s.opacity === "number") node._cmpOpacity = s.opacity;
|
|
if (typeof s.splitX === "number") node._cmpSplitX = s.splitX;
|
|
if (typeof s.splitY === "number") node._cmpSplitY = s.splitY;
|
|
if (typeof s.sideRatio === "number") node._cmpSideRatio = s.sideRatio;
|
|
if (typeof s.diffThreshold === "number") node._cmpDiffThreshold = s.diffThreshold;
|
|
if (Array.isArray(s.images)) {
|
|
if (s.images[0]) loadCmpImage(node, s.images[0], 0);
|
|
if (s.images[1]) loadCmpImage(node, s.images[1], 1);
|
|
}
|
|
repaint(node);
|
|
}
|
|
|
|
// ── Repaint dispatch ──────────────────────────────────────────────────
|
|
function repaint(node) {
|
|
if (!node) return;
|
|
if (isVueNodes()) {
|
|
// V2: schedule a render via rAF — never touch setDirtyCanvas
|
|
if (node._cmpRafPending) return;
|
|
node._cmpRafPending = true;
|
|
requestAnimationFrame(() => {
|
|
node._cmpRafPending = false;
|
|
node._cmpDomRender?.();
|
|
});
|
|
} else {
|
|
node.setDirtyCanvas?.(true, false);
|
|
}
|
|
}
|
|
|
|
// ── Image-fit helper ──────────────────────────────────────────────────
|
|
function fitImage(img, areaX, areaY, areaW, areaH) {
|
|
if (!img || !img.naturalWidth) {
|
|
return { x: areaX, y: areaY, w: areaW, h: areaH };
|
|
}
|
|
const ar = img.naturalWidth / img.naturalHeight;
|
|
const fw = areaW;
|
|
const fh = fw / ar;
|
|
if (fh <= areaH) {
|
|
return { x: areaX, y: areaY + (areaH - fh) / 2, w: areaW, h: fh };
|
|
}
|
|
const nh = areaH;
|
|
const nw = nh * ar;
|
|
return { x: areaX + (areaW - nw) / 2, y: areaY, w: nw, h: nh };
|
|
}
|
|
|
|
// ── Status bar helper ──────────────────────────────────────────────────
|
|
function drawStatusBar(ctx, rect, fill, color = "rgba(255,200,0,0.95)") {
|
|
if (!rect || rect.w <= 0) return;
|
|
const barH = 4;
|
|
const barY = rect.y;
|
|
ctx.fillStyle = "rgba(0,0,0,0.45)";
|
|
ctx.fillRect(rect.x, barY, rect.w, barH);
|
|
ctx.fillStyle = color;
|
|
ctx.fillRect(rect.x, barY, Math.max(0, Math.min(1, fill)) * rect.w, barH);
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════
|
|
// Paint core — draws the interactive image area
|
|
// ════════════════════════════════════════════════════════════════════
|
|
function paintCompare(ctx, node, W, H, imgTop) {
|
|
const imgH = H - imgTop;
|
|
if (imgH <= 0) return;
|
|
|
|
// ── Empty state ─────────────────────────────────────────────
|
|
if (!node._cmpImg1 && !node._cmpImg2) {
|
|
ctx.save();
|
|
ctx.fillStyle = "#555";
|
|
ctx.font = "12px 'Segoe UI',sans-serif";
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "middle";
|
|
ctx.fillText("Connect images & run to compare", W / 2, imgTop + imgH / 2);
|
|
ctx.restore();
|
|
return;
|
|
}
|
|
|
|
const m = node._cmpMode;
|
|
const img1 = node._cmpImg1;
|
|
const img2 = node._cmpImg2;
|
|
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.rect(0, imgTop, W, imgH);
|
|
ctx.clip();
|
|
|
|
// ── difference (static) ────────────────────────────────────
|
|
if (m === 3) {
|
|
const r1 = fitImage(img1 || img2, 0, imgTop, W, imgH);
|
|
if (img1) ctx.drawImage(img1, r1.x, r1.y, r1.w, r1.h);
|
|
if (img2) {
|
|
const r2 = fitImage(img2, 0, imgTop, W, imgH);
|
|
ctx.globalCompositeOperation = "difference";
|
|
ctx.drawImage(img2, r2.x, r2.y, r2.w, r2.h);
|
|
ctx.globalCompositeOperation = "source-over";
|
|
}
|
|
ctx.restore();
|
|
return;
|
|
}
|
|
|
|
// ── overlay (opacity) ─────────────────────────────────────
|
|
if (m === 2) {
|
|
const r1 = fitImage(img1 || img2, 0, imgTop, W, imgH);
|
|
if (img1) ctx.drawImage(img1, r1.x, r1.y, r1.w, r1.h);
|
|
if (img2) {
|
|
const r2 = fitImage(img2, 0, imgTop, W, imgH);
|
|
ctx.globalAlpha = node._cmpOpacity;
|
|
ctx.drawImage(img2, r2.x, r2.y, r2.w, r2.h);
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
if (img1 || img2) drawStatusBar(ctx, r1, node._cmpOpacity);
|
|
ctx.restore();
|
|
return;
|
|
}
|
|
|
|
// ── side_by_side (draggable divider) ──────────────────────
|
|
if (m === 4) {
|
|
const ratio = node._cmpSideRatio;
|
|
const halfW = W * ratio;
|
|
if (img1) {
|
|
const r1 = fitImage(img1, 0, imgTop, halfW, imgH);
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.rect(0, imgTop, halfW, imgH);
|
|
ctx.clip();
|
|
ctx.drawImage(img1, r1.x, r1.y, r1.w, r1.h);
|
|
ctx.restore();
|
|
}
|
|
if (img2) {
|
|
const r2 = fitImage(img2, halfW, imgTop, W - halfW, imgH);
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.rect(halfW, imgTop, W - halfW, imgH);
|
|
ctx.clip();
|
|
ctx.drawImage(img2, r2.x, r2.y, r2.w, r2.h);
|
|
ctx.restore();
|
|
}
|
|
if (ratio > 0.01 && ratio < 0.99) {
|
|
ctx.strokeStyle = "rgba(255,255,255,0.7)";
|
|
ctx.lineWidth = 1.5;
|
|
ctx.beginPath();
|
|
ctx.moveTo(halfW, imgTop);
|
|
ctx.lineTo(halfW, imgTop + imgH);
|
|
ctx.stroke();
|
|
}
|
|
ctx.restore();
|
|
return;
|
|
}
|
|
|
|
// ── left_right (left/right wipe) ────────────────────
|
|
if (m === 0) {
|
|
const sx = W * node._cmpSplitX;
|
|
if (img2) {
|
|
const r2 = fitImage(img2, 0, imgTop, W, imgH);
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.rect(sx, imgTop, W - sx, imgH);
|
|
ctx.clip();
|
|
ctx.drawImage(img2, r2.x, r2.y, r2.w, r2.h);
|
|
ctx.restore();
|
|
}
|
|
if (img1) {
|
|
const r1 = fitImage(img1, 0, imgTop, W, imgH);
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.rect(0, imgTop, sx, imgH);
|
|
ctx.clip();
|
|
ctx.drawImage(img1, r1.x, r1.y, r1.w, r1.h);
|
|
ctx.restore();
|
|
}
|
|
if (node._cmpSplitX > 0.01 && node._cmpSplitX < 0.99) {
|
|
ctx.strokeStyle = "rgba(255,255,255,0.7)";
|
|
ctx.lineWidth = 1.5;
|
|
ctx.beginPath();
|
|
ctx.moveTo(sx, imgTop);
|
|
ctx.lineTo(sx, imgTop + imgH);
|
|
ctx.stroke();
|
|
}
|
|
ctx.restore();
|
|
return;
|
|
}
|
|
|
|
// ── up_down (up/down wipe) ──────────────────────
|
|
if (m === 1) {
|
|
const sy = imgTop + imgH * node._cmpSplitY;
|
|
if (img2) {
|
|
const r2 = fitImage(img2, 0, imgTop, W, imgH);
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.rect(0, sy, W, imgTop + imgH - sy);
|
|
ctx.clip();
|
|
ctx.drawImage(img2, r2.x, r2.y, r2.w, r2.h);
|
|
ctx.restore();
|
|
}
|
|
if (img1) {
|
|
const r1 = fitImage(img1, 0, imgTop, W, imgH);
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.rect(0, imgTop, W, sy - imgTop);
|
|
ctx.clip();
|
|
ctx.drawImage(img1, r1.x, r1.y, r1.w, r1.h);
|
|
ctx.restore();
|
|
}
|
|
if (node._cmpSplitY > 0.01 && node._cmpSplitY < 0.99) {
|
|
ctx.strokeStyle = "rgba(255,255,255,0.7)";
|
|
ctx.lineWidth = 1.5;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, sy);
|
|
ctx.lineTo(W, sy);
|
|
ctx.stroke();
|
|
}
|
|
ctx.restore();
|
|
return;
|
|
}
|
|
|
|
// ── highlight_diff (grey base, red on difference) ─────────
|
|
if (m === 5) {
|
|
const r1 = fitImage(img1 || img2, 0, imgTop, W, imgH);
|
|
if (img1) {
|
|
ctx.drawImage(img1, r1.x, r1.y, r1.w, r1.h);
|
|
ctx.save();
|
|
ctx.globalCompositeOperation = "saturation";
|
|
ctx.fillStyle = "hsl(0,0%,50%)";
|
|
ctx.fillRect(r1.x, r1.y, r1.w, r1.h);
|
|
ctx.restore();
|
|
}
|
|
if (img2 && img1) {
|
|
const threshold = node._cmpDiffThreshold ?? 38;
|
|
const cw = Math.min(Math.round(r1.w), 512);
|
|
const ch = Math.min(Math.round(r1.h), 512);
|
|
const cacheKey = img1.src + "|" + img2.src + "|" + threshold + "|" + cw + "x" + ch;
|
|
if (node._cmpDiffCacheKey !== cacheKey) {
|
|
node._cmpDiffCacheKey = cacheKey;
|
|
const tmpC = document.createElement("canvas");
|
|
tmpC.width = cw;
|
|
tmpC.height = ch;
|
|
const tctx = tmpC.getContext("2d");
|
|
tctx.drawImage(img1, 0, 0, cw, ch);
|
|
const d1 = tctx.getImageData(0, 0, cw, ch);
|
|
tctx.clearRect(0, 0, cw, ch);
|
|
tctx.drawImage(img2, 0, 0, cw, ch);
|
|
const d2 = tctx.getImageData(0, 0, cw, ch);
|
|
const out = tctx.createImageData(cw, ch);
|
|
for (let i = 0; i < d1.data.length; i += 4) {
|
|
const dr = Math.abs(d1.data[i] - d2.data[i]);
|
|
const dg = Math.abs(d1.data[i + 1] - d2.data[i + 1]);
|
|
const db = Math.abs(d1.data[i + 2] - d2.data[i + 2]);
|
|
const avg = (dr + dg + db) / 3;
|
|
if (avg > threshold) {
|
|
out.data[i] = 255;
|
|
out.data[i + 1] = 0;
|
|
out.data[i + 2] = 0;
|
|
out.data[i + 3] = 255;
|
|
} else {
|
|
out.data[i + 3] = 0;
|
|
}
|
|
}
|
|
tctx.putImageData(out, 0, 0);
|
|
node._cmpDiffCanvas = tmpC;
|
|
}
|
|
if (node._cmpDiffCanvas) {
|
|
ctx.drawImage(node._cmpDiffCanvas, r1.x, r1.y, r1.w, r1.h);
|
|
}
|
|
}
|
|
ctx.restore();
|
|
return;
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════
|
|
// Mouse event handlers (shared logic)
|
|
// ════════════════════════════════════════════════════════════════════
|
|
function cmpMove(node, lx, ly, W, H, imgTop) {
|
|
const m = node._cmpMode;
|
|
const imgH = H - imgTop;
|
|
const img1 = node._cmpImg1;
|
|
const img2 = node._cmpImg2;
|
|
const ir = fitImage(img1 || img2, 0, imgTop, W, imgH);
|
|
const inImg = img1 || img2
|
|
? lx >= ir.x && lx <= ir.x + ir.w && ly >= ir.y && ly <= ir.y + ir.h
|
|
: false;
|
|
|
|
if (m === 4 && node._cmpDragging && inImg) {
|
|
node._cmpSideRatio = Math.max(0.02, Math.min(0.98, lx / W));
|
|
return true;
|
|
}
|
|
if (m === 2 && node._cmpDragging && inImg) {
|
|
node._cmpOpacity = Math.max(0, Math.min(1, (lx - ir.x) / ir.w));
|
|
return true;
|
|
}
|
|
if (m === 0 && inImg) {
|
|
node._cmpSplitX = Math.max(0, Math.min(1, lx / W));
|
|
return true;
|
|
}
|
|
if (m === 1 && inImg) {
|
|
node._cmpSplitY = Math.max(0, Math.min(1, (ly - imgTop) / imgH));
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function cmpDown(node, lx, ly, W, H, imgTop) {
|
|
const m = node._cmpMode;
|
|
const imgH = H - imgTop;
|
|
const img1 = node._cmpImg1;
|
|
const img2 = node._cmpImg2;
|
|
const ir = fitImage(img1 || img2, 0, imgTop, W, imgH);
|
|
const inImg = img1 || img2
|
|
? lx >= ir.x && lx <= ir.x + ir.w && ly >= ir.y && ly <= ir.y + ir.h
|
|
: false;
|
|
|
|
if (m === 4 && inImg) {
|
|
const halfW = W * node._cmpSideRatio;
|
|
if (Math.abs(lx - halfW) < 12) {
|
|
node._cmpDragging = "divider";
|
|
node._cmpSideRatio = Math.max(0.02, Math.min(0.98, lx / W));
|
|
return true;
|
|
}
|
|
}
|
|
if ((m === 0 || m === 1) && inImg) {
|
|
node._cmpDragging = "split";
|
|
return cmpMove(node, lx, ly, W, H, imgTop);
|
|
}
|
|
if (m === 2 && inImg) {
|
|
node._cmpDragging = "opacity";
|
|
node._cmpOpacity = Math.max(0, Math.min(1, (lx - ir.x) / ir.w));
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function cmpUp(node) {
|
|
if (node._cmpDragging) saveCompareState(node);
|
|
node._cmpDragging = false;
|
|
}
|
|
|
|
function cmpWheel(node, ly, deltaY, imgTop) {
|
|
const m = node._cmpMode;
|
|
if (m === 2 && ly >= imgTop) {
|
|
node._cmpOpacity = Math.max(0, Math.min(1, node._cmpOpacity + (deltaY > 0 ? -0.05 : 0.05)));
|
|
saveCompareState(node);
|
|
return true;
|
|
}
|
|
if (m === 5 && ly >= imgTop) {
|
|
node._cmpDiffThreshold = Math.max(5, Math.min(120, (node._cmpDiffThreshold ?? 38) + (deltaY > 0 ? -3 : 3)));
|
|
saveCompareState(node);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function cmpLeave(node) {
|
|
if (node._cmpDragging) saveCompareState(node);
|
|
node._cmpDragging = false;
|
|
if (node._cmpMode === 0) node._cmpSplitX = 0.5;
|
|
else if (node._cmpMode === 1) node._cmpSplitY = 0.5;
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════
|
|
// V2 (Vue Nodes 2.0) DOM Widget
|
|
// ─ Canvas inside a DOM container; events are local.
|
|
// ─ Uses rAF-throttled render, only triggered by real state changes.
|
|
// ─ NEVER calls setDirtyCanvas.
|
|
// ════════════════════════════════════════════════════════════════════
|
|
function canvasBackingScale(cssW, cssH) {
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const zoom = Math.max(1, app.canvas?.ds?.scale || 1);
|
|
let s = dpr * zoom;
|
|
const longCss = Math.max(cssW || 0, cssH || 0);
|
|
if (longCss > 0 && longCss * s > CANVAS_BACKING_CAP) {
|
|
s = CANVAS_BACKING_CAP / longCss;
|
|
}
|
|
return s;
|
|
}
|
|
|
|
function createCompareDOMWidget(node) {
|
|
const root = document.createElement("div");
|
|
root.className = "ailab-cmp-root";
|
|
root.style.cssText =
|
|
"position:relative;width:100%;flex:1 1 0;min-height:0;box-sizing:border-box;cursor:default;";
|
|
root._cmpNode = node;
|
|
|
|
const canvas = document.createElement("canvas");
|
|
canvas.style.cssText =
|
|
"position:absolute;inset:0;width:100%;height:100%;display:block;";
|
|
root.appendChild(canvas);
|
|
|
|
const widget = node.addDOMWidget("compare_viewer", "compare_viewer", root, {
|
|
serialize: false,
|
|
hideOnZoom: false,
|
|
getMinHeight: () => MIN_H,
|
|
});
|
|
|
|
widget.computeLayoutSize = () => ({ minHeight: MIN_H, minWidth: 1 });
|
|
|
|
// In V1, make widget canvas-only so LiteGraph doesn't render as HTML
|
|
try {
|
|
Object.defineProperty(widget.options, "canvasOnly", {
|
|
configurable: true,
|
|
enumerable: true,
|
|
get() { return !window.LiteGraph?.vueNodesMode; },
|
|
});
|
|
} catch (_) {
|
|
widget.options.canvasOnly = !window.LiteGraph?.vueNodesMode;
|
|
}
|
|
|
|
// ── rAF-throttled render — the ONLY render path for V2 ────
|
|
// Scheduled via repaint(); never called directly from events.
|
|
const render = () => {
|
|
const cssW = root.clientWidth;
|
|
const cssH = root.clientHeight;
|
|
if (cssW <= 0 || cssH <= 0) return;
|
|
|
|
const s = canvasBackingScale(cssW, cssH);
|
|
const bw = Math.round(cssW * s);
|
|
const bh = Math.round(cssH * s);
|
|
if (canvas.width !== bw) canvas.width = bw;
|
|
if (canvas.height !== bh) canvas.height = bh;
|
|
|
|
const ctx = canvas.getContext("2d");
|
|
ctx.setTransform(s, 0, 0, s, 0, 0);
|
|
ctx.clearRect(0, 0, cssW, cssH);
|
|
|
|
node._cmpDomW = cssW;
|
|
node._cmpDomH = cssH;
|
|
paintCompare(ctx, node, cssW, cssH, 0);
|
|
};
|
|
node._cmpDomRender = render;
|
|
|
|
// ── Local coordinate mapping ──────────────────────────────
|
|
const localPos = (e) => {
|
|
const r = root.getBoundingClientRect();
|
|
const sx = r.width ? root.clientWidth / r.width : 1;
|
|
const sy = r.height ? root.clientHeight / r.height : 1;
|
|
return [(e.clientX - r.left) * sx, (e.clientY - r.top) * sy];
|
|
};
|
|
const W = () => node._cmpDomW || root.clientWidth;
|
|
const H = () => node._cmpDomH || root.clientHeight;
|
|
|
|
// ── Pointer events — only repaint when state actually changes ──
|
|
root.addEventListener("pointerdown", (e) => {
|
|
const [lx, ly] = localPos(e);
|
|
if (cmpDown(node, lx, ly, W(), H(), 0)) {
|
|
e.stopPropagation();
|
|
if (node._cmpDragging) {
|
|
try { root.setPointerCapture(e.pointerId); } catch (_) { }
|
|
}
|
|
repaint(node);
|
|
}
|
|
});
|
|
|
|
root.addEventListener("pointermove", (e) => {
|
|
const [lx, ly] = localPos(e);
|
|
const changed = cmpMove(node, lx, ly, W(), H(), 0);
|
|
if (changed) {
|
|
e.stopPropagation();
|
|
repaint(node);
|
|
}
|
|
// Update cursor — cheap, no canvas repaint needed
|
|
const m = node._cmpMode;
|
|
const ir = fitImage(node._cmpImg1 || node._cmpImg2, 0, 0, W(), H());
|
|
const inImg = (node._cmpImg1 || node._cmpImg2) &&
|
|
lx >= ir.x && lx <= ir.x + ir.w && ly >= ir.y && ly <= ir.y + ir.h;
|
|
const prevCursor = root.style.cursor;
|
|
let nextCursor = "default";
|
|
if (inImg && (m === 0 || m === 2 || m === 4)) nextCursor = "ew-resize";
|
|
else if (inImg && m === 1) nextCursor = "ns-resize";
|
|
if (prevCursor !== nextCursor) root.style.cursor = nextCursor;
|
|
});
|
|
|
|
root.addEventListener("pointerup", (e) => {
|
|
cmpUp(node);
|
|
try { root.releasePointerCapture(e.pointerId); } catch (_) { }
|
|
repaint(node);
|
|
});
|
|
|
|
root.addEventListener("pointerleave", () => {
|
|
cmpLeave(node);
|
|
repaint(node);
|
|
});
|
|
|
|
// ── Wheel: only intercept in overlay/highlight_diff modes ───
|
|
// In other modes, let the event bubble so graph zoom works.
|
|
root.addEventListener("wheel", (e) => {
|
|
const [, ly] = localPos(e);
|
|
if (cmpWheel(node, ly, e.deltaY, 0)) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
repaint(node);
|
|
}
|
|
}, { passive: false });
|
|
|
|
// ── ResizeObserver: repaint when container size changes ─────
|
|
const ro = new ResizeObserver(() => repaint(node));
|
|
ro.observe(root);
|
|
node._cmpDomRO = ro;
|
|
|
|
// Initial render
|
|
repaint(node);
|
|
return widget;
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════
|
|
// V1 (LiteGraph) helpers
|
|
// ════════════════════════════════════════════════════════════════════
|
|
function getV1WidgetBottomY(node) {
|
|
const widgets = node.widgets || [];
|
|
if (widgets.length > 0) {
|
|
const last = widgets[widgets.length - 1];
|
|
if (last && typeof last.last_y === "number") {
|
|
return Math.ceil(last.last_y + (last.computedHeight || 20) + 6);
|
|
}
|
|
}
|
|
const titleH = 30;
|
|
const inputH = (node.inputs?.length || 0) * 26;
|
|
const widgetH = widgets.length * 26;
|
|
return Math.max(titleH + inputH + widgetH + 4, 60);
|
|
}
|
|
|
|
function getV1ImgTop(node) {
|
|
return node._cmpImgTop ?? 60;
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════
|
|
// Register Extension
|
|
// ════════════════════════════════════════════════════════════════════
|
|
app.registerExtension({
|
|
name: "AILab.ImageCompareView",
|
|
|
|
async beforeRegisterNodeDef(nodeType, nodeData) {
|
|
if (nodeData.name !== "AILab_ImageCompareView") return;
|
|
|
|
// ── onNodeCreated ──────────────────────────────────────────
|
|
const _origCreated = nodeType.prototype.onNodeCreated;
|
|
nodeType.prototype.onNodeCreated = function () {
|
|
_origCreated?.apply(this, arguments);
|
|
|
|
this._cmpMode = 0;
|
|
this._cmpSplitX = 0.5;
|
|
this._cmpSplitY = 0.5;
|
|
this._cmpOpacity = 0.5;
|
|
this._cmpSideRatio = 0.5;
|
|
this._cmpDiffThreshold = 38;
|
|
this._cmpDragging = false;
|
|
this._cmpImg1 = null;
|
|
this._cmpImg2 = null;
|
|
this._cmpImgTop = 54;
|
|
|
|
this.hideOutputImages = true;
|
|
this.size = [INIT_W, INIT_H];
|
|
|
|
// V2: create DOM widget
|
|
if (isVueNodes()) {
|
|
createCompareDOMWidget(this);
|
|
}
|
|
|
|
// Hook the native "mode" widget
|
|
const syncMode = () => {
|
|
const w = this.widgets?.find((w) => w.name === "mode");
|
|
if (!w) return;
|
|
const val = w.value || w.options?.default || "left_right";
|
|
this._cmpMode = MODE_MAP[val] ?? 0;
|
|
const origCb = w.callback;
|
|
w.callback = (...args) => {
|
|
const v = args[0] || w.value;
|
|
this._cmpMode = MODE_MAP[v] ?? 0;
|
|
repaint(this);
|
|
if (typeof origCb === "function") return origCb.apply(w, args);
|
|
};
|
|
};
|
|
queueMicrotask(syncMode);
|
|
|
|
// Restore from saved state (only if not already restored by onConfigure)
|
|
queueMicrotask(() => {
|
|
if (!this._cmpRestored) {
|
|
this._cmpRestored = true;
|
|
restoreCompareFromProperties(this);
|
|
}
|
|
});
|
|
};
|
|
|
|
// ── onConfigure (workflow load) ────────────────────────────
|
|
const _origConfigure = nodeType.prototype.onConfigure;
|
|
nodeType.prototype.onConfigure = function () {
|
|
const r = _origConfigure ? _origConfigure.apply(this, arguments) : undefined;
|
|
this._cmpRestored = true;
|
|
restoreCompareFromProperties(this);
|
|
return r;
|
|
};
|
|
|
|
// ── onExecuted ────────────────────────────────────────────
|
|
nodeType.prototype.onExecuted = function (output) {
|
|
const imgs = output?.images || [];
|
|
const d1 = imgs.find((i) => i.slot === 1) || null;
|
|
const d2 = imgs.find((i) => i.slot === 2) || null;
|
|
|
|
saveCompareImagesToProps(this, d1, d2);
|
|
|
|
if (d1) loadCmpImage(this, d1, 0);
|
|
else this._cmpImg1 = null;
|
|
if (d2) loadCmpImage(this, d2, 1);
|
|
else this._cmpImg2 = null;
|
|
repaint(this);
|
|
};
|
|
|
|
// ── onDrawBackground: suppress default preview ────────────
|
|
nodeType.prototype.onDrawBackground = function () {
|
|
if (this.flags?.collapsed) return;
|
|
if (this.imgs) this.imgs = null;
|
|
};
|
|
|
|
// ── onDrawForeground (V1 only) ─────────────────────────────
|
|
const _origDraw = nodeType.prototype.onDrawForeground;
|
|
nodeType.prototype.onDrawForeground = function (ctx) {
|
|
if (_origDraw) _origDraw.call(this, ctx);
|
|
if (this.flags?.collapsed) return;
|
|
if (isVueNodes()) return;
|
|
|
|
if (this.size[0] < MIN_W) this.size[0] = MIN_W;
|
|
if (this.size[1] < MIN_H) this.size[1] = MIN_H;
|
|
|
|
this._cmpImgTop = getV1WidgetBottomY(this);
|
|
paintCompare(ctx, this, this.size[0], this.size[1], this._cmpImgTop);
|
|
};
|
|
|
|
// ── V1 Mouse hooks ────────────────────────────────────────
|
|
const _origDown = nodeType.prototype.onMouseDown;
|
|
nodeType.prototype.onMouseDown = function (e, pos) {
|
|
if (isVueNodes()) return _origDown ? _origDown.call(this, e, pos) : undefined;
|
|
const imgTop = getV1ImgTop(this);
|
|
if (cmpDown(this, pos[0], pos[1], this.size[0], this.size[1], imgTop)) {
|
|
this.setDirtyCanvas(true, false);
|
|
return true;
|
|
}
|
|
if (_origDown) return _origDown.call(this, e, pos);
|
|
};
|
|
|
|
const _origMove = nodeType.prototype.onMouseMove;
|
|
nodeType.prototype.onMouseMove = function (e, pos) {
|
|
if (isVueNodes()) return _origMove ? _origMove.call(this, e, pos) : undefined;
|
|
const imgTop = getV1ImgTop(this);
|
|
if (cmpMove(this, pos[0], pos[1], this.size[0], this.size[1], imgTop)) {
|
|
this.setDirtyCanvas(true, false);
|
|
}
|
|
if (_origMove) return _origMove.call(this, e, pos);
|
|
};
|
|
|
|
const _origUp = nodeType.prototype.onMouseUp;
|
|
nodeType.prototype.onMouseUp = function (e, pos) {
|
|
if (isVueNodes()) return _origUp ? _origUp.call(this, e, pos) : undefined;
|
|
cmpUp(this);
|
|
if (_origUp) return _origUp.call(this, e, pos);
|
|
};
|
|
|
|
const _origWheel = nodeType.prototype.onMouseWheel;
|
|
nodeType.prototype.onMouseWheel = function (e, pos) {
|
|
if (isVueNodes()) return _origWheel ? _origWheel.call(this, e, pos) : undefined;
|
|
const imgTop = getV1ImgTop(this);
|
|
if (cmpWheel(this, pos[1], e.deltaY, imgTop)) {
|
|
this.setDirtyCanvas(true, false);
|
|
return true;
|
|
}
|
|
if (_origWheel) return _origWheel.call(this, e, pos);
|
|
};
|
|
|
|
const _origLeave = nodeType.prototype.onMouseLeave;
|
|
nodeType.prototype.onMouseLeave = function (e) {
|
|
if (isVueNodes()) return _origLeave ? _origLeave.call(this, e) : undefined;
|
|
cmpLeave(this);
|
|
this.setDirtyCanvas(true, false);
|
|
if (_origLeave) return _origLeave.call(this, e);
|
|
};
|
|
|
|
// ── onResize ──────────────────────────────────────────────
|
|
const _origResize = nodeType.prototype.onResize;
|
|
nodeType.prototype.onResize = function (e) {
|
|
if (_origResize) _origResize.call(this, e);
|
|
if (isVueNodes()) return;
|
|
this.size[0] = Math.max(this.size[0], MIN_W);
|
|
this.size[1] = Math.max(this.size[1], MIN_H);
|
|
};
|
|
|
|
// ── onRemoved: cleanup ────────────────────────────────────
|
|
const _origRemoved = nodeType.prototype.onRemoved;
|
|
nodeType.prototype.onRemoved = function () {
|
|
try { this._cmpDomRO?.disconnect(); } catch (_) { }
|
|
this._cmpDomRO = null;
|
|
this._cmpDomRender = null;
|
|
this._cmpRafPending = false;
|
|
this._cmpDiffCanvas = null;
|
|
this._cmpDiffCacheKey = null;
|
|
return _origRemoved ? _origRemoved.apply(this, arguments) : undefined;
|
|
};
|
|
},
|
|
});
|