From 5a443746ce242109650d14a2dd92730e84f4152a Mon Sep 17 00:00:00 2001 From: Chris Dumas Date: Tue, 28 Jul 2026 06:31:13 +0000 Subject: [PATCH] Add compare node frontend viewer --- README.md | 1 + __init__.py | 4 +- js/compare/index.js | 593 +++++++++++++++++++++++++++++++++++++++++++ js/shared/nodes2.mjs | 19 ++ 4 files changed, 616 insertions(+), 1 deletion(-) create mode 100644 js/compare/index.js create mode 100644 js/shared/nodes2.mjs diff --git a/README.md b/README.md index 945f5b8..9d7c8cd 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ - Inputs: optional `image1`, optional `image2` - Outputs: `new image` - Saves preview images for the built-in compare UI and passes through the second image when present, otherwise the first. + - Bundles a `js/` frontend extension so the compare viewer renders inside both classic ComfyUI and Nodes 2.0. - `Dumas JSON String to Object` - Input: `json_string` diff --git a/__init__.py b/__init__.py index 5303dc0..5cd4c80 100644 --- a/__init__.py +++ b/__init__.py @@ -15,4 +15,6 @@ NODE_DISPLAY_NAME_MAPPINGS = {} NODE_DISPLAY_NAME_MAPPINGS.update(JSON_NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(IMAGE_NODE_DISPLAY_NAME_MAPPINGS) -__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] +WEB_DIRECTORY = "./js" + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] diff --git a/js/compare/index.js b/js/compare/index.js new file mode 100644 index 0000000..fa7a2b1 --- /dev/null +++ b/js/compare/index.js @@ -0,0 +1,593 @@ +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 TITLE = "#f3f3f3"; + +const MODE_LABELS = ["Show 1", "Show 2", "Left Right", "Up Down", "Overlay", "Difference"]; +const INIT_W = 420; +const INIT_H = 474; +const MIN_W = 360; +const MIN_H = 250; +const BUTTON_GAP = 4; +const BUTTON_H = 22; +const BUTTON_TOP = 10; +const INFO_TOP = 38; +const IMAGE_TOP = 64; +const OUTER_PAD = 12; +const TOP_TRIM = 8; + +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 getButtons(node, width) { + const availableWidth = width - OUTER_PAD * 2 - BUTTON_GAP * (MODE_LABELS.length - 1); + const buttonWidth = Math.max(44, Math.floor(availableWidth / MODE_LABELS.length)); + return MODE_LABELS.map((label, index) => ({ + label, + index, + x: OUTER_PAD + index * (buttonWidth + BUTTON_GAP), + y: BUTTON_TOP, + w: buttonWidth, + h: BUTTON_H, + active: node._cmpMode === index, + })); +} + +function getInfoRect(width) { + return { + x: OUTER_PAD, + y: INFO_TOP, + w: width - OUTER_PAD * 2, + h: BUTTON_H, + }; +} + +function getSliderRect(width) { + const info = getInfoRect(width); + const trackPad = 88; + return { + x: info.x + trackPad, + y: info.y + 8, + w: Math.max(40, info.w - trackPad - 8), + h: 6, + }; +} + +function getImageRect(width, height) { + return { + x: OUTER_PAD, + y: IMAGE_TOP, + w: Math.max(40, width - OUTER_PAD * 2), + h: Math.max(80, height - IMAGE_TOP - OUTER_PAD), + }; +} + +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; + ctx.font = "10px 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 drawInfoBar(ctx, node, width) { + const info = getInfoRect(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 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(width); + ctx.fillStyle = TEXT; + ctx.font = "11px 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; + } + + const parts = images.map(({ slot, image }) => `${slot}: ${image.naturalWidth}x${image.naturalHeight}`); + ctx.fillStyle = TEXT; + ctx.font = "11px sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(parts.join(" "), info.x + info.w / 2, info.y + info.h / 2); + ctx.restore(); +} + +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 sans-serif"; + ctx.textAlign = "right"; + ctx.textBaseline = "bottom"; + ctx.fillText("Move over the image to slide", width - OUTER_PAD, 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(width); + node._cmpOpacity = clamp((x - slider.x) / slider.w, 0, 1); +} + +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; + } + } + + if (node._cmpMode === 4 && pointInRect(x, y, { + x: getSliderRect(width).x - 10, + y: getSliderRect(width).y - 10, + w: getSliderRect(width).w + 20, + h: getSliderRect(width).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); + 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); + render(); + }); + + root.addEventListener("pointerup", (event) => { + pointerUp(node); + try { + root.releasePointerCapture(event.pointerId); + } catch (_error) { + // Ignore release failures. + } + render(); + }); + + root.addEventListener("pointerleave", () => { + pointerUp(node); + node._cmpMouse = null; + 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); + }; + }, +}); diff --git a/js/shared/nodes2.mjs b/js/shared/nodes2.mjs new file mode 100644 index 0000000..e06c4aa --- /dev/null +++ b/js/shared/nodes2.mjs @@ -0,0 +1,19 @@ +export function isVueNodes() { + return !!window.LiteGraph?.vueNodesMode; +} + +export function applyAdaptiveCanvasOnly(widget) { + if (!widget || !widget.options) return widget; + try { + Object.defineProperty(widget.options, "canvasOnly", { + configurable: true, + enumerable: true, + get() { + return !window.LiteGraph?.vueNodesMode; + }, + }); + } catch (_error) { + widget.options.canvasOnly = !window.LiteGraph?.vueNodesMode; + } + return widget; +}