From d1519c047712f7a6ab7ae6ae595cdea548c5bb64 Mon Sep 17 00:00:00 2001 From: AI Lab <129358391+1038lab@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:44:06 -0700 Subject: [PATCH] Add files via upload --- web/js/appearance.js | 1 + web/js/colorWidget.js | 346 +++++++++++++++- web/js/imageCompareView.js | 798 +++++++++++++++++++++++++++++++++++++ 3 files changed, 1124 insertions(+), 21 deletions(-) create mode 100644 web/js/imageCompareView.js diff --git a/web/js/appearance.js b/web/js/appearance.js index 8e0adf5..594eb86 100644 --- a/web/js/appearance.js +++ b/web/js/appearance.js @@ -52,6 +52,7 @@ const NODE_COLORS = { "AILab_MaskToList": "utility", "AILab_ImageMaskToList": "utility", "AILab_ColorToMask": "utility", + "AILab_Resolution_Selector": "utility", }; function setNodeColors(node, theme) { diff --git a/web/js/colorWidget.js b/web/js/colorWidget.js index 707d5a7..4f59156 100644 --- a/web/js/colorWidget.js +++ b/web/js/colorWidget.js @@ -22,13 +22,327 @@ const getContrastTextColor = (hexColor) => { return luminance > 0.5 ? '#333333' : '#cccccc'; }; +// ── Color Conversion Helpers ────────────────────────────────────────── + +function hexToRGB(hex) { + hex = hex.replace('#', ''); + return { + r: parseInt(hex.substr(0, 2), 16), + g: parseInt(hex.substr(2, 2), 16), + b: parseInt(hex.substr(4, 2), 16), + }; +} + +function rgbToHex(r, g, b) { + return '#' + [r, g, b].map(v => Math.round(v).toString(16).padStart(2, '0')).join(''); +} + +function rgbToHSV(r, g, b) { + r /= 255; g /= 255; b /= 255; + const max = Math.max(r, g, b), min = Math.min(r, g, b); + const d = max - min; + let h = 0, s = max === 0 ? 0 : d / max, v = max; + if (d !== 0) { + switch (max) { + case r: h = ((g - b) / d + 6) % 6; break; + case g: h = (b - r) / d + 2; break; + case b: h = (r - g) / d + 4; break; + } + h *= 60; + } + return { h, s, v }; +} + +function hsvToRGB(h, s, v) { + h = ((h % 360) + 360) % 360; + const c = v * s; + const x = c * (1 - Math.abs((h / 60) % 2 - 1)); + const m = v - c; + let r, g, b; + if (h < 60) { r = c; g = x; b = 0; } + else if (h < 120) { r = x; g = c; b = 0; } + else if (h < 180) { r = 0; g = c; b = x; } + else if (h < 240) { r = 0; g = x; b = c; } + else if (h < 300) { r = x; g = 0; b = c; } + else { r = c; g = 0; b = x; } + return { + r: Math.round((r + m) * 255), + g: Math.round((g + m) * 255), + b: Math.round((b + m) * 255), + }; +} + +function hexToHSV(hex) { + const { r, g, b } = hexToRGB(hex); + return rgbToHSV(r, g, b); +} + +function hsvToHex(h, s, v) { + const { r, g, b } = hsvToRGB(h, s, v); + return rgbToHex(r, g, b); +} + +// ── Custom Color Picker ─────────────────────────────────────────────── + +function openColorPicker(initialColor, clientX, clientY, onApply) { + // Close any existing picker + const existing = document.querySelector('.ailab-cp-backdrop'); + if (existing) existing.remove(); + const existingPanel = document.querySelector('.ailab-cp-panel'); + if (existingPanel) existingPanel.remove(); + + const SV_SIZE = 180; + const HUE_H = 14; + const PAD = 12; + const GAP = 8; + + let hsv = hexToHSV(initialColor); + + // ── Create DOM elements ── + + const backdrop = document.createElement('div'); + backdrop.className = 'ailab-cp-backdrop'; + Object.assign(backdrop.style, { + position: 'fixed', top: '0', left: '0', + width: '100%', height: '100%', zIndex: '99998', + }); + + const panel = document.createElement('div'); + panel.className = 'ailab-cp-panel'; + Object.assign(panel.style, { + position: 'fixed', zIndex: '99999', + background: '#1e1e1e', border: '1px solid #444', + borderRadius: '8px', padding: `${PAD}px`, + boxShadow: '0 8px 32px rgba(0,0,0,0.6)', + fontFamily: 'sans-serif', fontSize: '12px', color: '#ccc', + userSelect: 'none', + }); + + // SV (saturation-value) canvas + const svCanvas = document.createElement('canvas'); + svCanvas.width = SV_SIZE; + svCanvas.height = SV_SIZE; + Object.assign(svCanvas.style, { + display: 'block', borderRadius: '4px', cursor: 'crosshair', + marginBottom: `${GAP}px`, + }); + + // Hue bar canvas + const hueCanvas = document.createElement('canvas'); + hueCanvas.width = SV_SIZE; + hueCanvas.height = HUE_H; + Object.assign(hueCanvas.style, { + display: 'block', borderRadius: '8px', cursor: 'pointer', + marginBottom: `${GAP}px`, + }); + + // Bottom row: preview swatch + hex input + OK button + const row = document.createElement('div'); + Object.assign(row.style, { + display: 'flex', alignItems: 'center', gap: `${GAP}px`, + }); + + const preview = document.createElement('div'); + Object.assign(preview.style, { + width: '28px', height: '28px', borderRadius: '4px', + border: '1px solid #555', flexShrink: '0', + }); + + const hexInput = document.createElement('input'); + hexInput.type = 'text'; + Object.assign(hexInput.style, { + flex: '1', background: '#333', border: '1px solid #555', + borderRadius: '4px', color: '#eee', padding: '4px 8px', + fontFamily: 'monospace', fontSize: '13px', outline: 'none', + width: '80px', minWidth: '0', + }); + + const okBtn = document.createElement('button'); + okBtn.textContent = 'OK'; + Object.assign(okBtn.style, { + background: '#4a9eff', color: '#fff', border: 'none', + borderRadius: '4px', padding: '5px 14px', cursor: 'pointer', + fontSize: '12px', fontWeight: 'bold', + }); + + row.append(preview, hexInput, okBtn); + panel.append(svCanvas, hueCanvas, row); + + // ── Position panel below click point, clamped to viewport ── + + const panelW = SV_SIZE + PAD * 2; + const panelH = SV_SIZE + HUE_H + 28 + GAP * 2 + PAD * 2 + 4; + + let px = clientX - panelW / 2; + let py = clientY + 10; + + if (px + panelW > window.innerWidth - 10) px = window.innerWidth - panelW - 10; + if (px < 10) px = 10; + if (py + panelH > window.innerHeight - 10) py = clientY - panelH - 10; + if (py < 10) py = 10; + + panel.style.left = `${px}px`; + panel.style.top = `${py}px`; + + // ── Drawing functions ── + + function drawSV() { + const ctx = svCanvas.getContext('2d'); + const { r, g, b } = hsvToRGB(hsv.h, 1, 1); + + // Base hue fill + ctx.fillStyle = `rgb(${r},${g},${b})`; + ctx.fillRect(0, 0, SV_SIZE, SV_SIZE); + + // White gradient (left → right = low → high saturation) + const whiteGrad = ctx.createLinearGradient(0, 0, SV_SIZE, 0); + whiteGrad.addColorStop(0, '#ffffff'); + whiteGrad.addColorStop(1, 'rgba(255,255,255,0)'); + ctx.fillStyle = whiteGrad; + ctx.fillRect(0, 0, SV_SIZE, SV_SIZE); + + // Black gradient (top → bottom = high → low value) + const blackGrad = ctx.createLinearGradient(0, 0, 0, SV_SIZE); + blackGrad.addColorStop(0, 'rgba(0,0,0,0)'); + blackGrad.addColorStop(1, '#000000'); + ctx.fillStyle = blackGrad; + ctx.fillRect(0, 0, SV_SIZE, SV_SIZE); + + // Crosshair cursor + const cx = hsv.s * SV_SIZE; + const cy = (1 - hsv.v) * SV_SIZE; + ctx.beginPath(); + ctx.arc(cx, cy, 6, 0, Math.PI * 2); + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 2; + ctx.stroke(); + ctx.beginPath(); + ctx.arc(cx, cy, 5, 0, Math.PI * 2); + ctx.strokeStyle = '#000000'; + ctx.lineWidth = 1; + ctx.stroke(); + } + + function drawHue() { + const ctx = hueCanvas.getContext('2d'); + const grad = ctx.createLinearGradient(0, 0, SV_SIZE, 0); + [0, 60, 120, 180, 240, 300, 360].forEach(deg => { + const { r, g, b } = hsvToRGB(deg, 1, 1); + grad.addColorStop(deg / 360, `rgb(${r},${g},${b})`); + }); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, SV_SIZE, HUE_H); + + // Indicator thumb + const ix = (hsv.h / 360) * SV_SIZE; + ctx.fillStyle = '#ffffff'; + ctx.fillRect(ix - 2, 0, 4, HUE_H); + ctx.strokeStyle = '#333333'; + ctx.lineWidth = 1; + ctx.strokeRect(ix - 2, 0, 4, HUE_H); + } + + function updateAll() { + const hex = hsvToHex(hsv.h, hsv.s, hsv.v); + preview.style.backgroundColor = hex; + hexInput.value = hex; + drawSV(); + drawHue(); + } + + // ── Interaction handlers ── + + let svDrag = false, hueDrag = false; + + function onSV(e) { + const rect = svCanvas.getBoundingClientRect(); + hsv.s = Math.max(0, Math.min(1, (e.clientX - rect.left) / SV_SIZE)); + hsv.v = Math.max(0, Math.min(1, 1 - (e.clientY - rect.top) / SV_SIZE)); + updateAll(); + } + + function onHue(e) { + const rect = hueCanvas.getBoundingClientRect(); + hsv.h = Math.max(0, Math.min(360, ((e.clientX - rect.left) / SV_SIZE) * 360)); + updateAll(); + } + + function onPointerMove(e) { + if (svDrag) onSV(e); + if (hueDrag) onHue(e); + } + + function onPointerUp() { + svDrag = false; + hueDrag = false; + } + + svCanvas.addEventListener('pointerdown', (e) => { + svDrag = true; onSV(e); + e.preventDefault(); e.stopPropagation(); + }); + hueCanvas.addEventListener('pointerdown', (e) => { + hueDrag = true; onHue(e); + e.preventDefault(); e.stopPropagation(); + }); + + document.addEventListener('pointermove', onPointerMove); + document.addEventListener('pointerup', onPointerUp); + + // Hex input: live update on valid input + hexInput.addEventListener('input', () => { + const val = hexInput.value.trim(); + if (/^#?[0-9a-fA-F]{6}$/.test(val)) { + const hex = val.startsWith('#') ? val : '#' + val; + hsv = hexToHSV(hex); + preview.style.backgroundColor = hex; + drawSV(); + drawHue(); + } + }); + + // Prevent keystrokes from reaching ComfyUI canvas + hexInput.addEventListener('keydown', (e) => e.stopPropagation()); + + // Prevent panel clicks from reaching backdrop + panel.addEventListener('pointerdown', (e) => e.stopPropagation()); + + // ── Close / Apply ── + + function close() { + document.removeEventListener('pointermove', onPointerMove); + document.removeEventListener('pointerup', onPointerUp); + backdrop.remove(); + panel.remove(); + } + + backdrop.addEventListener('pointerdown', (e) => { + e.preventDefault(); e.stopPropagation(); + onApply(hsvToHex(hsv.h, hsv.s, hsv.v)); + close(); + }); + + okBtn.addEventListener('click', (e) => { + e.stopPropagation(); + onApply(hsvToHex(hsv.h, hsv.s, hsv.v)); + close(); + }); + + // ── Mount & render ── + + document.body.append(backdrop, panel); + updateAll(); +} + +// ── ComfyUI Widget Definition ───────────────────────────────────────── + const AILabColorWidget = { COLORCODE: (key, val) => { const widget = {}; widget.y = 0; widget.name = key; widget.type = 'COLORCODE'; - + const defaultColor = '#222222'; widget.options = { default: defaultColor }; @@ -50,6 +364,7 @@ const AILabColorWidget = { return; } + const actualWidth = node.size[0]; const drawHeight = 22; const margin = 15; const radius = 10; @@ -58,7 +373,7 @@ const AILabColorWidget = { ctx.beginPath(); const x = margin; const y = widgetY + (height - drawHeight) / 2; - const w = widgetWidth - margin * 2; + const w = actualWidth - margin * 2; const h = drawHeight; ctx.moveTo(x + radius, y); ctx.lineTo(x + w - radius, y); @@ -81,7 +396,7 @@ const AILabColorWidget = { ctx.textAlign = 'center'; const text = `${this.name} (${this.value})`; - ctx.fillText(text, widgetWidth * 0.5, y + drawHeight * 0.65); + ctx.fillText(text, actualWidth * 0.5, y + drawHeight * 0.65); }; widget.mouse = function (e, pos, node) { @@ -89,32 +404,21 @@ const AILabColorWidget = { const margin = 15; if (pos[0] >= margin && pos[0] <= node.size[0] - margin) { - const picker = document.createElement('input'); - picker.type = 'color'; - picker.value = this.value; - - picker.style.position = 'absolute'; - picker.style.left = '-9999px'; - picker.style.top = '-9999px'; - - document.body.appendChild(picker); - - picker.addEventListener('change', () => { - this.value = picker.value; + const widgetRef = this; + openColorPicker(this.value, e.clientX, e.clientY, (newColor) => { + widgetRef.value = newColor; + widgetRef.callback?.(newColor); node.graph._version++; node.setDirtyCanvas(true, true); - picker.remove(); }); - - picker.click(); return true; } } return false; }; - widget.computeSize = function (width) { - return [width, 22]; + widget.computeSize = function () { + return [50, 22]; }; return widget; @@ -129,7 +433,7 @@ app.registerExtension({ COLORCODE: (node, inputName, inputData) => { return { widget: node.addCustomWidget( - AILabColorWidget.COLORCODE(inputName, inputData) + AILabColorWidget.COLORCODE(inputName, inputData) ), minWidth: 150, minHeight: 22, diff --git a/web/js/imageCompareView.js b/web/js/imageCompareView.js new file mode 100644 index 0000000..8f6fdcd --- /dev/null +++ b/web/js/imageCompareView.js @@ -0,0 +1,798 @@ +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 + 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; + }; + }, +});