From 83fe6ef4b96e76f97602adf2d3d015da1398cea3 Mon Sep 17 00:00:00 2001 From: MajoorWaldi Date: Fri, 27 Mar 2026 09:15:50 +0100 Subject: [PATCH] feat: Enhance live preview rendering and state management - Introduced new state properties in ensureState to manage rendering state, including lastRenderTick, renderInFlight, queuedRenderTick, and renderNonce. - Added hashText and serializePreviewValue functions for efficient preview value serialization. - Implemented buildPreviewRenderKey to create a unique key for rendering based on node state and inputs. - Improved rendering logic in registerImageOpsLivePreview to handle queued renders and prevent unnecessary re-renders. - Refactored blit function to accept various source types and improved canvas handling. - Added ensureCanvasSize and renderImageSourceToCanvas functions to streamline canvas creation and image rendering. - Updated ops and renderer modules to utilize new rendering functions and improve performance. - Enhanced error handling during rendering processes to provide clearer feedback. --- js/preview/host.js | 165 ++++++++++++++++++++++++++++------ js/preview/ops.js | 6 +- js/preview/renderer.js | 38 ++------ js/preview/source.js | 27 +++++- src/preview/host.ts | 190 +++++++++++++++++++++++++++++++++------- src/preview/ops.ts | 8 +- src/preview/renderer.ts | 50 +++-------- src/preview/source.ts | 32 ++++++- src/types.ts | 8 ++ 9 files changed, 382 insertions(+), 142 deletions(-) diff --git a/js/preview/host.js b/js/preview/host.js index 3853374..9b5b59e 100644 --- a/js/preview/host.js +++ b/js/preview/host.js @@ -50,6 +50,10 @@ function ensureState(node) { rafId: null, debounceTimer: null, lastKey: null, + lastRenderTick: null, + renderInFlight: false, + queuedRenderTick: null, + renderNonce: 0, isPreview: isPreviewNode(node), nativeAnimated: false, nativeDirty: false, @@ -92,6 +96,50 @@ function ensureState(node) { }); return node.__imageops_state; } +function hashText(value) { + let hash = 2166136261; + for (let index = 0; index < value.length; index++) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(16); +} +function serializePreviewValue(value) { + if (value == null) return "null"; + if (typeof value === "string") { + return value.length > 120 ? `str:${value.length}:${hashText(value)}` : `str:${value}`; + } + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (Array.isArray(value)) { + return `arr:${value.length}:${value.map((entry) => serializePreviewValue(entry)).join(",")}`; + } + try { + const json = JSON.stringify(value); + if (!json) return String(value); + return json.length > 120 ? `json:${json.length}:${hashText(json)}` : `json:${json}`; + } catch { + return String(value); + } +} +function buildPreviewRenderKey(node, tick, st) { + const parts = [ + String(node.id), + String(node.comfyClass ?? ""), + `tick:${tick}`, + `dirty:${st.nativeDirty ? 1 : 0}`, + `anim:${st.nativeAnimated ? 1 : 0}`, + `imageIndex:${typeof node.imageIndex === "number" ? node.imageIndex : -1}`, + `imgs:${Array.isArray(node.imgs) ? node.imgs.length : 0}` + ]; + for (const widget of node.widgets ?? []) { + parts.push(`${widget?.name ?? "widget"}=${serializePreviewValue(widget?.value)}`); + } + for (let index = 0; index < (node.inputs?.length ?? 0); index++) { + const input = node.inputs?.[index]; + parts.push(`in${index}:${input?.name ?? ""}:${input?.link ?? "null"}`); + } + return parts.join("|"); +} function stopRAF(st) { if (st?.rafId) { cancelAnimationFrame(st.rafId); @@ -952,29 +1000,34 @@ function tryRenderNativePreview(node, st, canvasSize) { } const sourceWidth = img.naturalWidth || img.width || 1; const sourceHeight = img.naturalHeight || img.height || 1; - const frame = document.createElement("canvas"); - frame.width = sourceWidth; - frame.height = sourceHeight; - const frameCtx = frame.getContext("2d"); - if (!frameCtx) return false; - frameCtx.clearRect(0, 0, sourceWidth, sourceHeight); - frameCtx.drawImage(img, 0, 0, sourceWidth, sourceHeight); - blit(node, st, frame, canvasSize); + blit(node, st, img, canvasSize, sourceWidth, sourceHeight); st.info.textContent = st.nativeAnimated ? "Node preview (animated)" : "Node preview"; return true; } -function blit(node, st, imgCanvas, canvasSize) { +function blit(node, st, source, canvasSize, sourceWidth, sourceHeight) { if (!st.canvas) return; const ctx = st.canvas.getContext("2d"); if (!ctx) return; - st.canvas.width = canvasSize; - st.canvas.height = canvasSize; - drawFitSource(ctx, canvasSize, canvasSize, imgCanvas, imgCanvas.width || 1, imgCanvas.height || 1); + if (st.canvas.width !== canvasSize) st.canvas.width = canvasSize; + if (st.canvas.height !== canvasSize) st.canvas.height = canvasSize; + const resolvedWidth = Math.max( + 1, + Math.round( + sourceWidth ?? source.naturalWidth ?? source.videoWidth ?? source.width ?? 1 + ) + ); + const resolvedHeight = Math.max( + 1, + Math.round( + sourceHeight ?? source.naturalHeight ?? source.videoHeight ?? source.height ?? 1 + ) + ); + drawFitSource(ctx, canvasSize, canvasSize, source, resolvedWidth, resolvedHeight); if (isDrawNode(node)) { - const fit = getFitPlacement(canvasSize, canvasSize, imgCanvas.width || 1, imgCanvas.height || 1); + const fit = getFitPlacement(canvasSize, canvasSize, resolvedWidth, resolvedHeight); st.drawGeometry = { - sourceWidth: imgCanvas.width || 1, - sourceHeight: imgCanvas.height || 1, + sourceWidth: resolvedWidth, + sourceHeight: resolvedHeight, fitDx: fit.dx, fitDy: fit.dy, fitDrawWidth: fit.drawWidth, @@ -983,9 +1036,9 @@ function blit(node, st, imgCanvas, canvasSize) { } else { st.drawGeometry = null; } - st.cropGeometry = drawCropBounds(node, ctx, canvasSize, canvasSize, imgCanvas.width || 1, imgCanvas.height || 1); - drawTransformBounds(node, ctx, canvasSize, canvasSize, imgCanvas.width || 1, imgCanvas.height || 1); - drawCompBounds(node, ctx, canvasSize, canvasSize, st.compOutputWidth || imgCanvas.width || 1, st.compOutputHeight || imgCanvas.height || 1, st.compLayers); + st.cropGeometry = drawCropBounds(node, ctx, canvasSize, canvasSize, resolvedWidth, resolvedHeight); + drawTransformBounds(node, ctx, canvasSize, canvasSize, resolvedWidth, resolvedHeight); + drawCompBounds(node, ctx, canvasSize, canvasSize, st.compOutputWidth || resolvedWidth, st.compOutputHeight || resolvedHeight, st.compLayers); } function getCropInfoText(node) { const preset = widgetString(node, "aspect_ratio", "custom"); @@ -1717,15 +1770,46 @@ function registerImageOpsLivePreview() { function renderNode(node, tick = 0) { const st = ensurePreviewWidget(node, progress, canvasSize); if (!st) return; + const renderKey = buildPreviewRenderKey(node, tick, st); + if (!st.nativeDirty && st.lastKey === renderKey && st.lastRenderTick === tick) { + return; + } + if (st.renderInFlight) { + st.queuedRenderTick = tick; + return; + } + const finishRender = () => { + st.renderInFlight = false; + const queuedTick = st.queuedRenderTick; + st.queuedRenderTick = null; + if (queuedTick != null) { + renderNode(node, queuedTick); + } + }; + const commitRender = () => { + st.lastKey = renderKey; + st.lastRenderTick = tick; + st.nativeDirty = false; + }; + const failRender = (message, error) => { + st.info.textContent = message; + console.warn("[ImageOps] render error", error); + finishRender(); + }; + st.renderInFlight = true; + st.renderNonce += 1; if (isPreviewNode(node)) { if (isGraphTooLarge(node?.graph, cfg.maxGraphNodes)) { st.info.textContent = "Live preview disabled: graph too large"; stopRAF(st); + finishRender(); return; } - renderPreviewBridgeNode(node, tick).catch((err) => { - st.info.textContent = "Preview bridge error (check console)"; - console.warn("[ImageOps] preview bridge render error", err); + renderPreviewBridgeNode(node, tick).then(() => { + commitRender(); + finishRender(); + }).catch((err) => { + failRender("Preview bridge error (check console)", err); }); return; } @@ -1733,11 +1817,14 @@ function registerImageOpsLivePreview() { if (isGraphTooLarge(node?.graph, cfg.maxGraphNodes)) { st.info.textContent = "Live preview disabled: graph too large"; stopRAF(st); + finishRender(); return; } - renderDrawNode(node, tick).catch((err) => { - st.info.textContent = "Draw preview error (check console)"; - console.warn("[ImageOps] draw render error", err); + renderDrawNode(node, tick).then(() => { + commitRender(); + finishRender(); + }).catch((err) => { + failRender("Draw preview error (check console)", err); }); return; } @@ -1746,26 +1833,30 @@ function registerImageOpsLivePreview() { st.info.textContent = "Live preview disabled: graph too large"; st.cropGeometry = null; stopRAF(st); + finishRender(); return; } const upstream = getUpstreamNode(node, 0); if (!upstream) { st.info.textContent = "Live preview: connect a supported loader/chain"; st.cropGeometry = null; + finishRender(); return; } renderer.render(upstream, tick).then((result) => { if (!result?.canvas) { st.info.textContent = "Live preview: connect a supported loader/chain"; st.cropGeometry = null; + finishRender(); return; } blit(node, st, result.canvas, canvasSize); st.info.textContent = getCropInfoText(node); + commitRender(); + finishRender(); }).catch((err) => { - st.info.textContent = "Live preview error (check console)"; st.cropGeometry = null; - console.warn("[ImageOps] render error", err); + failRender("Live preview error (check console)", err); }); return; } @@ -1774,6 +1865,7 @@ function registerImageOpsLivePreview() { st.info.textContent = "Live preview disabled: graph too large"; st.compLayers = []; stopRAF(st); + finishRender(); return; } const slots = getCompSlots(node); @@ -1796,6 +1888,7 @@ function registerImageOpsLivePreview() { st.compOutputWidth = Math.max(1, Math.round(widgetNumber(node, "width", 1024))); st.compOutputHeight = Math.max(1, Math.round(widgetNumber(node, "height", 1024))); updateCompControls(node); + finishRender(); return; } const rendered = renderCompPreview(node, ordered); @@ -1805,24 +1898,31 @@ function registerImageOpsLivePreview() { blit(node, st, rendered.canvas, canvasSize); st.info.textContent = getCompInfoText(node, ordered.length, slots.length, st.compOutputWidth, st.compOutputHeight); updateCompControls(node); + commitRender(); + finishRender(); }).catch((err) => { st.info.textContent = "Comp preview error (check console)"; st.compLayers = []; console.warn("[ImageOps] comp render error", err); + finishRender(); }); return; } if (tryRenderNativePreview(node, st, canvasSize)) { + commitRender(); + finishRender(); return; } if (isGraphTooLarge(node?.graph, cfg.maxGraphNodes)) { st.info.textContent = "Live preview disabled: graph too large"; stopRAF(st); + finishRender(); return; } renderer.render(node, tick).then((result) => { if (!result?.canvas) { st.info.textContent = "Live preview: connect a supported loader/chain"; + finishRender(); return; } blit(node, st, result.canvas, canvasSize); @@ -1836,9 +1936,10 @@ function registerImageOpsLivePreview() { } else { st.info.textContent = "Live preview (no queue)"; } + commitRender(); + finishRender(); }).catch((err) => { - st.info.textContent = "Live preview error (check console)"; - console.warn("[ImageOps] render error", err); + failRender("Live preview error (check console)", err); }); } function startLoopIfVideo(node) { @@ -1857,6 +1958,7 @@ function registerImageOpsLivePreview() { return; } let tick = 0; + let lastLoopTick = null; const proceduralFrameCount = getProceduralFrameCount(node); const proceduralFps = proceduralFrameCount != null ? getProceduralPlaybackFps(node) ?? 12 : null; const startedAt = performance.now(); @@ -1867,7 +1969,10 @@ function registerImageOpsLivePreview() { } else { tick++; } - renderNode(node, tick); + if (tick !== lastLoopTick) { + lastLoopTick = tick; + renderNode(node, tick); + } st.rafId = requestAnimationFrame(loop); }; stopRAF(st); @@ -1968,6 +2073,8 @@ function registerImageOpsLivePreview() { const r = origExecuted?.apply(this, arguments); st.nativeAnimated = !!message?.animated?.[0]; st.nativeDirty = false; + st.lastKey = null; + st.lastRenderTick = null; schedule(node, () => { if (IMAGEOPS_CLASSES.has(node.comfyClass)) startLoopIfVideo(node); refreshDependents(node); diff --git a/js/preview/ops.js b/js/preview/ops.js index a8a38ce..73c6193 100644 --- a/js/preview/ops.js +++ b/js/preview/ops.js @@ -421,7 +421,7 @@ function distortConnectedInputs(node, inputs) { return { source, displacement, mask }; } function extractCanvasField(canvas, width, height, channel) { - const fitted = fitCanvas(canvas, width, height); + const fitted = (canvas.width || 1) === width && (canvas.height || 1) === height ? canvas : fitCanvas(canvas, width, height); const data = fitted.getContext("2d").getImageData(0, 0, width, height).data; const field = new Float32Array(width * height); const normalized = String(channel || "red").toLowerCase(); @@ -515,7 +515,7 @@ function renderDistortCanvas(node, inputs, frameIndex = 0) { xField = extractCanvasField(driver, width, height, strAny(node, ["x_channel"], "Red", frameIndex)); yField = extractCanvasField(driver, width, height, strAny(node, ["y_channel"], "Green", frameIndex)); } - const sourceCanvas = fitCanvas(source, width, height); + const sourceCanvas = source; const sourceCtx = sourceCanvas.getContext("2d"); const sourceData = sourceCtx.getImageData(0, 0, width, height); const output = makeCanvas(width, height); @@ -1011,7 +1011,7 @@ function resolvePreviewMaskCanvas(node, source, rawMask, frameIndex = 0) { return boolAny(node, ["invert_mask"], false, frameIndex) ? invertMaskCanvas(matte) : matte; } function compositeProcessedWithMask(baseCanvas, processedCanvas, maskCanvas) { - if (!maskCanvas) return fitCanvas(processedCanvas, processedCanvas.width || 1, processedCanvas.height || 1); + if (!maskCanvas) return processedCanvas; const output = fitCanvas(baseCanvas, processedCanvas.width || 1, processedCanvas.height || 1); const octx = output.getContext("2d"); octx.imageSmoothingEnabled = true; diff --git a/js/preview/renderer.js b/js/preview/renderer.js index 9ab325c..29b5847 100644 --- a/js/preview/renderer.js +++ b/js/preview/renderer.js @@ -1,5 +1,5 @@ import { getInputLink, getUpstreamNode, detectSource } from "./graph.js"; -import { makeViewUrl, ensureBitmap, ensureImageElement, ensureVideoFrameCanvas, fitWithinMaxSize } from "./source.js"; +import { makeViewUrl, ensureBitmap, ensureImageElement, ensureVideoFrameCanvas, fitWithinMaxSize, renderImageSourceToCanvas } from "./source.js"; function buildRenderer({ api, registry, canvasSize }) { const MAX_RECURSION = 64; function hashString(value) { @@ -29,13 +29,7 @@ function buildRenderer({ api, registry, canvasSize }) { if (media.readyState < 2) return null; } const dims = fitWithinMaxSize(media.videoWidth || 1, media.videoHeight || 1, canvasSize); - const canvas = document.createElement("canvas"); - canvas.width = dims.width; - canvas.height = dims.height; - const ctx = canvas.getContext("2d"); - if (!ctx) return null; - ctx.drawImage(media, 0, 0, dims.width, dims.height); - return canvas; + return renderImageSourceToCanvas(node, media, dims.width, dims.height, "nativeCanvas"); } if (media instanceof HTMLImageElement) { if (!media.complete) { @@ -46,23 +40,11 @@ function buildRenderer({ api, registry, canvasSize }) { if (!media.complete) return null; } const dims = fitWithinMaxSize(media.naturalWidth || media.width || 1, media.naturalHeight || media.height || 1, canvasSize); - const canvas = document.createElement("canvas"); - canvas.width = dims.width; - canvas.height = dims.height; - const ctx = canvas.getContext("2d"); - if (!ctx) return null; - ctx.drawImage(media, 0, 0, dims.width, dims.height); - return canvas; + return renderImageSourceToCanvas(node, media, dims.width, dims.height, "nativeCanvas"); } if (media instanceof HTMLCanvasElement) { const dims = fitWithinMaxSize(media.width || 1, media.height || 1, canvasSize); - const canvas = document.createElement("canvas"); - canvas.width = dims.width; - canvas.height = dims.height; - const ctx = canvas.getContext("2d"); - if (!ctx) return null; - ctx.drawImage(media, 0, 0, dims.width, dims.height); - return canvas; + return renderImageSourceToCanvas(node, media, dims.width, dims.height, "nativeCanvas"); } return null; } @@ -102,11 +84,7 @@ function buildRenderer({ api, registry, canvasSize }) { return null; } const dims = fitWithinMaxSize(img.naturalWidth || img.width || 1, img.naturalHeight || img.height || 1, ctx.canvasSize); - c = document.createElement("canvas"); - c.width = dims.width; - c.height = dims.height; - const cctx = c.getContext("2d"); - if (cctx) cctx.drawImage(img, 0, 0, dims.width, dims.height); + c = renderImageSourceToCanvas(node, img, dims.width, dims.height, "animatedImageCanvas"); } else if (src.kind === "image") { const bmp = await ensureBitmap(node, url); if (!bmp) { @@ -114,11 +92,7 @@ function buildRenderer({ api, registry, canvasSize }) { return null; } const dims = fitWithinMaxSize(bmp.width || 1, bmp.height || 1, ctx.canvasSize); - c = document.createElement("canvas"); - c.width = dims.width; - c.height = dims.height; - const cctx = c.getContext("2d"); - if (cctx) cctx.drawImage(bmp, 0, 0, dims.width, dims.height); + c = renderImageSourceToCanvas(node, bmp, dims.width, dims.height, "imageCanvas"); } else { c = await ensureVideoFrameCanvas(node, url, ctx.canvasSize); } diff --git a/js/preview/source.js b/js/preview/source.js index 05a3371..1edd12b 100644 --- a/js/preview/source.js +++ b/js/preview/source.js @@ -31,6 +31,25 @@ function fitWithinMaxSize(width, height, maxSize) { height: Math.max(1, Math.round(safeHeight * scale)) }; } +function ensureCanvasSize(canvas, width, height) { + const targetWidth = Math.max(1, Math.round(width || 1)); + const targetHeight = Math.max(1, Math.round(height || 1)); + const next = canvas ?? document.createElement("canvas"); + if (next.width !== targetWidth) next.width = targetWidth; + if (next.height !== targetHeight) next.height = targetHeight; + return next; +} +function renderImageSourceToCanvas(node, source, width, height, slot) { + node.__imageops_media ?? (node.__imageops_media = {}); + const st = node.__imageops_media; + const canvas = ensureCanvasSize(st[slot], width, height); + const ctx = canvas.getContext("2d"); + if (!ctx) return canvas; + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(source, 0, 0, canvas.width, canvas.height); + st[slot] = canvas; + return canvas; +} async function ensureImageElement(node, url) { node.__imageops_media ?? (node.__imageops_media = {}); const st = node.__imageops_media; @@ -83,14 +102,13 @@ async function ensureVideoFrameCanvas(node, url, size) { } const v = st.videoEl; const { width, height } = fitWithinMaxSize(v.videoWidth || size, v.videoHeight || size, size); - const c = document.createElement("canvas"); - c.width = width; - c.height = height; + const c = ensureCanvasSize(st.videoCanvas, width, height); const ctx = c.getContext("2d"); if (!ctx) return c; if (v.readyState < 2) return c; ctx.clearRect(0, 0, width, height); ctx.drawImage(v, 0, 0, width, height); + st.videoCanvas = c; return c; } export { @@ -99,5 +117,6 @@ export { ensureVideoFrameCanvas, fitWithinMaxSize, makeViewUrl, - parseAnnotated + parseAnnotated, + renderImageSourceToCanvas }; diff --git a/src/preview/host.ts b/src/preview/host.ts index 838d0e4..cb6a81f 100644 --- a/src/preview/host.ts +++ b/src/preview/host.ts @@ -71,6 +71,10 @@ function ensureState(node: ComfyNode): NodeState { rafId: null, debounceTimer: null, lastKey: null, + lastRenderTick: null, + renderInFlight: false, + queuedRenderTick: null, + renderNonce: 0, isPreview: isPreviewNode(node), nativeAnimated: false, nativeDirty: false, @@ -114,6 +118,53 @@ function ensureState(node: ComfyNode): NodeState { return node.__imageops_state!; } +function hashText(value: string): string { + let hash = 2166136261; + for (let index = 0; index < value.length; index++) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(16); +} + +function serializePreviewValue(value: unknown): string { + if (value == null) return "null"; + if (typeof value === "string") { + return value.length > 120 ? `str:${value.length}:${hashText(value)}` : `str:${value}`; + } + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (Array.isArray(value)) { + return `arr:${value.length}:${value.map((entry) => serializePreviewValue(entry)).join(",")}`; + } + try { + const json = JSON.stringify(value); + if (!json) return String(value); + return json.length > 120 ? `json:${json.length}:${hashText(json)}` : `json:${json}`; + } catch { + return String(value); + } +} + +function buildPreviewRenderKey(node: ComfyNode, tick: number, st: NodeState): string { + const parts: string[] = [ + String(node.id), + String(node.comfyClass ?? ""), + `tick:${tick}`, + `dirty:${st.nativeDirty ? 1 : 0}`, + `anim:${st.nativeAnimated ? 1 : 0}`, + `imageIndex:${typeof node.imageIndex === "number" ? node.imageIndex : -1}`, + `imgs:${Array.isArray(node.imgs) ? node.imgs.length : 0}`, + ]; + for (const widget of (node.widgets ?? [])) { + parts.push(`${widget?.name ?? "widget"}=${serializePreviewValue(widget?.value)}`); + } + for (let index = 0; index < (node.inputs?.length ?? 0); index++) { + const input = node.inputs?.[index]; + parts.push(`in${index}:${input?.name ?? ""}:${input?.link ?? "null"}`); + } + return parts.join("|"); +} + function stopRAF(st: NodeState): void { if (st?.rafId) { cancelAnimationFrame(st.rafId); @@ -1138,31 +1189,50 @@ function tryRenderNativePreview(node: ComfyNode, st: NodeState, canvasSize: numb const sourceWidth = img.naturalWidth || img.width || 1; const sourceHeight = img.naturalHeight || img.height || 1; - const frame = document.createElement("canvas"); - frame.width = sourceWidth; - frame.height = sourceHeight; - const frameCtx = frame.getContext("2d"); - if (!frameCtx) return false; - - frameCtx.clearRect(0, 0, sourceWidth, sourceHeight); - frameCtx.drawImage(img, 0, 0, sourceWidth, sourceHeight); - blit(node, st, frame, canvasSize); + blit(node, st, img, canvasSize, sourceWidth, sourceHeight); st.info!.textContent = st.nativeAnimated ? "Node preview (animated)" : "Node preview"; return true; } -function blit(node: ComfyNode, st: NodeState, imgCanvas: HTMLCanvasElement, canvasSize: number): void { +function blit( + node: ComfyNode, + st: NodeState, + source: CanvasImageSource, + canvasSize: number, + sourceWidth?: number, + sourceHeight?: number, +): void { if (!st.canvas) return; const ctx = st.canvas.getContext("2d"); if (!ctx) return; - st.canvas.width = canvasSize; - st.canvas.height = canvasSize; - drawFitSource(ctx, canvasSize, canvasSize, imgCanvas, imgCanvas.width || 1, imgCanvas.height || 1); + if (st.canvas.width !== canvasSize) st.canvas.width = canvasSize; + if (st.canvas.height !== canvasSize) st.canvas.height = canvasSize; + const resolvedWidth = Math.max( + 1, + Math.round( + sourceWidth + ?? (source as HTMLImageElement).naturalWidth + ?? (source as HTMLVideoElement).videoWidth + ?? (source as HTMLCanvasElement).width + ?? 1, + ), + ); + const resolvedHeight = Math.max( + 1, + Math.round( + sourceHeight + ?? (source as HTMLImageElement).naturalHeight + ?? (source as HTMLVideoElement).videoHeight + ?? (source as HTMLCanvasElement).height + ?? 1, + ), + ); + drawFitSource(ctx, canvasSize, canvasSize, source, resolvedWidth, resolvedHeight); if (isDrawNode(node)) { - const fit = getFitPlacement(canvasSize, canvasSize, imgCanvas.width || 1, imgCanvas.height || 1); + const fit = getFitPlacement(canvasSize, canvasSize, resolvedWidth, resolvedHeight); st.drawGeometry = { - sourceWidth: imgCanvas.width || 1, - sourceHeight: imgCanvas.height || 1, + sourceWidth: resolvedWidth, + sourceHeight: resolvedHeight, fitDx: fit.dx, fitDy: fit.dy, fitDrawWidth: fit.drawWidth, @@ -1171,9 +1241,9 @@ function blit(node: ComfyNode, st: NodeState, imgCanvas: HTMLCanvasElement, canv } else { st.drawGeometry = null; } - st.cropGeometry = drawCropBounds(node, ctx, canvasSize, canvasSize, imgCanvas.width || 1, imgCanvas.height || 1); - drawTransformBounds(node, ctx, canvasSize, canvasSize, imgCanvas.width || 1, imgCanvas.height || 1); - drawCompBounds(node, ctx, canvasSize, canvasSize, st.compOutputWidth || imgCanvas.width || 1, st.compOutputHeight || imgCanvas.height || 1, st.compLayers); + st.cropGeometry = drawCropBounds(node, ctx, canvasSize, canvasSize, resolvedWidth, resolvedHeight); + drawTransformBounds(node, ctx, canvasSize, canvasSize, resolvedWidth, resolvedHeight); + drawCompBounds(node, ctx, canvasSize, canvasSize, st.compOutputWidth || resolvedWidth, st.compOutputHeight || resolvedHeight, st.compLayers); } function getCropInfoText(node: ComfyNode): string { @@ -1996,16 +2066,53 @@ export function registerImageOpsLivePreview(): void { function renderNode(node: ComfyNode, tick: number = 0): void { const st = ensurePreviewWidget(node, progress, canvasSize); if (!st) return; + const renderKey = buildPreviewRenderKey(node, tick, st); + + if (!st.nativeDirty && st.lastKey === renderKey && st.lastRenderTick === tick) { + return; + } + + if (st.renderInFlight) { + st.queuedRenderTick = tick; + return; + } + + const finishRender = (): void => { + st.renderInFlight = false; + const queuedTick = st.queuedRenderTick; + st.queuedRenderTick = null; + if (queuedTick != null) { + renderNode(node, queuedTick); + } + }; + + const commitRender = (): void => { + st.lastKey = renderKey; + st.lastRenderTick = tick; + st.nativeDirty = false; + }; + + const failRender = (message: string, error: unknown): void => { + st.info!.textContent = message; + console.warn("[ImageOps] render error", error); + finishRender(); + }; + + st.renderInFlight = true; + st.renderNonce += 1; if (isPreviewNode(node)) { if (isGraphTooLarge(node?.graph, cfg.maxGraphNodes)) { st.info!.textContent = "Live preview disabled: graph too large"; stopRAF(st); + finishRender(); return; } - renderPreviewBridgeNode(node, tick).catch((err) => { - st.info!.textContent = "Preview bridge error (check console)"; - console.warn("[ImageOps] preview bridge render error", err); + renderPreviewBridgeNode(node, tick).then(() => { + commitRender(); + finishRender(); + }).catch((err) => { + failRender("Preview bridge error (check console)", err); }); return; } @@ -2014,12 +2121,15 @@ export function registerImageOpsLivePreview(): void { if (isGraphTooLarge(node?.graph, cfg.maxGraphNodes)) { st.info!.textContent = "Live preview disabled: graph too large"; stopRAF(st); + finishRender(); return; } - renderDrawNode(node, tick).catch((err) => { - st.info!.textContent = "Draw preview error (check console)"; - console.warn("[ImageOps] draw render error", err); + renderDrawNode(node, tick).then(() => { + commitRender(); + finishRender(); + }).catch((err) => { + failRender("Draw preview error (check console)", err); }); return; } @@ -2029,6 +2139,7 @@ export function registerImageOpsLivePreview(): void { st.info!.textContent = "Live preview disabled: graph too large"; st.cropGeometry = null; stopRAF(st); + finishRender(); return; } @@ -2036,6 +2147,7 @@ export function registerImageOpsLivePreview(): void { if (!upstream) { st.info!.textContent = "Live preview: connect a supported loader/chain"; st.cropGeometry = null; + finishRender(); return; } @@ -2043,14 +2155,16 @@ export function registerImageOpsLivePreview(): void { if (!result?.canvas) { st.info!.textContent = "Live preview: connect a supported loader/chain"; st.cropGeometry = null; + finishRender(); return; } blit(node, st, result.canvas, canvasSize); st.info!.textContent = getCropInfoText(node); + commitRender(); + finishRender(); }).catch(err => { - st.info!.textContent = "Live preview error (check console)"; st.cropGeometry = null; - console.warn("[ImageOps] render error", err); + failRender("Live preview error (check console)", err); }); return; } @@ -2060,6 +2174,7 @@ export function registerImageOpsLivePreview(): void { st.info!.textContent = "Live preview disabled: graph too large"; st.compLayers = []; stopRAF(st); + finishRender(); return; } @@ -2083,6 +2198,7 @@ export function registerImageOpsLivePreview(): void { st.compOutputWidth = Math.max(1, Math.round(widgetNumber(node, "width", 1024))); st.compOutputHeight = Math.max(1, Math.round(widgetNumber(node, "height", 1024))); updateCompControls(node); + finishRender(); return; } const rendered = renderCompPreview(node, ordered); @@ -2092,27 +2208,34 @@ export function registerImageOpsLivePreview(): void { blit(node, st, rendered.canvas, canvasSize); st.info!.textContent = getCompInfoText(node, ordered.length, slots.length, st.compOutputWidth, st.compOutputHeight); updateCompControls(node); + commitRender(); + finishRender(); }).catch((err) => { st.info!.textContent = "Comp preview error (check console)"; st.compLayers = []; console.warn("[ImageOps] comp render error", err); + finishRender(); }); return; } if (tryRenderNativePreview(node, st, canvasSize)) { + commitRender(); + finishRender(); return; } if (isGraphTooLarge(node?.graph, cfg.maxGraphNodes)) { st.info!.textContent = "Live preview disabled: graph too large"; stopRAF(st); + finishRender(); return; } renderer.render(node, tick).then(result => { if (!result?.canvas) { st.info!.textContent = "Live preview: connect a supported loader/chain"; + finishRender(); return; } blit(node, st, result.canvas, canvasSize); @@ -2126,9 +2249,10 @@ export function registerImageOpsLivePreview(): void { } else { st.info!.textContent = "Live preview (no queue)"; } + commitRender(); + finishRender(); }).catch(err => { - st.info!.textContent = "Live preview error (check console)"; - console.warn("[ImageOps] render error", err); + failRender("Live preview error (check console)", err); }); } @@ -2151,6 +2275,7 @@ export function registerImageOpsLivePreview(): void { } let tick = 0; + let lastLoopTick: number | null = null; const proceduralFrameCount = getProceduralFrameCount(node); const proceduralFps = proceduralFrameCount != null ? (getProceduralPlaybackFps(node) ?? 12) : null; const startedAt = performance.now(); @@ -2161,7 +2286,10 @@ export function registerImageOpsLivePreview(): void { } else { tick++; } - renderNode(node, tick); + if (tick !== lastLoopTick) { + lastLoopTick = tick; + renderNode(node, tick); + } st.rafId = requestAnimationFrame(loop); }; stopRAF(st); @@ -2271,6 +2399,8 @@ export function registerImageOpsLivePreview(): void { const r = origExecuted?.apply(this, arguments as any); st.nativeAnimated = !!message?.animated?.[0]; st.nativeDirty = false; + st.lastKey = null; + st.lastRenderTick = null; schedule(node, () => { if (IMAGEOPS_CLASSES.has(node.comfyClass)) startLoopIfVideo(node); refreshDependents(node); diff --git a/src/preview/ops.ts b/src/preview/ops.ts index 50aef73..ab25d1b 100644 --- a/src/preview/ops.ts +++ b/src/preview/ops.ts @@ -514,7 +514,9 @@ function distortConnectedInputs( } function extractCanvasField(canvas: HTMLCanvasElement, width: number, height: number, channel: string): Float32Array { - const fitted = fitCanvas(canvas, width, height); + const fitted = (canvas.width || 1) === width && (canvas.height || 1) === height + ? canvas + : fitCanvas(canvas, width, height); const data = fitted.getContext("2d")!.getImageData(0, 0, width, height).data; const field = new Float32Array(width * height); const normalized = String(channel || "red").toLowerCase(); @@ -619,7 +621,7 @@ function renderDistortCanvas( yField = extractCanvasField(driver, width, height, strAny(node, ["y_channel"], "Green", frameIndex)); } - const sourceCanvas = fitCanvas(source, width, height); + const sourceCanvas = source; const sourceCtx = sourceCanvas.getContext("2d")!; const sourceData = sourceCtx.getImageData(0, 0, width, height); const output = makeCanvas(width, height); @@ -1167,7 +1169,7 @@ function compositeProcessedWithMask( processedCanvas: HTMLCanvasElement, maskCanvas: HTMLCanvasElement | null, ): HTMLCanvasElement { - if (!maskCanvas) return fitCanvas(processedCanvas, processedCanvas.width || 1, processedCanvas.height || 1); + if (!maskCanvas) return processedCanvas; const output = fitCanvas(baseCanvas, processedCanvas.width || 1, processedCanvas.height || 1); const octx = output.getContext("2d")!; octx.imageSmoothingEnabled = true; diff --git a/src/preview/renderer.ts b/src/preview/renderer.ts index 893139c..c918e5a 100644 --- a/src/preview/renderer.ts +++ b/src/preview/renderer.ts @@ -1,7 +1,7 @@ // Renderer (recursive, supports interop) (v6) import type { ComfyNode, ComfyAPI, AdapterRegistry, RenderContext, RenderInputInfo, RenderResult } from "../types.js"; import { getInputLink, getUpstreamNode, detectSource } from "./graph.js"; -import { makeViewUrl, ensureBitmap, ensureImageElement, ensureVideoFrameCanvas, fitWithinMaxSize } from "./source.js"; +import { makeViewUrl, ensureBitmap, ensureImageElement, ensureVideoFrameCanvas, fitWithinMaxSize, renderImageSourceToCanvas } from "./source.js"; interface RendererConfig { api: ComfyAPI; @@ -38,20 +38,14 @@ export function buildRenderer({ api, registry, canvasSize }: RendererConfig): Re const media = getNativePreviewElement(node); if (!media) return null; - if (media instanceof HTMLVideoElement) { - if (media.readyState < 2) { - try { await media.play(); } catch {} - if (media.readyState < 2) return null; + if (media instanceof HTMLVideoElement) { + if (media.readyState < 2) { + try { await media.play(); } catch {} + if (media.readyState < 2) return null; + } + const dims = fitWithinMaxSize(media.videoWidth || 1, media.videoHeight || 1, canvasSize); + return renderImageSourceToCanvas(node, media, dims.width, dims.height, "nativeCanvas"); } - const dims = fitWithinMaxSize(media.videoWidth || 1, media.videoHeight || 1, canvasSize); - const canvas = document.createElement("canvas"); - canvas.width = dims.width; - canvas.height = dims.height; - const ctx = canvas.getContext("2d"); - if (!ctx) return null; - ctx.drawImage(media, 0, 0, dims.width, dims.height); - return canvas; - } if (media instanceof HTMLImageElement) { if (!media.complete) { @@ -59,24 +53,12 @@ export function buildRenderer({ api, registry, canvasSize }: RendererConfig): Re if (!media.complete) return null; } const dims = fitWithinMaxSize(media.naturalWidth || media.width || 1, media.naturalHeight || media.height || 1, canvasSize); - const canvas = document.createElement("canvas"); - canvas.width = dims.width; - canvas.height = dims.height; - const ctx = canvas.getContext("2d"); - if (!ctx) return null; - ctx.drawImage(media, 0, 0, dims.width, dims.height); - return canvas; + return renderImageSourceToCanvas(node, media, dims.width, dims.height, "nativeCanvas"); } if (media instanceof HTMLCanvasElement) { const dims = fitWithinMaxSize(media.width || 1, media.height || 1, canvasSize); - const canvas = document.createElement("canvas"); - canvas.width = dims.width; - canvas.height = dims.height; - const ctx = canvas.getContext("2d"); - if (!ctx) return null; - ctx.drawImage(media, 0, 0, dims.width, dims.height); - return canvas; + return renderImageSourceToCanvas(node, media, dims.width, dims.height, "nativeCanvas"); } return null; @@ -118,20 +100,12 @@ export function buildRenderer({ api, registry, canvasSize }: RendererConfig): Re const img = await ensureImageElement(node, url); if (!img) { ctx.visited.delete(node.id); return null; } const dims = fitWithinMaxSize(img.naturalWidth || img.width || 1, img.naturalHeight || img.height || 1, ctx.canvasSize); - c = document.createElement("canvas"); - c.width = dims.width; - c.height = dims.height; - const cctx = c.getContext("2d"); - if (cctx) cctx.drawImage(img, 0, 0, dims.width, dims.height); + c = renderImageSourceToCanvas(node, img, dims.width, dims.height, "animatedImageCanvas"); } else if (src.kind === "image") { const bmp = await ensureBitmap(node, url); if (!bmp) { ctx.visited.delete(node.id); return null; } const dims = fitWithinMaxSize(bmp.width || 1, bmp.height || 1, ctx.canvasSize); - c = document.createElement("canvas"); - c.width = dims.width; - c.height = dims.height; - const cctx = c.getContext("2d"); - if (cctx) cctx.drawImage(bmp, 0, 0, dims.width, dims.height); + c = renderImageSourceToCanvas(node, bmp, dims.width, dims.height, "imageCanvas"); } else { c = await ensureVideoFrameCanvas(node, url, ctx.canvasSize); } diff --git a/src/preview/source.ts b/src/preview/source.ts index 7d17f2e..a7bcf62 100644 --- a/src/preview/source.ts +++ b/src/preview/source.ts @@ -39,6 +39,33 @@ export function fitWithinMaxSize(width: number, height: number, maxSize: number) }; } +function ensureCanvasSize(canvas: HTMLCanvasElement | undefined, width: number, height: number): HTMLCanvasElement { + const targetWidth = Math.max(1, Math.round(width || 1)); + const targetHeight = Math.max(1, Math.round(height || 1)); + const next = canvas ?? document.createElement("canvas"); + if (next.width !== targetWidth) next.width = targetWidth; + if (next.height !== targetHeight) next.height = targetHeight; + return next; +} + +export function renderImageSourceToCanvas( + node: ComfyNode, + source: CanvasImageSource, + width: number, + height: number, + slot: "imageCanvas" | "animatedImageCanvas" | "videoCanvas" | "nativeCanvas", +): HTMLCanvasElement { + node.__imageops_media ??= {} as MediaState; + const st = node.__imageops_media!; + const canvas = ensureCanvasSize(st[slot], width, height); + const ctx = canvas.getContext("2d"); + if (!ctx) return canvas; + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(source, 0, 0, canvas.width, canvas.height); + st[slot] = canvas; + return canvas; +} + export async function ensureImageElement(node: ComfyNode, url: string): Promise { node.__imageops_media ??= {} as MediaState; const st = node.__imageops_media!; @@ -90,9 +117,7 @@ export async function ensureVideoFrameCanvas(node: ComfyNode, url: string, size: const v = st.videoEl!; const { width, height } = fitWithinMaxSize(v.videoWidth || size, v.videoHeight || size, size); - const c = document.createElement("canvas"); - c.width = width; - c.height = height; + const c = ensureCanvasSize(st.videoCanvas, width, height); const ctx = c.getContext("2d"); if (!ctx) return c; @@ -100,5 +125,6 @@ export async function ensureVideoFrameCanvas(node: ComfyNode, url: string, size: ctx.clearRect(0, 0, width, height); ctx.drawImage(v, 0, 0, width, height); + st.videoCanvas = c; return c; } diff --git a/src/types.ts b/src/types.ts index 3857460..570bdfc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -152,6 +152,10 @@ export interface NodeState { rafId: number | null; debounceTimer: ReturnType | null; lastKey: string | null; + lastRenderTick: number | null; + renderInFlight: boolean; + queuedRenderTick: number | null; + renderNonce: number; isPreview: boolean; nativeAnimated: boolean; nativeDirty: boolean; @@ -281,6 +285,10 @@ export interface MediaState { lastBitmap?: ImageBitmap; videoEl?: HTMLVideoElement; lastVideoURL?: string; + imageCanvas?: HTMLCanvasElement; + animatedImageCanvas?: HTMLCanvasElement; + videoCanvas?: HTMLCanvasElement; + nativeCanvas?: HTMLCanvasElement; } // ── Media / source ──