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.
This commit is contained in:
MajoorWaldi
2026-03-27 09:15:50 +01:00
parent 314efcdbe6
commit 83fe6ef4b9
9 changed files with 382 additions and 142 deletions
+136 -29
View File
@@ -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);
+3 -3
View File
@@ -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;
+6 -32
View File
@@ -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);
}
+23 -4
View File
@@ -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
};
+160 -30
View File
@@ -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);
+5 -3
View File
@@ -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;
+12 -38
View File
@@ -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);
}
+29 -3
View File
@@ -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<HTMLImageElement | null> {
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;
}
+8
View File
@@ -152,6 +152,10 @@ export interface NodeState {
rafId: number | null;
debounceTimer: ReturnType<typeof setTimeout> | 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 ──