/** * ImageOps preview bridge — minimal, canvas-only. * * majoor-imageops keeps a rendered preview canvas at * `node.__imageops_state.canvas`. We stream that canvas to the MFV after * cropping out the letterbox/guide area so the displayed content fills the * viewer (no square scope, no dashed guides outside the image). * * Crop math: ImageOps draws the source `previewSourceWidth x previewSourceHeight` * into a square `canvasSize x canvasSize` via a centered contain-fit. We * recompute the same fit and copy that sub-rect into a temp canvas before * encoding. When the user has panned/zoomed inside ImageOps, the content * overflows that rect — we fall back to streaming the full canvas in that * case to avoid clipping the scene. * * The encoded data-URL is cached per signature so we re-encode only when * the canvas (or crop rect) actually changes. */ const IMAGEOPS_STATE_KEY = "__imageops_state"; const IMAGEOPS_SOURCE = "imageops-live-preview"; function _isCanvas(value: any) { return typeof HTMLCanvasElement !== "undefined" && value instanceof HTMLCanvasElement; } function _canvasUsable(canvas: any) { return _isCanvas(canvas) && Number(canvas.width) > 0 && Number(canvas.height) > 0; } function _hashText(value: any) { let hash = 2166136261; const text = String(value || ""); for (let index = 0; index < text.length; index += 1) { hash ^= text.charCodeAt(index); hash = Math.imul(hash, 16777619); } return (hash >>> 0).toString(16); } /** * Compute the contain-fit sub-rect of the source image inside the square * preview canvas. Returns null when crop should be skipped (no source dims, * user pan/zoom active, or the fit equals the full canvas). */ function _computeContentRect(st: any, canvas: any) { const sw = Number(st?.previewSourceWidth) || 0; const sh = Number(st?.previewSourceHeight) || 0; const cw = Number(canvas?.width) || 0; const ch = Number(canvas?.height) || 0; if (sw <= 0 || sh <= 0 || cw <= 0 || ch <= 0) return null; // ImageOps applies the user's pan/zoom inside its own canvas. Cropping // the static fit rect would clip the panned content — keep the full // canvas in that case. const zoom = Number(st?.previewZoom); const panX = Number(st?.previewPanX) || 0; const panY = Number(st?.previewPanY) || 0; if ((Number.isFinite(zoom) && Math.abs(zoom - 1) > 1e-3) || panX !== 0 || panY !== 0) { return null; } const scale = Math.min(cw / sw, ch / sh); const drawW = Math.max(1, Math.round(sw * scale)); const drawH = Math.max(1, Math.round(sh * scale)); const dx = Math.max(0, Math.round((cw - drawW) / 2)); const dy = Math.max(0, Math.round((ch - drawH) / 2)); // No letterbox ? nothing to crop, skip the temp-canvas detour. if (dx === 0 && dy === 0 && drawW === cw && drawH === ch) return null; return { dx, dy, w: drawW, h: drawH }; } function _signatureFor(node: any, st: any, canvas: any, crop: any) { const cropPart = crop ? `${crop.dx},${crop.dy},${crop.w}x${crop.h}` : "full"; const parts = [ String(node?.id ?? ""), String(st?.lastKey ?? ""), String(st?.lastRenderTick ?? ""), String(st?.nativeDirty ? 1 : 0), `${Number(canvas?.width) || 0}x${Number(canvas?.height) || 0}`, cropPart, ]; return _hashText(parts.join("|")); } function _encodeCropped(canvas: any, crop: any) { const tmp = document.createElement("canvas"); tmp.width = crop.w; tmp.height = crop.h; const ctx = tmp.getContext("2d"); if (!ctx) return ""; ctx.drawImage(canvas, crop.dx, crop.dy, crop.w, crop.h, 0, 0, crop.w, crop.h); return tmp.toDataURL("image/png"); } // WeakMap caches keep ImageOps nodes garbage-collectable. We reuse the last // encoded URL whenever the signature is unchanged — avoids re-encoding the // full canvas on every poll tick. const _lastSigByNode = new WeakMap(); const _lastUrlByNode = new WeakMap(); export function extractImageOpsPreview(node: any): any { if (!node) return null; const st = node[IMAGEOPS_STATE_KEY]; const canvas = st?.canvas; if (!_canvasUsable(canvas)) return null; const crop = _computeContentRect(st, canvas); const signature = _signatureFor(node, st, canvas, crop); let url = _lastSigByNode.get(node) === signature ? _lastUrlByNode.get(node) : ""; if (!url) { try { url = crop ? _encodeCropped(canvas, crop) : canvas.toDataURL("image/png"); } catch (error: any) { // Tainted canvas (cross-origin). Warn so the cause is visible. console.warn("[NodeStream] ImageOps canvas export failed:", error); return null; } if (!url) return null; _lastSigByNode.set(node, signature); _lastUrlByNode.set(node, url); } const outW = crop ? crop.w : Number(canvas.width) || undefined; const outH = crop ? crop.h : Number(canvas.height) || undefined; const classType = node.comfyClass || node.type || "ImageOps"; return { filename: `imageops_${node.id ?? "node"}_${signature}.png`, subfolder: "", type: "temp", kind: "image", url, width: outW, height: outH, _nodeId: String(node.id ?? ""), _classType: classType, _source: IMAGEOPS_SOURCE, _signature: signature, }; } export function hasImageOpsPreviewState(node: any): boolean { return _canvasUsable(node?.[IMAGEOPS_STATE_KEY]?.canvas); } export const IMAGEOPS_PREVIEW_SOURCE = IMAGEOPS_SOURCE;