import { app } from "/scripts/app.js"; import { applyAdaptiveCanvasOnly, isVueNodes } from "../shared/nodes2.mjs"; const NODE_NAME = "DumasImageCompare"; const BRAND = "#e66a2c"; const BG = "#1c1d1f"; const PANEL = "#2a2c2e"; const BORDER = "#444"; const TEXT = "#d8d8d8"; const MUTED = "#9a9a9a"; const MODE_LABELS = ["Show 1", "Show 2", "Left Right", "Up Down", "Overlay", "Difference"]; const MODE_HINTS = ["", "", "Hover to slide left / right", "Hover to slide up / down", "", "Shows pixel differences"]; const INIT_W = 440; const IMAGE_TOP = 54; const INIT_H = INIT_W + IMAGE_TOP; const BUTTON_GAP = 3; const BUTTON_H = 18; const BUTTON_TOP = 10; const INFO_TOP = 30; const BTN_X = 80; const MIN_W = BTN_X + 6 * 56 + BUTTON_GAP * 5 + 6; const MIN_H = IMAGE_TOP + 100; const TOP_TRIM = 8; const SIZE_FONT = "12px 'Segoe UI',sans-serif"; const BADGE_FONT = "10px 'Segoe UI',sans-serif"; const BADGE_R = 8; const SIZE_RESERVE = 86; const SIZE_GAP = 6; const SLIDER_PAD = 72; function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } function loadCompareImage(node, meta, key) { const image = new Image(); image.crossOrigin = "anonymous"; image.onload = () => { node[key] = image; repaintNode(node); }; image.src = `/view?filename=${encodeURIComponent(meta.filename)}&type=${encodeURIComponent(meta.type)}&subfolder=${encodeURIComponent(meta.subfolder || "")}&t=${Date.now()}`; } function repaintNode(node) { node.setDirtyCanvas?.(true, true); node._cmpDomRender?.(); } function rowLayout(width) { const leftPad = isVueNodes() ? 12 : BTN_X; const rightPad = isVueNodes() ? 12 : 6; const buttonWidth = Math.max( 30, Math.floor((width - leftPad - rightPad - BUTTON_GAP * (MODE_LABELS.length - 1)) / MODE_LABELS.length), ); return { leftPad, rightPad, buttonWidth }; } function getButtons(node, width) { const layout = rowLayout(width); return MODE_LABELS.map((label, index) => ({ label, index, x: layout.leftPad + index * (layout.buttonWidth + BUTTON_GAP), y: BUTTON_TOP, w: layout.buttonWidth, h: BUTTON_H, active: node._cmpMode === index, })); } function getInfoRect(node, width) { const layout = rowLayout(width); const full = { x: layout.leftPad, y: INFO_TOP, w: layout.buttonWidth * MODE_LABELS.length + BUTTON_GAP * (MODE_LABELS.length - 1), h: BUTTON_H, }; const reserveLeft = node?._cmpImg1 ? SIZE_RESERVE + SIZE_GAP : 0; const reserveRight = node?._cmpImg2 ? SIZE_RESERVE + SIZE_GAP : 0; return { x: full.x + reserveLeft, y: full.y, w: Math.max(40, full.w - reserveLeft - reserveRight), h: full.h, }; } function getSliderRect(node, width) { const info = getInfoRect(node, width); return { x: info.x + SLIDER_PAD, y: info.y + info.h / 2 - 3, w: Math.max(20, info.w - SLIDER_PAD - 24), h: 6, }; } function getImageRect(width, height) { return { x: 6, y: IMAGE_TOP, w: Math.max(40, width - 12), h: Math.max(80, height - IMAGE_TOP - 6), }; } function pointInRect(x, y, rect) { return x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h; } function getAvailableImages(node) { const entries = []; if (node._cmpImg1) entries.push({ slot: 1, image: node._cmpImg1 }); if (node._cmpImg2) entries.push({ slot: 2, image: node._cmpImg2 }); return entries; } function getPrimaryImage(node) { return node._cmpImg1 || node._cmpImg2 || null; } function getSecondaryImage(node) { return node._cmpImg2 || node._cmpImg1 || null; } function fitImage(rect, image) { if (!image) return null; const scale = Math.min(rect.w / image.naturalWidth, rect.h / image.naturalHeight); const drawWidth = image.naturalWidth * scale; const drawHeight = image.naturalHeight * scale; return { x: rect.x + (rect.w - drawWidth) / 2, y: rect.y + (rect.h - drawHeight) / 2, w: drawWidth, h: drawHeight, }; } function drawButton(ctx, button, hovered) { ctx.save(); ctx.fillStyle = button.active ? BRAND : PANEL; ctx.strokeStyle = button.active ? BRAND : hovered ? BRAND : BORDER; ctx.lineWidth = 1; ctx.beginPath(); if (ctx.roundRect) ctx.roundRect(button.x, button.y, button.w, button.h, 4); else ctx.rect(button.x, button.y, button.w, button.h); ctx.fill(); ctx.stroke(); ctx.fillStyle = button.active ? "#ffffff" : hovered ? "#ebebeb" : MUTED; let fontSize = 9; ctx.font = `${fontSize}px 'Segoe UI',sans-serif`; while (fontSize > 7 && ctx.measureText(button.label).width > button.w - 8) { fontSize -= 0.5; ctx.font = `${fontSize}px 'Segoe UI',sans-serif`; } ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(button.label, button.x + button.w / 2, button.y + button.h / 2); ctx.restore(); } function dimsText(image) { return image ? `${image.naturalWidth}x${image.naturalHeight}` : ""; } function sizesDiffer(node) { return !!( node?._cmpImg1 && node?._cmpImg2 && (node._cmpImg1.naturalWidth !== node._cmpImg2.naturalWidth || node._cmpImg1.naturalHeight !== node._cmpImg2.naturalHeight) ); } function fillTextVCenter(ctx, text, x, yMid) { const metrics = ctx.measureText(text); if (metrics.actualBoundingBoxAscent != null && metrics.actualBoundingBoxDescent != null) { ctx.textBaseline = "alphabetic"; ctx.fillText(text, x, yMid + (metrics.actualBoundingBoxAscent - metrics.actualBoundingBoxDescent) / 2); return; } ctx.textBaseline = "middle"; ctx.fillText(text, x, yMid); } function drawSizeBadge(ctx, cx, cy, label) { ctx.save(); ctx.fillStyle = BRAND; ctx.beginPath(); ctx.arc(cx, cy, BADGE_R, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = "#ffffff"; ctx.font = BADGE_FONT; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(String(label), cx, cy + 0.5); ctx.restore(); } function drawSizeLabels(ctx, node, width) { if (!node._cmpImg1 && !node._cmpImg2) return; const layout = rowLayout(width); const infoFull = { x: layout.leftPad, y: INFO_TOP, w: layout.buttonWidth * MODE_LABELS.length + BUTTON_GAP * (MODE_LABELS.length - 1), h: BUTTON_H, }; const rightEdge = infoFull.x + infoFull.w; const yMid = infoFull.y + infoFull.h / 2; const textColor = sizesDiffer(node) ? BRAND : "#cfcfcf"; if (node._cmpImg1) { ctx.save(); ctx.beginPath(); ctx.rect(infoFull.x, infoFull.y, SIZE_RESERVE, infoFull.h); ctx.clip(); const cx = infoFull.x + BADGE_R; drawSizeBadge(ctx, cx, yMid, 1); ctx.font = SIZE_FONT; ctx.fillStyle = textColor; ctx.textAlign = "left"; fillTextVCenter(ctx, dimsText(node._cmpImg1), cx + BADGE_R + 5, yMid); ctx.restore(); } if (node._cmpImg2) { ctx.save(); ctx.beginPath(); ctx.rect(rightEdge - SIZE_RESERVE, infoFull.y, SIZE_RESERVE, infoFull.h); ctx.clip(); const cx = rightEdge - BADGE_R; drawSizeBadge(ctx, cx, yMid, 2); ctx.font = SIZE_FONT; ctx.fillStyle = textColor; ctx.textAlign = "right"; fillTextVCenter(ctx, dimsText(node._cmpImg2), cx - BADGE_R - 5, yMid); ctx.restore(); } } function drawInfoBar(ctx, node, width) { const info = getInfoRect(node, width); ctx.save(); ctx.fillStyle = PANEL; ctx.strokeStyle = BORDER; ctx.lineWidth = 1; ctx.beginPath(); if (ctx.roundRect) ctx.roundRect(info.x, info.y, info.w, info.h, 4); else ctx.rect(info.x, info.y, info.w, info.h); ctx.fill(); ctx.stroke(); const images = getAvailableImages(node); if (!images.length) { ctx.fillStyle = MUTED; ctx.font = "11px 'Segoe UI',sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText("Connect images and run to compare", info.x + info.w / 2, info.y + info.h / 2); ctx.restore(); return; } if (node._cmpMode === 4) { const slider = getSliderRect(node, width); ctx.fillStyle = TEXT; ctx.font = "11px 'Segoe UI',sans-serif"; ctx.textAlign = "left"; ctx.textBaseline = "middle"; ctx.fillText(`Opacity ${Math.round(node._cmpOpacity * 100)}%`, info.x + 10, info.y + info.h / 2); ctx.fillStyle = "#161719"; ctx.beginPath(); if (ctx.roundRect) ctx.roundRect(slider.x, slider.y, slider.w, slider.h, 3); else ctx.rect(slider.x, slider.y, slider.w, slider.h); ctx.fill(); ctx.fillStyle = BRAND; ctx.beginPath(); if (ctx.roundRect) ctx.roundRect(slider.x, slider.y, slider.w * node._cmpOpacity, slider.h, 3); else ctx.rect(slider.x, slider.y, slider.w * node._cmpOpacity, slider.h); ctx.fill(); const knobX = slider.x + slider.w * node._cmpOpacity; ctx.fillStyle = "#ffffff"; ctx.beginPath(); ctx.arc(knobX, slider.y + slider.h / 2, 7, 0, Math.PI * 2); ctx.fill(); ctx.restore(); return; } if (node._cmpMode >= 2) { const hint = MODE_HINTS[node._cmpMode]; if (hint) { ctx.fillStyle = TEXT; ctx.font = "11px 'Segoe UI',sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(hint, info.x + info.w / 2, info.y + info.h / 2); } } ctx.restore(); drawSizeLabels(ctx, node, width); } function drawEmptyState(ctx, rect) { ctx.save(); ctx.fillStyle = BG; ctx.fillRect(rect.x, rect.y, rect.w, rect.h); ctx.strokeStyle = BORDER; ctx.lineWidth = 1; ctx.strokeRect(rect.x, rect.y, rect.w, rect.h); ctx.fillStyle = MUTED; ctx.font = "13px sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText("No images loaded yet", rect.x + rect.w / 2, rect.y + rect.h / 2 - 10); ctx.font = "11px sans-serif"; ctx.fillText("Run the workflow after connecting one or two IMAGE inputs", rect.x + rect.w / 2, rect.y + rect.h / 2 + 12); ctx.restore(); } function drawSingleImage(ctx, rect, image) { const fitted = fitImage(rect, image); if (!fitted) return; ctx.drawImage(image, fitted.x, fitted.y, fitted.w, fitted.h); } function drawCompare(ctx, node, rect) { const image1 = getPrimaryImage(node); const image2 = getSecondaryImage(node); if (!image1 && !image2) { drawEmptyState(ctx, rect); return; } ctx.save(); ctx.fillStyle = BG; ctx.fillRect(rect.x, rect.y, rect.w, rect.h); ctx.strokeStyle = BORDER; ctx.lineWidth = 1; ctx.strokeRect(rect.x, rect.y, rect.w, rect.h); if (!image1 || !image2 || node._cmpMode === 0 || node._cmpMode === 1) { const chosenImage = node._cmpMode === 0 ? node._cmpImg1 || node._cmpImg2 : node._cmpImg2 || node._cmpImg1; drawSingleImage(ctx, rect, chosenImage || image1 || image2); ctx.restore(); return; } const first = fitImage(rect, image1); const second = fitImage(rect, image2); if (!first || !second) { ctx.restore(); return; } if (node._cmpMode === 2) { ctx.save(); ctx.beginPath(); ctx.rect(rect.x, rect.y, rect.w * node._cmpSplitX, rect.h); ctx.clip(); ctx.drawImage(image1, first.x, first.y, first.w, first.h); ctx.restore(); ctx.save(); ctx.beginPath(); ctx.rect(rect.x + rect.w * node._cmpSplitX, rect.y, rect.w * (1 - node._cmpSplitX), rect.h); ctx.clip(); ctx.drawImage(image2, second.x, second.y, second.w, second.h); ctx.restore(); const guideX = rect.x + rect.w * node._cmpSplitX; ctx.strokeStyle = "rgba(255,255,255,0.5)"; ctx.beginPath(); ctx.moveTo(guideX, rect.y); ctx.lineTo(guideX, rect.y + rect.h); ctx.stroke(); ctx.restore(); return; } if (node._cmpMode === 3) { ctx.save(); ctx.beginPath(); ctx.rect(rect.x, rect.y, rect.w, rect.h * node._cmpSplitY); ctx.clip(); ctx.drawImage(image1, first.x, first.y, first.w, first.h); ctx.restore(); ctx.save(); ctx.beginPath(); ctx.rect(rect.x, rect.y + rect.h * node._cmpSplitY, rect.w, rect.h * (1 - node._cmpSplitY)); ctx.clip(); ctx.drawImage(image2, second.x, second.y, second.w, second.h); ctx.restore(); const guideY = rect.y + rect.h * node._cmpSplitY; ctx.strokeStyle = "rgba(255,255,255,0.5)"; ctx.beginPath(); ctx.moveTo(rect.x, guideY); ctx.lineTo(rect.x + rect.w, guideY); ctx.stroke(); ctx.restore(); return; } if (node._cmpMode === 4) { ctx.drawImage(image1, first.x, first.y, first.w, first.h); ctx.globalAlpha = node._cmpOpacity; ctx.drawImage(image2, second.x, second.y, second.w, second.h); ctx.globalAlpha = 1; ctx.restore(); return; } ctx.drawImage(image1, first.x, first.y, first.w, first.h); ctx.globalCompositeOperation = "difference"; ctx.drawImage(image2, second.x, second.y, second.w, second.h); ctx.globalCompositeOperation = "source-over"; ctx.restore(); } function paintNode(ctx, node, width, height, mouse) { const buttons = getButtons(node, width); for (const button of buttons) { drawButton(ctx, button, mouse ? pointInRect(mouse.x, mouse.y, button) : false); } drawInfoBar(ctx, node, width); drawCompare(ctx, node, getImageRect(width, height)); if (node._cmpMode === 2 || node._cmpMode === 3) { ctx.save(); ctx.fillStyle = MUTED; ctx.font = "11px 'Segoe UI',sans-serif"; ctx.textAlign = "right"; ctx.textBaseline = "bottom"; ctx.fillText("Move over the image to slide", width - 8, height - 4); ctx.restore(); } } function updateImageInteraction(node, x, y, width, height) { const rect = getImageRect(width, height); if (!pointInRect(x, y, rect)) return false; if (node._cmpMode === 2) { node._cmpSplitX = clamp((x - rect.x) / rect.w, 0, 1); return true; } if (node._cmpMode === 3) { node._cmpSplitY = clamp((y - rect.y) / rect.h, 0, 1); return true; } return false; } function updateOpacityFromX(node, x, width) { const slider = getSliderRect(node, width); node._cmpOpacity = clamp((x - slider.x) / slider.w, 0, 1); } function getCursor(node, x, y, width, height) { const buttons = getButtons(node, width); if (buttons.some((button) => pointInRect(x, y, button))) return "pointer"; if (node._cmpMode === 4) { const slider = getSliderRect(node, width); if (pointInRect(x, y, { x: slider.x - 10, y: slider.y - 10, w: slider.w + 20, h: slider.h + 20, })) { return "pointer"; } } const rect = getImageRect(width, height); if (pointInRect(x, y, rect)) { if (node._cmpMode === 2) return "ew-resize"; if (node._cmpMode === 3) return "ns-resize"; } return "default"; } function pointerDown(node, x, y, width, height) { const buttons = getButtons(node, width); for (const button of buttons) { if (pointInRect(x, y, button)) { node._cmpMode = button.index; repaintNode(node); return true; } } const slider = getSliderRect(node, width); if (node._cmpMode === 4 && pointInRect(x, y, { x: slider.x - 10, y: slider.y - 10, w: slider.w + 20, h: slider.h + 20, })) { node._cmpDraggingOpacity = true; updateOpacityFromX(node, x, width); repaintNode(node); return true; } if (updateImageInteraction(node, x, y, width, height)) { repaintNode(node); return true; } return false; } function pointerMove(node, x, y, width, height) { let changed = false; if (node._cmpDraggingOpacity && node._cmpMode === 4) { updateOpacityFromX(node, x, width); changed = true; } else if (updateImageInteraction(node, x, y, width, height)) { changed = true; } node._cmpMouse = { x, y }; if (changed) repaintNode(node); } function pointerUp(node) { node._cmpDraggingOpacity = false; } function createDomWidget(node) { const root = document.createElement("div"); root.style.cssText = "position:relative;width:100%;flex:1 1 0;min-height:0;box-sizing:border-box;cursor:default;"; 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("dumas_compare", "dumas_compare", root, { serialize: false, hideOnZoom: false, getMinHeight: () => MIN_H, }); widget.computeLayoutSize = () => ({ minHeight: MIN_H, minWidth: 1 }); applyAdaptiveCanvasOnly(widget); const render = () => { const cssWidth = root.clientWidth; const cssHeight = root.clientHeight; if (cssWidth <= 0 || cssHeight <= 0) return; const scale = (window.devicePixelRatio || 1) * Math.max(1, app.canvas?.ds?.scale || 1); const width = Math.round(cssWidth * scale); const height = Math.round(cssHeight * scale); if (canvas.width !== width) canvas.width = width; if (canvas.height !== height) canvas.height = height; const ctx = canvas.getContext("2d"); ctx.setTransform(scale, 0, 0, scale, 0, 0); ctx.clearRect(0, 0, cssWidth, cssHeight); ctx.translate(0, -TOP_TRIM); paintNode(ctx, node, cssWidth, cssHeight + TOP_TRIM, node._cmpMouse || null); }; node._cmpDomRender = render; const localPos = (event) => { const rect = root.getBoundingClientRect(); const scaleX = rect.width ? root.clientWidth / rect.width : 1; const scaleY = rect.height ? root.clientHeight / rect.height : 1; return [ (event.clientX - rect.left) * scaleX, (event.clientY - rect.top) * scaleY + TOP_TRIM, ]; }; root.addEventListener("pointerdown", (event) => { const [x, y] = localPos(event); root.style.cursor = getCursor(node, x, y, root.clientWidth, root.clientHeight + TOP_TRIM); if (pointerDown(node, x, y, root.clientWidth, root.clientHeight + TOP_TRIM)) { event.stopPropagation(); if (node._cmpDraggingOpacity) { try { root.setPointerCapture(event.pointerId); } catch (_error) { // Ignore capture failures and keep the node functional. } } render(); } }); root.addEventListener("pointermove", (event) => { const [x, y] = localPos(event); pointerMove(node, x, y, root.clientWidth, root.clientHeight + TOP_TRIM); root.style.cursor = getCursor(node, x, y, root.clientWidth, root.clientHeight + TOP_TRIM); render(); }); root.addEventListener("pointerup", (event) => { pointerUp(node); const [x, y] = localPos(event); root.style.cursor = getCursor(node, x, y, root.clientWidth, root.clientHeight + TOP_TRIM); try { root.releasePointerCapture(event.pointerId); } catch (_error) { // Ignore release failures. } render(); }); root.addEventListener("pointerleave", () => { pointerUp(node); node._cmpMouse = null; root.style.cursor = "default"; render(); }); const resizeObserver = new ResizeObserver(() => render()); resizeObserver.observe(root); node._cmpDomResizeObserver = resizeObserver; requestAnimationFrame(render); } app.registerExtension({ name: "Dumas.Compare", async beforeRegisterNodeDef(nodeType, nodeData) { if (nodeData.name !== NODE_NAME) return; const originalNodeCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { originalNodeCreated?.apply(this, arguments); this._cmpMode = 1; this._cmpOpacity = 0.5; this._cmpSplitX = 0.5; this._cmpSplitY = 0.5; this._cmpImg1 = null; this._cmpImg2 = null; this._cmpDraggingOpacity = false; this._cmpMouse = null; this.hideOutputImages = true; this.size[0] = Math.max(this.size[0] || 0, INIT_W); this.size[1] = Math.max(this.size[1] || 0, INIT_H); if (isVueNodes()) createDomWidget(this); }; nodeType.prototype.onExecuted = function (output) { this.imgs = null; const images = Array.isArray(output?.images) ? output.images : []; let slot1 = null; let slot2 = null; for (const image of images) { if (image?.slot === 1) slot1 = image; if (image?.slot === 2) slot2 = image; } if (!slot1 && !slot2 && images.length) { slot1 = images[0] || null; slot2 = images[1] || null; } if (slot1) loadCompareImage(this, slot1, "_cmpImg1"); else this._cmpImg1 = null; if (slot2) loadCompareImage(this, slot2, "_cmpImg2"); else this._cmpImg2 = null; repaintNode(this); }; nodeType.prototype.onDrawBackground = function () { if (this.flags?.collapsed) return; this.imgs = null; }; const originalDrawForeground = nodeType.prototype.onDrawForeground; nodeType.prototype.onDrawForeground = function (ctx) { originalDrawForeground?.call(this, ctx); if (this.flags?.collapsed || isVueNodes()) return; this.size[0] = Math.max(this.size[0], MIN_W); this.size[1] = Math.max(this.size[1], MIN_H); const graphMouse = app.canvas?.graph_mouse; const mouse = graphMouse ? { x: graphMouse[0] - this.pos[0], y: graphMouse[1] - this.pos[1] } : null; paintNode(ctx, this, this.size[0], this.size[1], mouse); }; const originalMouseDown = nodeType.prototype.onMouseDown; nodeType.prototype.onMouseDown = function (event, pos) { if (!isVueNodes() && pointerDown(this, pos[0], pos[1], this.size[0], this.size[1])) { repaintNode(this); return true; } return originalMouseDown?.call(this, event, pos); }; const originalMouseMove = nodeType.prototype.onMouseMove; nodeType.prototype.onMouseMove = function (event, pos) { if (!isVueNodes()) pointerMove(this, pos[0], pos[1], this.size[0], this.size[1]); return originalMouseMove?.call(this, event, pos); }; const originalMouseUp = nodeType.prototype.onMouseUp; nodeType.prototype.onMouseUp = function (event, pos) { if (!isVueNodes()) { pointerUp(this); repaintNode(this); } return originalMouseUp?.call(this, event, pos); }; const originalMouseLeave = nodeType.prototype.onMouseLeave; nodeType.prototype.onMouseLeave = function (event) { if (!isVueNodes()) { pointerUp(this); this._cmpMouse = null; repaintNode(this); } return originalMouseLeave?.call(this, event); }; const originalRemoved = nodeType.prototype.onRemoved; nodeType.prototype.onRemoved = function () { try { this._cmpDomResizeObserver?.disconnect(); } catch (_error) { // Ignore cleanup failures. } this._cmpDomResizeObserver = null; this._cmpDomRender = null; return originalRemoved?.apply(this, arguments); }; }, });