diff --git a/README.md b/README.md index 93ae64a..71c2ad6 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ | **ImageOps Invert** | `ImageOpsInvert` | Invert colors and/or alpha channel | | **ImageOps Clamp** | `ImageOpsClamp` | Clamp pixel values to min/max range | | **ImageOps Merge** | `ImageOpsMerge` | Blend two images with multiple blend modes (over, add, subtract, multiply, screen, difference, max, min) | -| **ImageOps Noise** | `ImageOpsNoise` | Procedural noise source with Perlin, value, FBM, turbulence, ridged, seed stepping, and color ramp output | +| **ImageOps Noise** | `ImageOpsNoise` | Procedural noise source with Perlin, value, FBM, turbulence, ridged, seed stepping, frame length/FPS animation controls, and color ramp output | | **ImageOps Draw** | `ImageOpsDraw` | Digital painting with brush/eraser tools, opacity, and color controls | | **ImageOps Comp** | `ImageOpsComp` | Multi-layer compositor with blend modes, positioning, and opacity per layer | diff --git a/js/preview/adapters/imageops.js b/js/preview/adapters/imageops.js index c3db1a4..98644ea 100644 --- a/js/preview/adapters/imageops.js +++ b/js/preview/adapters/imageops.js @@ -55,21 +55,21 @@ function imageOpsAdapter() { } if (bypass && cls !== "ImageOpsDraw") return; if (cls === "ImageOpsColorAjust") { - return ops.colorAjust(ctx, canvasSize, node, inputs); + return ops.colorAjust(ctx, canvasSize, node, inputs, tick ?? 0); } else if (cls === "ImageOpsChannel") { - return ops.channel(ctx, canvasSize, node, outputSlot, inputs); + return ops.channel(ctx, canvasSize, node, outputSlot, inputs, tick ?? 0); } else if (cls === "ImageOpsCrop") { - return ops.crop(ctx, canvasSize, node, inputs); + return ops.crop(ctx, canvasSize, node, inputs, tick ?? 0); } else if (cls === "ImageOpsBlur") { - return ops.blur(ctx, canvasSize, node, inputs); + return ops.blur(ctx, canvasSize, node, inputs, tick ?? 0); } else if (cls === "ImageOpsTransform") { - return ops.transform(ctx, canvasSize, node, inputs); + return ops.transform(ctx, canvasSize, node, inputs, tick ?? 0); } else if (cls === "ImageOpsInvert") { - return ops.invert(ctx, canvasSize, node, inputs); + return ops.invert(ctx, canvasSize, node, inputs, tick ?? 0); } else if (cls === "ImageOpsClamp") { - return ops.clamp(ctx, canvasSize, node, inputs); + return ops.clamp(ctx, canvasSize, node, inputs, tick ?? 0); } else if (cls === "ImageOpsMerge") { - return ops.merge(ctx, canvasSize, node, inputs); + return ops.merge(ctx, canvasSize, node, inputs, void 0, tick ?? 0); } else if (cls === "ImageOpsComp") { return ops.comp(ctx, canvasSize, node, inputs); } else if (cls === "ImageOpsDistort") { diff --git a/js/preview/host.js b/js/preview/host.js index 1434969..3853374 100644 --- a/js/preview/host.js +++ b/js/preview/host.js @@ -468,9 +468,17 @@ function widgetHasAnimatedValues(value) { if (value.length > 1) return true; return value.length === 1 ? widgetHasAnimatedValues(value[0]) : false; } -function hasProceduralAnimation(node) { +function widgetAnimatedLength(value) { + if (!Array.isArray(value)) return 1; + if (value.length > 1) return value.length; + return value.length === 1 ? widgetAnimatedLength(value[0]) : 1; +} +function getProceduralFrameCount(node) { const cls = String(node?.comfyClass ?? ""); - if (cls !== "ImageOpsNoise") return false; + const animatedLength = Math.max(1, ...(node.widgets ?? []).map((entry) => widgetAnimatedLength(entry?.value))); + if (cls !== "ImageOpsNoise") { + return animatedLength > 1 ? animatedLength : null; + } const widget = (name) => node.widgets?.find((entry) => entry?.name === name) ?? null; const numeric = (name, fallback = 0) => { const value = widget(name)?.value; @@ -479,11 +487,34 @@ function hasProceduralAnimation(node) { return Number.isFinite(parsed) ? parsed : fallback; }; const batchSize = Math.max(1, Math.round(numeric("batch_size", 1))); - if (batchSize <= 1) return false; + const frameLength = Math.max(0, Math.round(numeric("frame_length", 0))); + const frameCount = frameLength > 0 ? frameLength : batchSize; + return Math.max(frameCount, animatedLength); +} +function hasProceduralAnimation(node) { + const cls = String(node?.comfyClass ?? ""); + const animatedWidgets = (node.widgets ?? []).some((entry) => widgetHasAnimatedValues(entry?.value)); + if (cls !== "ImageOpsNoise") return animatedWidgets && (getProceduralFrameCount(node) ?? 1) > 1; + const widget = (name) => node.widgets?.find((entry) => entry?.name === name) ?? null; + const numeric = (name, fallback = 0) => { + const value = widget(name)?.value; + if (Array.isArray(value) && value.length > 1) return 1; + const parsed = parseFloat(value); + return Number.isFinite(parsed) ? parsed : fallback; + }; + const frameCount = getProceduralFrameCount(node) ?? 1; + if (frameCount <= 1) return false; if (numeric("seed_step", 0) !== 0) return true; if (numeric("frame_offset_x", 0) !== 0) return true; if (numeric("frame_offset_y", 0) !== 0) return true; - return (node.widgets ?? []).some((entry) => widgetHasAnimatedValues(entry?.value)); + return animatedWidgets; +} +function getProceduralPlaybackFps(node) { + const cls = String(node?.comfyClass ?? ""); + if (cls !== "ImageOpsNoise") return null; + const widget = (name) => node.widgets?.find((entry) => entry?.name === name) ?? null; + const value = parseFloat(widget("fps")?.value); + return Number.isFinite(value) ? Math.max(1, value) : 12; } function getInputIndexByName(node, name) { return (node.inputs ?? []).findIndex((input) => String(input?.name ?? "") === name); @@ -1826,8 +1857,16 @@ function registerImageOpsLivePreview() { return; } let tick = 0; + const proceduralFrameCount = getProceduralFrameCount(node); + const proceduralFps = proceduralFrameCount != null ? getProceduralPlaybackFps(node) ?? 12 : null; + const startedAt = performance.now(); const loop = () => { - tick++; + if (proceduralFps != null) { + const rawTick = Math.floor((performance.now() - startedAt) * proceduralFps / 1e3); + tick = proceduralFrameCount != null && proceduralFrameCount > 0 ? rawTick % proceduralFrameCount : rawTick; + } else { + tick++; + } renderNode(node, tick); st.rafId = requestAnimationFrame(loop); }; diff --git a/js/preview/ops.js b/js/preview/ops.js index 661e641..a8a38ce 100644 --- a/js/preview/ops.js +++ b/js/preview/ops.js @@ -387,9 +387,11 @@ function renderNoiseCanvas(node, maskOnly = false, frameIndex = 0) { const width = Math.max(1, Math.round(numAny(node, ["width"], 1024))); const height = Math.max(1, Math.round(numAny(node, ["height"], 1024))); const batchSize = Math.max(1, Math.round(numAny(node, ["batch_size"], 1))); - const resolvedFrameIndex = (Math.max(0, Math.round(frameIndex)) % batchSize + batchSize) % batchSize; - const low = hexToRgb01(strAny(node, ["low_color"], "#000000")); - const high = hexToRgb01(strAny(node, ["high_color"], "#ffffff")); + const frameLength = Math.max(0, Math.round(numAny(node, ["frame_length"], 0))); + const frameCount = frameLength > 0 ? frameLength : batchSize; + const resolvedFrameIndex = (Math.max(0, Math.round(frameIndex)) % frameCount + frameCount) % frameCount; + const low = hexToRgb01(strAny(node, ["low_color"], "#000000", resolvedFrameIndex)); + const high = hexToRgb01(strAny(node, ["high_color"], "#ffffff", resolvedFrameIndex)); const grayValues = buildNoiseField(width, height, { basis: strAny(node, ["basis"], "perlin", resolvedFrameIndex), fractalMode: strAny(node, ["fractal_mode"], "fbm", resolvedFrameIndex), @@ -494,7 +496,7 @@ function renderDistortCanvas(node, inputs, frameIndex = 0) { const mapSource = strAny(node, ["map_source"], "source_channel", frameIndex).toLowerCase(); const centeredMap = boolAny(node, ["centered_map"], true, frameIndex); const invertMap = boolAny(node, ["invert_map"], false, frameIndex); - const effectMask = mapSource === "mask" ? null : resolvePreviewMaskCanvas(node, source, rawMask); + const effectMask = mapSource === "mask" ? null : resolvePreviewMaskCanvas(node, source, rawMask, frameIndex); let previewMask = null; let xField; let yField; @@ -1003,10 +1005,10 @@ function applyEffectToCanvas(source, effect) { const result = effect(octx, output.width, output.height); return result instanceof HTMLCanvasElement ? result : output; } -function resolvePreviewMaskCanvas(node, source, rawMask) { +function resolvePreviewMaskCanvas(node, source, rawMask, frameIndex = 0) { if (!rawMask) return null; const matte = buildMaskAlphaCanvas(rawMask, source.width || 1, source.height || 1); - return boolAny(node, ["invert_mask"], false) ? invertMaskCanvas(matte) : matte; + 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); @@ -1024,7 +1026,7 @@ function compositeProcessedWithMask(baseCanvas, processedCanvas, maskCanvas) { return output; } function renderMaskedEffectPreview(node, source, rawMask, processImage, options = {}) { - const mask = resolvePreviewMaskCanvas(node, source, rawMask); + const mask = resolvePreviewMaskCanvas(node, source, rawMask, options.frameIndex ?? 0); if (!mask) return processImage(source); const processed = processImage(options.premultBeforeProcess ? premultLayerWithMask(source, mask) : source); const processedMask = options.processMask ? options.processMask(mask) : fitCanvas(mask, processed.width || 1, processed.height || 1); @@ -1381,7 +1383,7 @@ function mergeChannelInputs(inputs, mode) { return output; } const ops = { - colorAjust(ctx, W, node, inputs = []) { + colorAjust(ctx, W, node, inputs = [], frameIndex = 0) { const source = inputs[0] ?? ctx.canvas; const rawMask = inputs[1] ?? null; return renderMaskedEffectPreview( @@ -1393,24 +1395,25 @@ const ops = { effectCtx, width, height, - numAny(node, ["temperature"], 0), - numAny(node, ["hue", "hue_deg"], 0), - numAny(node, ["brightness"], 0), - numAny(node, ["contrast"], 0), - numAny(node, ["saturation", "sat"], 0), - numAny(node, ["gamma"], 1) + numAny(node, ["temperature"], 0, frameIndex), + numAny(node, ["hue", "hue_deg"], 0, frameIndex), + numAny(node, ["brightness"], 0, frameIndex), + numAny(node, ["contrast"], 0, frameIndex), + numAny(node, ["saturation", "sat"], 0, frameIndex), + numAny(node, ["gamma"], 1, frameIndex) ); - }) + }), + { frameIndex } ); }, - colorCorrect(ctx, W, node, inputs = []) { - return ops.colorAjust(ctx, W, node, inputs); + colorCorrect(ctx, W, node, inputs = [], frameIndex = 0) { + return ops.colorAjust(ctx, W, node, inputs, frameIndex); }, - blur(ctx, W, node, inputs = []) { + blur(ctx, W, node, inputs = [], frameIndex = 0) { const source = inputs[0] ?? ctx.canvas; const rawMask = inputs[1] ?? null; - const radius = numAny(node, ["radius", "blur", "blur_radius"], 0); - const sigma = numAny(node, ["sigma", "radius", "blur"], radius); + const radius = numAny(node, ["radius", "blur", "blur_radius"], 0, frameIndex); + const sigma = numAny(node, ["sigma", "radius", "blur"], radius, frameIndex); return renderMaskedEffectPreview( node, source, @@ -1419,6 +1422,7 @@ const ops = { applyBlur(effectCtx, width, height, radius, sigma); }), { + frameIndex, premultBeforeProcess: true, processMask: (mask) => applyEffectToCanvas(mask, (effectCtx, width, height) => { applyBlur(effectCtx, width, height, radius, sigma); @@ -1426,10 +1430,10 @@ const ops = { } ); }, - channel(ctx, W, node, outputSlot, inputs = []) { + channel(ctx, W, node, outputSlot, inputs = [], frameIndex = 0) { const source = inputs[0] ?? ctx.canvas; const rawMask = inputs[1] ?? null; - const splitMode = strAny(node, ["mode"], "RGBA"); + const splitMode = strAny(node, ["mode"], "RGBA", frameIndex); const hasSingleChannelWidget = !!wAny(node, ["channel"]); if (!hasSingleChannelWidget && outputSlot != null) { return extractSplitChannelCanvas(source, outputSlot, splitMode); @@ -1439,11 +1443,12 @@ const ops = { source, rawMask, (input) => applyEffectToCanvas(input, (effectCtx, width, height) => { - applyChannel(effectCtx, width, height, strAny(node, ["channel"], "Red")); - }) + applyChannel(effectCtx, width, height, strAny(node, ["channel"], "Red", frameIndex)); + }), + { frameIndex } ); }, - crop(ctx, W, node, inputs = []) { + crop(ctx, W, node, inputs = [], frameIndex = 0) { const source = inputs[0] ?? ctx.canvas; const rawMask = inputs[1] ?? null; return renderMaskedEffectPreview( @@ -1455,20 +1460,21 @@ const ops = { node, width, height, - str(node, "aspect_ratio", "custom"), - num(node, "width", width), - num(node, "height", height) + str(node, "aspect_ratio", "custom", frameIndex), + num(node, "width", width, frameIndex), + num(node, "height", height, frameIndex) )), { + frameIndex, premultBeforeProcess: true, processMask: (mask) => applyEffectToCanvas(mask, (effectCtx, width, height) => applyCrop( effectCtx, node, width, height, - str(node, "aspect_ratio", "custom"), - num(node, "width", width), - num(node, "height", height) + str(node, "aspect_ratio", "custom", frameIndex), + num(node, "width", width, frameIndex), + num(node, "height", height, frameIndex) )) } ); @@ -1506,34 +1512,34 @@ const ops = { const height = Math.max(1, Math.round(cropRegion?.height ?? numAny(bboxNode ?? node, ["height", "crop_h"], sourceHeight))); return cropRectCanvas(source, x, y, Math.min(width, sourceWidth - x), Math.min(height, sourceHeight - y)); }, - transform(ctx, W, node, inputs = []) { + transform(ctx, W, node, inputs = [], frameIndex = 0) { const source = inputs[0] ?? ctx.canvas; const rawMask = inputs[1] ?? null; const transformImage = (input) => { let working = input; - const mirror = strAny(node, ["mirror"], "none").toLowerCase(); - const flipMethod = strAny(node, ["flip_method"], ""); + const mirror = strAny(node, ["mirror"], "none", frameIndex).toLowerCase(); + const flipMethod = strAny(node, ["flip_method"], "", frameIndex); const horizontal = mirror === "horizontal" || flipMethod.startsWith("y"); const vertical = mirror === "vertical" || flipMethod.startsWith("x"); working = flipCanvas(working, horizontal, vertical); - const rotationLabel = strAny(node, ["rotation"], ""); + const rotationLabel = strAny(node, ["rotation"], "", frameIndex); if (rotationLabel.startsWith("90")) working = rotateDiscrete(working, 1); else if (rotationLabel.startsWith("180")) working = rotateDiscrete(working, 2); else if (rotationLabel.startsWith("270")) working = rotateDiscrete(working, 3); - const aspectRatio = numAny(node, ["aspect_ratio"], 1); + const aspectRatio = numAny(node, ["aspect_ratio"], 1, frameIndex); if (Math.abs(aspectRatio - 1) > 1e-4) { const scaled = makeCanvas(working.width || 1, Math.max(1, Math.round((working.height || 1) * aspectRatio))); const sctx = scaled.getContext("2d"); - setResampleMode(sctx, normalizeFilterName(strAny(node, ["upscale_method", "interpolation", "transform_method", "filter"], "bilinear"))); + setResampleMode(sctx, normalizeFilterName(strAny(node, ["upscale_method", "interpolation", "transform_method", "filter"], "bilinear", frameIndex))); sctx.drawImage(working, 0, 0, scaled.width, scaled.height); working = scaled; } - const tx = numAny(node, ["translate_x", "x", "shift_x"], 0); - const ty = numAny(node, ["translate_y", "y", "shift_y"], 0); - const rot = numAny(node, ["rotate_deg", "rotate"], 0); - const scale = numAny(node, ["scale"], 1); - const filter = normalizeFilterName(strAny(node, ["filter", "upscale_method", "interpolation", "transform_method"], "bilinear")); - const expand = boolAny(node, ["expand"], false); + const tx = numAny(node, ["translate_x", "x", "shift_x"], 0, frameIndex); + const ty = numAny(node, ["translate_y", "y", "shift_y"], 0, frameIndex); + const rot = numAny(node, ["rotate_deg", "rotate"], 0, frameIndex); + const scale = numAny(node, ["scale"], 1, frameIndex); + const filter = normalizeFilterName(strAny(node, ["filter", "upscale_method", "interpolation", "transform_method"], "bilinear", frameIndex)); + const expand = boolAny(node, ["expand"], false, frameIndex); return applyEffectToCanvas(working, (effectCtx, width, height) => { return applyTransform(effectCtx, width, height, tx, ty, rot, scale, filter, expand); }); @@ -1544,6 +1550,7 @@ const ops = { rawMask, transformImage, { + frameIndex, premultBeforeProcess: true, processMask: transformImage } @@ -1573,7 +1580,7 @@ const ops = { numAny(node, ["value", "val"], 1) ); }, - invert(ctx, W, node, inputs = []) { + invert(ctx, W, node, inputs = [], frameIndex = 0) { const source = inputs[0] ?? ctx.canvas; const rawMask = inputs[1] ?? null; return renderMaskedEffectPreview( @@ -1581,11 +1588,12 @@ const ops = { source, rawMask, (input) => applyEffectToCanvas(input, (effectCtx, width, height) => { - applyInvert(effectCtx, width, height, boolAny(node, ["invert_alpha"], false)); - }) + applyInvert(effectCtx, width, height, boolAny(node, ["invert_alpha"], false, frameIndex)); + }), + { frameIndex } ); }, - clamp(ctx, W, node, inputs = []) { + clamp(ctx, W, node, inputs = [], frameIndex = 0) { const source = inputs[0] ?? ctx.canvas; const rawMask = inputs[1] ?? null; return renderMaskedEffectPreview( @@ -1593,8 +1601,9 @@ const ops = { source, rawMask, (input) => applyEffectToCanvas(input, (effectCtx, width, height) => { - applyClamp(effectCtx, width, height, numAny(node, ["min_v", "min"], 0), numAny(node, ["max_v", "max"], 1)); - }) + applyClamp(effectCtx, width, height, numAny(node, ["min_v", "min"], 0, frameIndex), numAny(node, ["max_v", "max"], 1, frameIndex)); + }), + { frameIndex } ); }, sharpen(ctx, W, node) { @@ -1629,18 +1638,18 @@ const ops = { const { width, height } = getCanvasDimensions(ctx); applyLumaKey(ctx, width, height, numAny(node, ["low"], 0.1), numAny(node, ["high"], 0.9), numAny(node, ["softness"], 0.05)); }, - merge(ctx, W, node, topCanvasOrInputs, _opts) { + merge(ctx, W, node, topCanvasOrInputs, _opts, frameIndex = 0) { const inputs = Array.isArray(topCanvasOrInputs) ? topCanvasOrInputs : [ctx.canvas, topCanvasOrInputs]; const base = inputs[0] ?? ctx.canvas; const topCanvas = inputs[1] ?? null; if (!topCanvas) return fitCanvas(base, base.width || 1, base.height || 1); - const mode = strAny(node, ["mode", "blend_mode"], "over"); - const rawOpacity = w(node, "opacity") ? num(node, "opacity", 100) : numAny(node, ["mix", "factor", "fade_factor", "blend_factor", "start_level", "end_level"], 1); + const mode = strAny(node, ["mode", "blend_mode"], "over", frameIndex); + const rawOpacity = w(node, "opacity") ? num(node, "opacity", 100, frameIndex) : numAny(node, ["mix", "factor", "fade_factor", "blend_factor", "start_level", "end_level"], 1, frameIndex); const opacity = rawOpacity > 1 ? rawOpacity / 100 : rawOpacity; const merged = applyEffectToCanvas(base, (effectCtx, width, height) => { blend(effectCtx, width, height, topCanvas, mode, opacity); }); - const effectMask = resolvePreviewMaskCanvas(node, base, inputs[2] ?? null); + const effectMask = resolvePreviewMaskCanvas(node, base, inputs[2] ?? null, frameIndex); return effectMask ? compositeProcessedWithMask(base, merged, effectMask) : merged; }, resize(ctx, W, node) { @@ -1758,7 +1767,7 @@ const ops = { imageOpsMask(ctx, W, node, cls, inputs = [], frameIndex = 0) { const source = inputs[0] ?? ctx.canvas; const rawMask = inputs[1] ?? null; - const resolvedMask = resolvePreviewMaskCanvas(node, source, rawMask); + const resolvedMask = resolvePreviewMaskCanvas(node, source, rawMask, frameIndex); if (cls === "ImageOpsNoise") { return renderNoiseCanvas(node, true, frameIndex); } @@ -1766,40 +1775,40 @@ const ops = { return renderDistortCanvas(node, inputs, frameIndex).mask; } if (cls === "ImageOpsBlur") { - const radius = numAny(node, ["radius", "blur", "blur_radius"], 0); - const sigma = numAny(node, ["sigma", "radius", "blur"], radius); + const radius = numAny(node, ["radius", "blur", "blur_radius"], 0, frameIndex); + const sigma = numAny(node, ["sigma", "radius", "blur"], radius, frameIndex); return applyEffectToCanvas(resolvedMask ?? alphaMaskCanvas(source), (effectCtx, width, height) => { applyBlur(effectCtx, width, height, radius, sigma); }); } if (cls === "ImageOpsTransform") { - return ops.transform(ctx, W, node, [resolvedMask ?? alphaMaskCanvas(source)]); + return ops.transform(ctx, W, node, [resolvedMask ?? alphaMaskCanvas(source)], frameIndex); } if (cls === "ImageOpsCrop") { - return ops.crop(ctx, W, node, [resolvedMask ?? alphaMaskCanvas(source)]); + return ops.crop(ctx, W, node, [resolvedMask ?? alphaMaskCanvas(source)], frameIndex); } if (cls === "ImageOpsChannel") { const extracted = applyEffectToCanvas(source, (effectCtx, width, height) => { - applyChannel(effectCtx, width, height, strAny(node, ["channel"], "Red")); + applyChannel(effectCtx, width, height, strAny(node, ["channel"], "Red", frameIndex)); }); return resolvedMask ? premultLayerWithMask(extracted, resolvedMask) : extracted; } if (cls === "ImageOpsClamp") { if (!resolvedMask) return alphaMaskCanvas(source); return applyEffectToCanvas(resolvedMask, (effectCtx, width, height) => { - applyClamp(effectCtx, width, height, numAny(node, ["min_v", "min"], 0), numAny(node, ["max_v", "max"], 1)); + applyClamp(effectCtx, width, height, numAny(node, ["min_v", "min"], 0, frameIndex), numAny(node, ["max_v", "max"], 1, frameIndex)); }); } if (cls === "ImageOpsInvert") { let mask = resolvedMask ?? alphaMaskCanvas(source); - if (!resolvedMask && boolAny(node, ["invert_alpha"], false)) { + if (!resolvedMask && boolAny(node, ["invert_alpha"], false, frameIndex)) { mask = invertMaskCanvas(mask); } return mask; } if (cls === "ImageOpsMerge") { if (resolvedMask) return resolvedMask; - const merged = ops.merge(ctx, W, node, inputs); + const merged = ops.merge(ctx, W, node, inputs, void 0, frameIndex); return alphaMaskCanvas(merged); } if (cls === "ImageOpsComp") { diff --git a/nodes/_helpers.py b/nodes/_helpers.py index 9f22076..73f9557 100644 --- a/nodes/_helpers.py +++ b/nodes/_helpers.py @@ -394,7 +394,12 @@ def _prepare_effect_mask(mask, reference, invert_mask=False): ) if prepared is None: return None - if _scalar(invert_mask, bool): + if isinstance(invert_mask, (list, tuple)): + prepared = prepared.clone() + for frame_index in range(prepared.shape[0]): + if _scalar(invert_mask, bool, index=frame_index): + prepared[frame_index:frame_index + 1] = 1.0 - prepared[frame_index:frame_index + 1] + elif _scalar(invert_mask, bool): prepared = 1.0 - prepared return torch.clamp(prepared, 0.0, 1.0) @@ -405,7 +410,12 @@ def _resolve_mask_output_source(mask, reference, invert_mask=False): prepared = _prepare_effect_mask(mask, reference, invert_mask=False) if prepared is None: prepared = _alpha_mask_from_image(reference) - if _scalar(invert_mask, bool): + if isinstance(invert_mask, (list, tuple)): + prepared = prepared.clone() + for frame_index in range(prepared.shape[0]): + if _scalar(invert_mask, bool, index=frame_index): + prepared[frame_index:frame_index + 1] = 1.0 - prepared[frame_index:frame_index + 1] + elif _scalar(invert_mask, bool): prepared = 1.0 - prepared return torch.clamp(prepared, 0.0, 1.0) @@ -429,6 +439,11 @@ def _extract_channel_mask(image: torch.Tensor, channel: str) -> torch.Tensor: raise ValueError("image is None") if image.dim() != 4: raise ValueError(f"Expected [B,H,W,C], got {tuple(image.shape)}") + if isinstance(channel, (list, tuple)): + return torch.cat([ + _extract_channel_mask(image[i:i+1], _scalar(channel, str, index=i)) + for i in range(image.shape[0]) + ], dim=0) normalized = str(channel or "Red").strip().lower() if normalized == "green": @@ -710,6 +725,16 @@ def _apply_merge(a: torch.Tensor, b: torch.Tensor, mode: str, mix): # a,b: [B,H,W,C] a = a.float().clamp(0,1) b = _match_image_to_reference(b.float().clamp(0,1), a) + if _has_list_param(mode, mix): + return torch.cat([ + _apply_merge( + a[i:i+1], + b[i:i+1], + _scalar(mode, str, index=i), + _scalar(mix, index=i), + ) + for i in range(a.shape[0]) + ], dim=0) mode = str(mode).lower() m = _param_tensor(mix, a.shape[0], a.device, a.dtype) ar, br = a[..., :3], b[..., :3] @@ -964,12 +989,12 @@ def _apply_interactive_crop_resize(image: torch.Tensor, out_w, out_h, aspect_rat if image.dim() != 4: raise ValueError(f"Expected [B,H,W,C], got {tuple(image.shape)}") - if _has_list_param(out_w, out_h, center_x, center_y, scale): + if _has_list_param(out_w, out_h, aspect_ratio, center_x, center_y, scale): return torch.cat([ _apply_interactive_crop_resize( image[i:i+1], _scalar(out_w, int, index=i), _scalar(out_h, int, index=i), - aspect_ratio, + _scalar(aspect_ratio, str, index=i), center_x=_scalar(center_x, index=i), center_y=_scalar(center_y, index=i), scale=_scalar(scale, index=i), diff --git a/nodes/_progress.py b/nodes/_progress.py new file mode 100644 index 0000000..46b6325 --- /dev/null +++ b/nodes/_progress.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +try: + from comfy.utils import ProgressBar +except Exception: + ProgressBar = None + + +def _normalize_unique_id(unique_id): + if isinstance(unique_id, (list, tuple)): + return unique_id[0] if unique_id else None + return unique_id + + +class ImageOpsProgress: + def __init__(self, total: int = 1, unique_id=None): + self.total = max(1, int(total or 1)) + self.unique_id = _normalize_unique_id(unique_id) + self._bar = None + + if ProgressBar is not None and self.unique_id is not None: + self._bar = ProgressBar(self.total, node_id=self.unique_id) + self._bar.update_absolute(0, total=self.total) + + def update(self, value: int = 1): + if self._bar is not None: + self._bar.update(max(0, int(value))) + return self + + def update_absolute(self, value: int, total: int | None = None): + if total is not None: + self.total = max(1, int(total or 1)) + if self._bar is not None: + self._bar.update_absolute(int(value), total=self.total) + return self + + def finish(self): + if self._bar is not None and self._bar.current < self.total: + self._bar.update_absolute(self.total, total=self.total) + return self + + +def start_progress(total: int = 1, unique_id=None) -> ImageOpsProgress: + return ImageOpsProgress(total=total, unique_id=unique_id) diff --git a/nodes/blur.py b/nodes/blur.py index ec1b969..302409a 100644 --- a/nodes/blur.py +++ b/nodes/blur.py @@ -8,6 +8,7 @@ from ._helpers import ( _scalar, _select_media_tensor, ) +from ._progress import start_progress from ._preview import build_node_preview_result @@ -29,18 +30,24 @@ class ImageOpsBlur: "optional": { "image": (MEDIA_INPUT_TYPE, {"tooltip": "Images/Video input. Accepts IMAGE batches and VIDEO frame sources.", "forceInput": True, "display_name": "Images/Video"}), "mask": ("MASK",), - } + }, + "hidden": { + "unique_id": "UNIQUE_ID", + }, } - def apply(self, image=None, bypass=False, radius=3, sigma=1.5, invert_mask=False, video=None, mask=None): + def apply(self, image=None, bypass=False, radius=3, sigma=1.5, invert_mask=False, video=None, mask=None, unique_id=None): source = _select_media_tensor(image, video) input_mask = _prepare_effect_mask(mask, source, invert_mask=invert_mask) output_mask_source = _resolve_mask_output_source(mask, source, invert_mask=invert_mask) + progress = start_progress(unique_id=unique_id) if _scalar(bypass, bool): + progress.finish() return build_node_preview_result(source, (source, output_mask_source), prefix="imageops_blur") if input_mask is not None: result, output_mask = _apply_blur_with_mask_pair(source, input_mask, radius, sigma) else: result = _apply_blur(source, radius, sigma) output_mask = _blur_mask(output_mask_source, radius, sigma) + progress.finish() return build_node_preview_result(result, (result, output_mask), prefix="imageops_blur") diff --git a/nodes/channel.py b/nodes/channel.py index 4fed5f7..e428d71 100644 --- a/nodes/channel.py +++ b/nodes/channel.py @@ -8,6 +8,7 @@ from ._helpers import ( _scalar, _select_media_tensor, ) +from ._progress import start_progress from ._preview import build_node_preview_result @@ -29,12 +30,17 @@ class ImageOpsChannel: "image": (MEDIA_INPUT_TYPE, {"tooltip": "Images/Video input. Accepts IMAGE batches and VIDEO frame sources.", "forceInput": True, "display_name": "Images/Video"}), "mask": ("MASK",), }, + "hidden": { + "unique_id": "UNIQUE_ID", + }, } - def apply(self, image=None, bypass=False, channel="Red", invert_mask=False, mask=None): + def apply(self, image=None, bypass=False, channel="Red", invert_mask=False, mask=None, unique_id=None): source = _select_media_tensor(image, None) output_mask_source = _resolve_mask_output_source(mask, source, invert_mask=invert_mask) + progress = start_progress(unique_id=unique_id) if _scalar(bypass, bool): + progress.finish() return build_node_preview_result(source, (source, output_mask_source), prefix="imageops_channel") extracted = _extract_channel_mask(source, channel) @@ -43,4 +49,5 @@ class ImageOpsChannel: extracted = (extracted * effect_mask).clamp(0.0, 1.0) result = _channel_mask_to_image(extracted, source) + progress.finish() return build_node_preview_result(result, (result, extracted), prefix="imageops_channel") diff --git a/nodes/clamp.py b/nodes/clamp.py index ae2e90a..07496b8 100644 --- a/nodes/clamp.py +++ b/nodes/clamp.py @@ -6,6 +6,7 @@ from ._helpers import ( _scalar, _select_media_tensor, ) +from ._progress import start_progress from ._preview import build_node_preview_result class ImageOpsClamp: @@ -26,17 +27,23 @@ class ImageOpsClamp: "optional": { "image": (MEDIA_INPUT_TYPE, {"tooltip": "Images/Video input. Accepts IMAGE batches and VIDEO frame sources.", "forceInput": True, "display_name": "Images/Video"}), "mask": ("MASK",), - } + }, + "hidden": { + "unique_id": "UNIQUE_ID", + }, } - def apply(self, image=None, bypass=False, min_v=0.0, max_v=1.0, invert_mask=False, video=None, mask=None): + def apply(self, image=None, bypass=False, min_v=0.0, max_v=1.0, invert_mask=False, video=None, mask=None, unique_id=None): src = _select_media_tensor(image, video) output_mask_source = _resolve_mask_output_source(mask, src, invert_mask=invert_mask) + progress = start_progress(unique_id=unique_id) if _scalar(bypass, bool): + progress.finish() return build_node_preview_result(src, (src, output_mask_source), prefix="imageops_clamp") out = _apply_clamp(src, min_v, max_v) if mask is None and src.shape[-1] < 4: output_mask = output_mask_source else: output_mask = _clamp_mask(output_mask_source, min_v, max_v) + progress.finish() return build_node_preview_result(out, (out, output_mask), prefix="imageops_clamp") diff --git a/nodes/color_ajust.py b/nodes/color_ajust.py index c0aed62..9b6d559 100644 --- a/nodes/color_ajust.py +++ b/nodes/color_ajust.py @@ -5,6 +5,7 @@ from ._helpers import ( _scalar, _select_media_tensor, ) +from ._progress import start_progress from ._preview import build_node_preview_result @@ -31,6 +32,9 @@ class ImageOpsColorAjust: "image": (MEDIA_INPUT_TYPE, {"tooltip": "Images/Video input. Accepts IMAGE batches and VIDEO frame sources.", "forceInput": True, "display_name": "Images/Video"}), "mask": ("MASK",), }, + "hidden": { + "unique_id": "UNIQUE_ID", + }, } def apply( @@ -46,10 +50,14 @@ class ImageOpsColorAjust: invert_mask=False, video=None, mask=None, + unique_id=None, ): source = _select_media_tensor(image, video) output_mask = _resolve_mask_output_source(mask, source, invert_mask=invert_mask) + progress = start_progress(unique_id=unique_id) if _scalar(bypass, bool): + progress.finish() return build_node_preview_result(source, (source, output_mask), prefix="imageops_color_ajust") result = _apply_color_correct_reference(source, temperature, hue, brightness, contrast, saturation, gamma) + progress.finish() return build_node_preview_result(result, (result, output_mask), prefix="imageops_color_ajust") diff --git a/nodes/comp.py b/nodes/comp.py index 92ab3d4..539358f 100644 --- a/nodes/comp.py +++ b/nodes/comp.py @@ -15,6 +15,7 @@ from ._helpers import ( _make_comp_canvas, _scalar, ) +from ._progress import start_progress from ._preview import build_node_preview_result @@ -123,6 +124,7 @@ class ImageOpsComp(io.ComfyNode): io.Image.Output("image", display_name="image"), io.Mask.Output("mask", display_name="mask"), ], + hidden=[io.Hidden.unique_id], ) @classmethod @@ -137,6 +139,7 @@ class ImageOpsComp(io.ComfyNode): invert_mask: bool = False, **inputs, ): + progress_unique_id = getattr(getattr(cls, "hidden", None), "unique_id", None) layer_numbers = _sorted_layer_numbers(inputs) layers = _parse_layers_state(layers_json, layer_numbers) connected_layers: list[tuple[dict[str, Any], Any, Any]] = [] @@ -150,18 +153,23 @@ class ImageOpsComp(io.ComfyNode): connected_layers.append((layer, image_value, mask_value)) if not connected_layers: + progress = start_progress(unique_id=progress_unique_id) out_w = max(1, _scalar(width, int)) out_h = max(1, _scalar(height, int)) blank = _make_comp_canvas(1, out_h, out_w, device="cpu", dtype=torch.float32, background_color=background_color) output_mask = blank[..., 3] if _scalar(invert_mask, bool): output_mask = 1.0 - output_mask + progress.finish() return build_node_preview_result(blank, (blank, output_mask), prefix="imageops_comp") + enabled_count = sum(1 for layer, _, _ in connected_layers if bool(layer.get("enabled", True))) + progress = start_progress(total=max(1, len(connected_layers) + enabled_count), unique_id=progress_unique_id) tensors: list[tuple[dict[str, Any], Any, Any]] = [] for layer, image_value, mask_value in connected_layers: image_tensor = _coerce_media_to_tensor(image_value, layer["slot"]).float().clamp(0.0, 1.0) tensors.append((layer, image_tensor, mask_value)) + progress.update() if _scalar(use_first_layer_size, bool): ref_tensor = tensors[0][1] @@ -194,6 +202,7 @@ class ImageOpsComp(io.ComfyNode): output_mask = prepared if _scalar(invert_mask, bool): output_mask = (1.0 - output_mask).clamp(0.0, 1.0) + progress.finish() return build_node_preview_result(result, (result, output_mask), prefix="imageops_comp") canvas = _make_comp_canvas(batch, out_h, out_w, device=device, dtype=dtype, background_color=background_color) @@ -210,8 +219,10 @@ class ImageOpsComp(io.ComfyNode): center_y=layer.get("center_y", 0.5), scale=layer.get("scale", 1.0), ) + progress.update() output_mask = canvas[..., 3].clamp(0.0, 1.0) if _scalar(invert_mask, bool): output_mask = (1.0 - output_mask).clamp(0.0, 1.0) + progress.finish() return build_node_preview_result(canvas, (canvas, output_mask), prefix="imageops_comp") diff --git a/nodes/crop.py b/nodes/crop.py index 261cff2..8c36ec8 100644 --- a/nodes/crop.py +++ b/nodes/crop.py @@ -8,6 +8,7 @@ from ._helpers import ( _scalar, _select_media_tensor, ) +from ._progress import start_progress from ._preview import build_node_preview_result @@ -36,16 +37,21 @@ class ImageOpsCrop: "image": (MEDIA_INPUT_TYPE, {"tooltip": "Images/Video input. Accepts IMAGE batches and VIDEO frame sources.", "forceInput": True, "display_name": "Images/Video"}), "mask": ("MASK",), }, + "hidden": { + "unique_id": "UNIQUE_ID", + }, } def apply(self, image=None, bypass=False, aspect_ratio="1:1", width=1024, height=1024, sync_dimensions=True, - invert_mask=False, mask=None, crop_center_x=0.5, crop_center_y=0.5, crop_scale=1.0): + invert_mask=False, mask=None, crop_center_x=0.5, crop_center_y=0.5, crop_scale=1.0, unique_id=None): del sync_dimensions source = _select_media_tensor(image, None) input_mask = _prepare_effect_mask(mask, source, invert_mask=invert_mask) output_mask_source = _resolve_mask_output_source(mask, source, invert_mask=invert_mask) + progress = start_progress(unique_id=unique_id) if _scalar(bypass, bool): + progress.finish() return build_node_preview_result(source, (source, output_mask_source), prefix="imageops_crop") if input_mask is not None: @@ -78,4 +84,5 @@ class ImageOpsCrop: center_y=crop_center_y, scale=crop_scale, )[..., 0] + progress.finish() return build_node_preview_result(result, (result, output_mask), prefix="imageops_crop") diff --git a/nodes/distort.py b/nodes/distort.py index e1c4bb5..ffa3234 100644 --- a/nodes/distort.py +++ b/nodes/distort.py @@ -16,6 +16,7 @@ from ._helpers import ( _scalar, _select_media_tensor, ) +from ._progress import start_progress from ._preview import build_node_preview_result _DISTORT_MAP_SOURCES = ["source_channel", "displacement_channel", "mask"] @@ -55,34 +56,69 @@ def _neutral_map(reference: torch.Tensor, centered_map) -> torch.Tensor: def _resolve_distortion_maps( source: torch.Tensor, - map_source: str, - x_channel: str, - y_channel: str, + map_source, + x_channel, + y_channel, centered_map, displacement=None, mask=None, ): - normalized = str(map_source or "source_channel").strip().lower() - if normalized == "mask": - prepared_mask = _prepare_mask_tensor( - mask, - batch=source.shape[0], - height=source.shape[1], - width=source.shape[2], + prepared_mask = _prepare_mask_tensor( + mask, + batch=source.shape[0], + height=source.shape[1], + width=source.shape[2], + device=source.device, + dtype=source.dtype, + ) + x_frames = [] + y_frames = [] + preview_mask_frames = [] + mask_source_frames: list[bool] = [] + + for frame_index in range(source.shape[0]): + normalized = _scalar(map_source, str, index=frame_index).strip().lower() + frame_source = source[frame_index:frame_index + 1] + frame_displacement = displacement[frame_index:frame_index + 1] if displacement is not None else None + + if normalized == "mask": + mask_source_frames.append(True) + if prepared_mask is None: + neutral = _neutral_map(frame_source, centered_map=_scalar(centered_map, bool, index=frame_index)) + x_frames.append(neutral) + y_frames.append(neutral) + preview_mask_frames.append(None) + else: + frame_mask = prepared_mask[frame_index:frame_index + 1] + x_frames.append(frame_mask) + y_frames.append(frame_mask) + preview_mask_frames.append(frame_mask) + continue + + mask_source_frames.append(False) + driver = frame_source + if normalized == "displacement_channel" and frame_displacement is not None: + driver = frame_displacement + x_frames.append( + _extract_map_channel(driver, _scalar(x_channel, str, index=frame_index)).to(device=source.device, dtype=source.dtype) + ) + y_frames.append( + _extract_map_channel(driver, _scalar(y_channel, str, index=frame_index)).to(device=source.device, dtype=source.dtype) + ) + preview_mask_frames.append(None) + + preview_mask = None + if any(frame is not None for frame in preview_mask_frames): + preview_mask = torch.zeros( + (source.shape[0], source.shape[1], source.shape[2]), device=source.device, dtype=source.dtype, ) - if prepared_mask is None: - neutral = _neutral_map(source, centered_map=centered_map) - return neutral, neutral, None - return prepared_mask, prepared_mask, prepared_mask + for frame_index, frame in enumerate(preview_mask_frames): + if frame is not None: + preview_mask[frame_index:frame_index + 1] = frame - driver = source - if normalized == "displacement_channel" and displacement is not None: - driver = displacement - x_map = _extract_map_channel(driver, x_channel).to(device=source.device, dtype=source.dtype) - y_map = _extract_map_channel(driver, y_channel).to(device=source.device, dtype=source.dtype) - return x_map, y_map, None + return torch.cat(x_frames, dim=0), torch.cat(y_frames, dim=0), preview_mask, mask_source_frames def _warp_image( @@ -93,8 +129,9 @@ def _warp_image( strength_y, centered_map, invert_map, - filter_mode: str, - edge_mode: str, + filter_mode, + edge_mode, + progress=None, ) -> torch.Tensor: image = source.float().permute(0, 3, 1, 2).contiguous() warped_frames = [] @@ -120,6 +157,12 @@ def _warp_image( dx = frame_x * float(_scalar(strength_x, index=frame_index)) dy = frame_y * float(_scalar(strength_y, index=frame_index)) + safe_filter = _scalar(filter_mode, str, index=frame_index).strip().lower() + if safe_filter not in ("nearest", "bilinear", "bicubic"): + safe_filter = "bilinear" + safe_edge_mode = _scalar(edge_mode, str, index=frame_index).strip().lower() + if safe_edge_mode not in ("border", "reflection", "zeros"): + safe_edge_mode = "border" grid = torch.stack( [ (base_x - dx * width_norm), @@ -131,11 +174,13 @@ def _warp_image( warped = F.grid_sample( image[frame_index:frame_index + 1], grid, - mode=str(filter_mode or "bilinear"), - padding_mode=str(edge_mode or "border"), + mode=safe_filter, + padding_mode=safe_edge_mode, align_corners=True, ) warped_frames.append(warped) + if progress is not None: + progress.update() return ( torch.cat(warped_frames, dim=0) @@ -173,6 +218,9 @@ class ImageOpsDistort: "displacement": (MEDIA_INPUT_TYPE, {"tooltip": "Optional displacement image/video when map_source is displacement_channel.", "display_name": "Displacement"}), "mask": ("MASK", {"tooltip": "Used as the distortion map when map_source is mask, otherwise acts as an effect mask."}), }, + "hidden": { + "unique_id": "UNIQUE_ID", + }, } def apply( @@ -192,6 +240,7 @@ class ImageOpsDistort: video=None, displacement=None, mask=None, + unique_id=None, ): source = _select_media_tensor(image, video).float().clamp(0.0, 1.0) displacement_tensor = None @@ -199,40 +248,42 @@ class ImageOpsDistort: displacement_tensor = _coerce_media_to_tensor(displacement, "displacement").float().clamp(0.0, 1.0) displacement_tensor = _match_image_to_reference(displacement_tensor, source) - normalized_map_source = str(map_source or "source_channel").strip().lower() - effect_mask = None - if normalized_map_source != "mask": - effect_mask = _prepare_effect_mask(mask, source, invert_mask=invert_mask) + effect_mask = _prepare_effect_mask(mask, source, invert_mask=invert_mask) - x_map, y_map, driver_preview_mask = _resolve_distortion_maps( + x_map, y_map, driver_preview_mask, mask_source_frames = _resolve_distortion_maps( source, - map_source=normalized_map_source, + map_source=map_source, x_channel=x_channel, y_channel=y_channel, centered_map=centered_map, displacement=displacement_tensor, mask=mask, ) + apply_effect_mask = effect_mask.clone() if effect_mask is not None else None + if apply_effect_mask is not None: + for frame_index, is_mask_source in enumerate(mask_source_frames): + if is_mask_source: + apply_effect_mask[frame_index:frame_index + 1] = 1.0 if _scalar(bypass, bool): + progress = start_progress(unique_id=unique_id) + output_mask = _alpha_mask_from_image(source) if effect_mask is not None: - output_mask = effect_mask - elif driver_preview_mask is not None: - preview_mask = driver_preview_mask - if _scalar(invert_map, bool): - preview_mask = 1.0 - preview_mask - output_mask = preview_mask.clamp(0.0, 1.0) - else: - output_mask = _alpha_mask_from_image(source) + for frame_index, is_mask_source in enumerate(mask_source_frames): + if not is_mask_source: + output_mask[frame_index:frame_index + 1] = effect_mask[frame_index:frame_index + 1].clamp(0.0, 1.0) + if driver_preview_mask is not None: + preview_mask = driver_preview_mask.clone() + for frame_index, is_mask_source in enumerate(mask_source_frames): + if is_mask_source and _scalar(invert_map, bool, index=frame_index): + preview_mask[frame_index:frame_index + 1] = 1.0 - preview_mask[frame_index:frame_index + 1] + for frame_index, is_mask_source in enumerate(mask_source_frames): + if is_mask_source: + output_mask[frame_index:frame_index + 1] = preview_mask[frame_index:frame_index + 1].clamp(0.0, 1.0) + progress.finish() return build_node_preview_result(source, (source, output_mask), prefix="imageops_distort") - safe_filter = str(filter or "bilinear").lower() - if safe_filter not in ("nearest", "bilinear", "bicubic"): - safe_filter = "bilinear" - safe_edge_mode = str(edge_mode or "border").lower() - if safe_edge_mode not in ("border", "reflection", "zeros"): - safe_edge_mode = "border" - + progress = start_progress(total=max(1, int(source.shape[0])), unique_id=unique_id) result = _warp_image( source, x_map=x_map, @@ -241,19 +292,26 @@ class ImageOpsDistort: strength_y=strength_y, centered_map=centered_map, invert_map=invert_map, - filter_mode=safe_filter, - edge_mode=safe_edge_mode, + filter_mode=filter, + edge_mode=edge_mode, + progress=progress, ) + if apply_effect_mask is not None: + result = source * (1.0 - apply_effect_mask.unsqueeze(-1)) + result * apply_effect_mask.unsqueeze(-1) + output_mask = _alpha_mask_from_image(result) if effect_mask is not None: - result = source * (1.0 - effect_mask.unsqueeze(-1)) + result * effect_mask.unsqueeze(-1) - output_mask = effect_mask.clamp(0.0, 1.0) - elif driver_preview_mask is not None: - preview_mask = driver_preview_mask - if _scalar(invert_map, bool): - preview_mask = 1.0 - preview_mask - output_mask = preview_mask.clamp(0.0, 1.0) - else: - output_mask = _alpha_mask_from_image(result) + for frame_index, is_mask_source in enumerate(mask_source_frames): + if not is_mask_source: + output_mask[frame_index:frame_index + 1] = effect_mask[frame_index:frame_index + 1].clamp(0.0, 1.0) + if driver_preview_mask is not None: + preview_mask = driver_preview_mask.clone() + for frame_index, is_mask_source in enumerate(mask_source_frames): + if is_mask_source and _scalar(invert_map, bool, index=frame_index): + preview_mask[frame_index:frame_index + 1] = 1.0 - preview_mask[frame_index:frame_index + 1] + for frame_index, is_mask_source in enumerate(mask_source_frames): + if is_mask_source: + output_mask[frame_index:frame_index + 1] = preview_mask[frame_index:frame_index + 1].clamp(0.0, 1.0) + progress.finish() return build_node_preview_result(result.clamp(0.0, 1.0), (result.clamp(0.0, 1.0), output_mask), prefix="imageops_distort") diff --git a/nodes/draw.py b/nodes/draw.py index cc2b80b..4099923 100644 --- a/nodes/draw.py +++ b/nodes/draw.py @@ -8,6 +8,7 @@ import torch from PIL import Image from ._helpers import MEDIA_INPUT_TYPE, _expand_image_batch, _hex_to_rgb01, _scalar, _select_media_tensor +from ._progress import start_progress from ._preview import build_node_preview_result @@ -110,6 +111,9 @@ class ImageOpsDraw: "optional": { "image": (MEDIA_INPUT_TYPE, {"tooltip": "Images/Video input. Accepts IMAGE batches and VIDEO frame sources.", "forceInput": True, "display_name": "Images/Video"}), }, + "hidden": { + "unique_id": "UNIQUE_ID", + }, } def apply( @@ -127,8 +131,10 @@ class ImageOpsDraw: overlay_data="", invert_mask=False, video=None, + unique_id=None, ): del sync_dimensions, tool, brush_color, brush_opacity, brush_size + progress = start_progress(unique_id=unique_id) source = None if image is not None or video is not None: @@ -152,9 +158,11 @@ class ImageOpsDraw: mask = torch.zeros((batch, target_h, target_w), device=source.device, dtype=source.dtype) if _scalar(invert_mask, bool): mask = 1.0 - mask + progress.finish() return build_node_preview_result(source, (source, mask), prefix="imageops_draw") result, mask = _composite_overlay(source, overlay) if _scalar(invert_mask, bool): mask = 1.0 - mask + progress.finish() return build_node_preview_result(result, (result, mask.clamp(0.0, 1.0)), prefix="imageops_draw") diff --git a/nodes/invert.py b/nodes/invert.py index 80d3af6..17a2f55 100644 --- a/nodes/invert.py +++ b/nodes/invert.py @@ -5,6 +5,7 @@ from ._helpers import ( _scalar, _select_media_tensor, ) +from ._progress import start_progress from ._preview import build_node_preview_result class ImageOpsInvert: @@ -24,15 +25,21 @@ class ImageOpsInvert: "optional": { "image": (MEDIA_INPUT_TYPE, {"tooltip": "Images/Video input. Accepts IMAGE batches and VIDEO frame sources.", "forceInput": True, "display_name": "Images/Video"}), "mask": ("MASK",), - } + }, + "hidden": { + "unique_id": "UNIQUE_ID", + }, } - def apply(self, image=None, bypass=False, invert_alpha=False, invert_mask=False, video=None, mask=None): + def apply(self, image=None, bypass=False, invert_alpha=False, invert_mask=False, video=None, mask=None, unique_id=None): src = _select_media_tensor(image, video) output_mask = _resolve_mask_output_source(mask, src, invert_mask=invert_mask) + progress = start_progress(unique_id=unique_id) if _scalar(bypass, bool): + progress.finish() return build_node_preview_result(src, (src, output_mask), prefix="imageops_invert") out = _apply_invert(src, invert_alpha=_scalar(invert_alpha, bool)) if mask is None and src.shape[-1] >= 4 and _scalar(invert_alpha, bool): output_mask = (1.0 - output_mask).clamp(0.0, 1.0) + progress.finish() return build_node_preview_result(out, (out, output_mask), prefix="imageops_invert") diff --git a/nodes/merge.py b/nodes/merge.py index 4bd1cd3..841a865 100644 --- a/nodes/merge.py +++ b/nodes/merge.py @@ -1,4 +1,5 @@ from ._helpers import MEDIA_INPUT_TYPE, _alpha_mask_from_image, _apply_merge, _apply_mask_to_image, _coerce_media_to_tensor, _prepare_effect_mask, _scalar +from ._progress import start_progress from ._preview import build_node_preview_result class ImageOpsMerge: @@ -20,21 +21,27 @@ class ImageOpsMerge: }, "optional": { "mask": ("MASK", {"tooltip": "Optional mask applied to the merged result", "display_name": "Mask"}), - } + }, + "hidden": { + "unique_id": "UNIQUE_ID", + }, } - def apply(self, A, B, bypass=False, mode="over", mix=1.0, invert_mask=False, mask=None): + def apply(self, A, B, bypass=False, mode="over", mix=1.0, invert_mask=False, mask=None, unique_id=None): A = _coerce_media_to_tensor(A, "A") B = _coerce_media_to_tensor(B, "B") effect_mask = _prepare_effect_mask(mask, A, invert_mask=invert_mask) + progress = start_progress(unique_id=unique_id) if _scalar(bypass, bool): output_mask = effect_mask if effect_mask is not None else _alpha_mask_from_image(A) if effect_mask is None and _scalar(invert_mask, bool): output_mask = (1.0 - output_mask).clamp(0.0, 1.0) + progress.finish() return build_node_preview_result(A, (A, output_mask), prefix="imageops_merge") out = _apply_merge(A, B, mode, mix) out = _apply_mask_to_image(A, out, effect_mask) output_mask = effect_mask if effect_mask is not None else _alpha_mask_from_image(out) if effect_mask is None and _scalar(invert_mask, bool): output_mask = (1.0 - output_mask).clamp(0.0, 1.0) + progress.finish() return build_node_preview_result(out, (out, output_mask), prefix="imageops_merge") diff --git a/nodes/noise.py b/nodes/noise.py index 221c73a..85a1f89 100644 --- a/nodes/noise.py +++ b/nodes/noise.py @@ -5,6 +5,7 @@ import math import torch from ._helpers import _hex_to_rgb01, _scalar +from ._progress import start_progress from ._preview import build_node_preview_result _NOISE_BASIS = ["perlin", "value", "white"] @@ -213,6 +214,8 @@ class ImageOpsNoise: "width": ("INT", {"default": 1024, "min": 64, "max": 8192, "step": 64}), "height": ("INT", {"default": 1024, "min": 64, "max": 8192, "step": 64}), "batch_size": ("INT", {"default": 1, "min": 1, "max": 256, "step": 1}), + "frame_length": ("INT", {"default": 0, "min": 0, "max": 256, "step": 1, "tooltip": "Animation length in frames. Set to 0 to use batch_size for backward compatibility."}), + "fps": ("FLOAT", {"default": 12.0, "min": 1.0, "max": 120.0, "step": 0.1, "round": 0.001}), "seed": ("INT", {"default": 0, "min": 0, "max": 2147483647, "step": 1}), "seed_step": ("INT", {"default": 1, "min": 0, "max": 1048576, "step": 1}), "basis": (_NOISE_BASIS, {"default": "perlin"}), @@ -229,7 +232,10 @@ class ImageOpsNoise: "invert": ("BOOLEAN", {"default": False}), "low_color": ("STRING", {"default": "#000000"}), "high_color": ("STRING", {"default": "#FFFFFF"}), - } + }, + "hidden": { + "unique_id": "UNIQUE_ID", + }, } def generate( @@ -237,6 +243,8 @@ class ImageOpsNoise: width=1024, height=1024, batch_size=1, + frame_length=0, + fps=12.0, seed=0, seed_step=1, basis="perlin", @@ -253,18 +261,23 @@ class ImageOpsNoise: invert=False, low_color="#000000", high_color="#FFFFFF", + unique_id=None, ): target_w = max(1, _scalar(width, int)) target_h = max(1, _scalar(height, int)) - frame_count = max(1, _scalar(batch_size, int)) + legacy_batch = max(1, _scalar(batch_size, int)) + requested_frame_length = max(0, _scalar(frame_length, int)) + frame_count = requested_frame_length if requested_frame_length > 0 else legacy_batch + preview_fps = max(1.0, _scalar(fps, float)) + progress = start_progress(total=frame_count, unique_id=unique_id) masks = [] images = [] for frame_index in range(frame_count): frame_seed = _scalar(seed, int, index=frame_index) + frame_index * _scalar(seed_step, int, index=frame_index) frame_gray = _synthesize_noise( - basis=basis, - fractal_mode=fractal_mode, + basis=_scalar(basis, str, index=frame_index), + fractal_mode=_scalar(fractal_mode, str, index=frame_index), width=target_w, height=target_h, scale=_scalar(scale, float, index=frame_index), @@ -282,8 +295,16 @@ class ImageOpsNoise: invert=_scalar(invert, bool, index=frame_index), ) masks.append(frame_gray.unsqueeze(0)) - images.append(_colorize(frame_gray, low_color=low_color, high_color=high_color).unsqueeze(0)) + images.append( + _colorize( + frame_gray, + low_color=_scalar(low_color, str, index=frame_index), + high_color=_scalar(high_color, str, index=frame_index), + ).unsqueeze(0) + ) + progress.update() mask = torch.cat(masks, dim=0).clamp(0.0, 1.0) image = torch.cat(images, dim=0).clamp(0.0, 1.0) - return build_node_preview_result(image, (image, mask), prefix="imageops_noise") + progress.finish() + return build_node_preview_result(image, (image, mask), prefix="imageops_noise", fps=preview_fps) diff --git a/nodes/preview.py b/nodes/preview.py index 84a7484..a8376fd 100644 --- a/nodes/preview.py +++ b/nodes/preview.py @@ -1,6 +1,7 @@ import torch from ._helpers import MEDIA_INPUT_TYPE, _alpha_mask_from_image, _coerce_mask_tensor, _mask_to_preview_image, _select_media_tensor +from ._progress import start_progress from ._preview import save_temp_images, save_temp_animated, save_temp_strip @@ -30,11 +31,13 @@ class ImageOpsPreview: "hidden": { "prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO", + "unique_id": "UNIQUE_ID", }, } - def preview(self, image=None, preview_target="auto", mode="images", mask=None, video=None, prompt=None, extra_pnginfo=None): + def preview(self, image=None, preview_target="auto", mode="images", mask=None, video=None, prompt=None, extra_pnginfo=None, unique_id=None): del prompt, extra_pnginfo + progress = start_progress(unique_id=unique_id) image_tensor = None if image is not None or video is not None: image_tensor = _select_media_tensor(image, video) @@ -70,4 +73,5 @@ class ImageOpsPreview: ui = {"images": [item]} if item else {"images": save_temp_images(preview_image, prefix="imageops_preview")} else: ui = {"images": save_temp_images(preview_image, prefix="imageops_preview")} + progress.finish() return {"ui": ui, "result": (output_image, output_mask)} diff --git a/nodes/transform.py b/nodes/transform.py index e87dbea..864e9ac 100644 --- a/nodes/transform.py +++ b/nodes/transform.py @@ -16,9 +16,19 @@ from ._helpers import ( _scalar, logger, ) +from ._progress import start_progress from ._preview import build_node_preview_result +def _resample_from_filter(filter_mode, index: int = 0): + normalized = _scalar(filter_mode, str, index=index).strip().lower() + return { + "nearest": Image.NEAREST, + "bilinear": Image.BILINEAR, + "bicubic": Image.BICUBIC, + }.get(normalized, Image.BILINEAR) + + def _mask_to_pil(mask_frame: torch.Tensor) -> Image.Image: array = (mask_frame.detach().cpu().float().clamp(0, 1).numpy() * 255.0 + 0.5).astype("uint8") return Image.fromarray(array, mode="L") @@ -29,7 +39,7 @@ def _pil_to_mask(pil: Image.Image, device: torch.device, dtype: torch.dtype) -> return torch.from_numpy(arr).unsqueeze(0).to(device=device, dtype=dtype) -def _transform_masked_source(source, input_mask, resample, translate_x, translate_y, rotate_deg, scale, expand): +def _transform_masked_source(source, input_mask, filter_mode, translate_x, translate_y, rotate_deg, scale, expand, progress=None): premult_rgb = source[..., :3].float().clamp(0.0, 1.0) * input_mask.unsqueeze(-1) processed_rgb = [] processed_mask = [] @@ -38,20 +48,29 @@ def _transform_masked_source(source, input_mask, resample, translate_x, translat for fi, pil in enumerate(_tensor_batch_to_pil_list(premult_rgb)): tx, ty = _scalar(translate_x, index=fi), _scalar(translate_y, index=fi) rd, sc = _scalar(rotate_deg, index=fi), _scalar(scale, index=fi) - processed_rgb.append(_pil_to_tensor(_transform_frame(pil, resample, tx, ty, rd, sc, expand))[..., :3]) + ex = _scalar(expand, bool, index=fi) + processed_rgb.append(_pil_to_tensor(_transform_frame(pil, _resample_from_filter(filter_mode, fi), tx, ty, rd, sc, ex))[..., :3]) + if progress is not None: + progress.update() for idx in range(input_mask.shape[0]): tx, ty = _scalar(translate_x, index=idx), _scalar(translate_y, index=idx) rd, sc = _scalar(rotate_deg, index=idx), _scalar(scale, index=idx) pil_mask = _mask_to_pil(input_mask[idx]) - processed_mask.append(_pil_to_mask(_transform_frame(pil_mask, resample, tx, ty, rd, sc, expand), source.device, source.dtype)) + ex = _scalar(expand, bool, index=idx) + processed_mask.append(_pil_to_mask(_transform_frame(pil_mask, _resample_from_filter(filter_mode, idx), tx, ty, rd, sc, ex), source.device, source.dtype)) + if progress is not None: + progress.update() if source.shape[-1] >= 4: for idx in range(source.shape[0]): tx, ty = _scalar(translate_x, index=idx), _scalar(translate_y, index=idx) rd, sc = _scalar(rotate_deg, index=idx), _scalar(scale, index=idx) pil_alpha = _mask_to_pil(source[idx, ..., 3]) - processed_alpha.append(_pil_to_mask(_transform_frame(pil_alpha, resample, tx, ty, rd, sc, expand), source.device, source.dtype)) + ex = _scalar(expand, bool, index=idx) + processed_alpha.append(_pil_to_mask(_transform_frame(pil_alpha, _resample_from_filter(filter_mode, idx), tx, ty, rd, sc, ex), source.device, source.dtype)) + if progress is not None: + progress.update() if not processed_rgb: raise ValueError("ImageOpsTransform received an empty image batch.") @@ -78,6 +97,7 @@ def _transform_frame(pil, resample, translate_x, translate_y, rotate_deg, scale, translate_x = _scalar(translate_x) translate_y = _scalar(translate_y) rotate_deg = _scalar(rotate_deg) + expand = _scalar(expand, bool) if abs(scale - 1.0) > EPSILON: nw, nh = max(1, int(round(base_w * scale))), max(1, int(round(base_h * scale))) if nw > MAX_SCALE_DIMENSION or nh > MAX_SCALE_DIMENSION: @@ -96,7 +116,7 @@ def _transform_frame(pil, resample, translate_x, translate_y, rotate_deg, scale, if abs(rotate_deg) > EPSILON: # Match compositor conventions used in Nuke/UI overlays: positive values rotate clockwise. - working = working.rotate(-rotate_deg, resample=resample, expand=_scalar(expand, bool)) + working = working.rotate(-rotate_deg, resample=resample, expand=expand) rw, rh = working.size if rw > MAX_SCALE_DIMENSION or rh > MAX_SCALE_DIMENSION: logger.error(f"Rotated dimensions ({rw}x{rh}) exceed maximum") @@ -141,52 +161,61 @@ class ImageOpsTransform: "optional": { "image": (MEDIA_INPUT_TYPE, {"tooltip": "Images/Video input. Accepts IMAGE batches and VIDEO frame sources.", "forceInput": True, "display_name": "Images/Video"}), "mask": ("MASK",), - } + }, + "hidden": { + "unique_id": "UNIQUE_ID", + }, } - def apply(self, image=None, bypass=False, translate_x=0, translate_y=0, rotate_deg=0.0, scale=1.0, filter="bilinear", expand=False, invert_mask=False, video=None, mask=None): + def apply(self, image=None, bypass=False, translate_x=0, translate_y=0, rotate_deg=0.0, scale=1.0, filter="bilinear", expand=False, invert_mask=False, video=None, mask=None, unique_id=None): source = _select_media_tensor(image, video) input_mask = _prepare_effect_mask(mask, source, invert_mask=invert_mask) output_mask_source = _resolve_mask_output_source(mask, source, invert_mask=invert_mask) + progress = start_progress(unique_id=unique_id) if _scalar(bypass, bool): + progress.finish() return build_node_preview_result(source, (source, output_mask_source), prefix="imageops_transform") - resample = { - "nearest": Image.NEAREST, - "bilinear": Image.BILINEAR, - "bicubic": Image.BICUBIC, - }.get(filter, Image.BILINEAR) - if input_mask is not None: + total_steps = int(source.shape[0]) + int(input_mask.shape[0]) + (int(source.shape[0]) if source.shape[-1] >= 4 else 0) + progress.update_absolute(0, total=max(1, total_steps)) result, output_mask = _transform_masked_source( source, input_mask, - resample, + filter, translate_x, translate_y, rotate_deg, scale, expand, + progress=progress, ) + progress.finish() return build_node_preview_result(result, (result, output_mask), prefix="imageops_transform") + progress.update_absolute(0, total=max(1, int(source.shape[0]) + int(output_mask_source.shape[0]))) processed_frames = [] processed_masks = [] for fi, pil in enumerate(_tensor_batch_to_pil_list(source)): tx, ty = _scalar(translate_x, index=fi), _scalar(translate_y, index=fi) rd, sc = _scalar(rotate_deg, index=fi), _scalar(scale, index=fi) - processed_frames.append(_pil_to_tensor(_transform_frame(pil, resample, tx, ty, rd, sc, expand))) + ex = _scalar(expand, bool, index=fi) + processed_frames.append(_pil_to_tensor(_transform_frame(pil, _resample_from_filter(filter, fi), tx, ty, rd, sc, ex))) + progress.update() for idx in range(output_mask_source.shape[0]): tx, ty = _scalar(translate_x, index=idx), _scalar(translate_y, index=idx) rd, sc = _scalar(rotate_deg, index=idx), _scalar(scale, index=idx) pil_mask = _mask_to_pil(output_mask_source[idx]) - transformed_mask = _transform_frame(pil_mask, resample, tx, ty, rd, sc, expand) + ex = _scalar(expand, bool, index=idx) + transformed_mask = _transform_frame(pil_mask, _resample_from_filter(filter, idx), tx, ty, rd, sc, ex) processed_masks.append(_pil_to_mask(transformed_mask, source.device, source.dtype)) + progress.update() if not processed_frames: raise ValueError("ImageOpsTransform received an empty image batch.") result = torch.cat(processed_frames, dim=0).to(device=source.device, dtype=source.dtype) output_mask = torch.cat(processed_masks, dim=0).to(device=source.device, dtype=source.dtype) + progress.finish() return build_node_preview_result(result, (result, output_mask), prefix="imageops_transform") diff --git a/src/preview/adapters/imageops.ts b/src/preview/adapters/imageops.ts index 39595b4..79ab146 100644 --- a/src/preview/adapters/imageops.ts +++ b/src/preview/adapters/imageops.ts @@ -60,21 +60,21 @@ export function imageOpsAdapter(): Adapter { } if (bypass && cls !== "ImageOpsDraw") return; if (cls === "ImageOpsColorAjust") { - return ops.colorAjust(ctx, canvasSize, node, inputs); + return ops.colorAjust(ctx, canvasSize, node, inputs, tick ?? 0); } else if (cls === "ImageOpsChannel") { - return ops.channel(ctx, canvasSize, node, outputSlot, inputs); + return ops.channel(ctx, canvasSize, node, outputSlot, inputs, tick ?? 0); } else if (cls === "ImageOpsCrop") { - return ops.crop(ctx, canvasSize, node, inputs); + return ops.crop(ctx, canvasSize, node, inputs, tick ?? 0); } else if (cls === "ImageOpsBlur") { - return ops.blur(ctx, canvasSize, node, inputs); + return ops.blur(ctx, canvasSize, node, inputs, tick ?? 0); } else if (cls === "ImageOpsTransform") { - return ops.transform(ctx, canvasSize, node, inputs); + return ops.transform(ctx, canvasSize, node, inputs, tick ?? 0); } else if (cls === "ImageOpsInvert") { - return ops.invert(ctx, canvasSize, node, inputs); + return ops.invert(ctx, canvasSize, node, inputs, tick ?? 0); } else if (cls === "ImageOpsClamp") { - return ops.clamp(ctx, canvasSize, node, inputs); + return ops.clamp(ctx, canvasSize, node, inputs, tick ?? 0); } else if (cls === "ImageOpsMerge") { - return ops.merge(ctx, canvasSize, node, inputs); + return ops.merge(ctx, canvasSize, node, inputs, undefined, tick ?? 0); } else if (cls === "ImageOpsComp") { return ops.comp(ctx, canvasSize, node, inputs); } else if (cls === "ImageOpsDistort") { diff --git a/src/preview/host.ts b/src/preview/host.ts index df50a37..838d0e4 100644 --- a/src/preview/host.ts +++ b/src/preview/host.ts @@ -555,9 +555,18 @@ function widgetHasAnimatedValues(value: unknown): boolean { return value.length === 1 ? widgetHasAnimatedValues(value[0]) : false; } -function hasProceduralAnimation(node: ComfyNode): boolean { +function widgetAnimatedLength(value: unknown): number { + if (!Array.isArray(value)) return 1; + if (value.length > 1) return value.length; + return value.length === 1 ? widgetAnimatedLength(value[0]) : 1; +} + +function getProceduralFrameCount(node: ComfyNode): number | null { const cls = String(node?.comfyClass ?? ""); - if (cls !== "ImageOpsNoise") return false; + const animatedLength = Math.max(1, ...(node.widgets ?? []).map((entry) => widgetAnimatedLength(entry?.value))); + if (cls !== "ImageOpsNoise") { + return animatedLength > 1 ? animatedLength : null; + } const widget = (name: string) => node.widgets?.find((entry) => entry?.name === name) ?? null; const numeric = (name: string, fallback: number = 0): number => { @@ -568,12 +577,39 @@ function hasProceduralAnimation(node: ComfyNode): boolean { }; const batchSize = Math.max(1, Math.round(numeric("batch_size", 1))); - if (batchSize <= 1) return false; + const frameLength = Math.max(0, Math.round(numeric("frame_length", 0))); + const frameCount = frameLength > 0 ? frameLength : batchSize; + return Math.max(frameCount, animatedLength); +} + +function hasProceduralAnimation(node: ComfyNode): boolean { + const cls = String(node?.comfyClass ?? ""); + const animatedWidgets = (node.widgets ?? []).some((entry) => widgetHasAnimatedValues(entry?.value)); + if (cls !== "ImageOpsNoise") return animatedWidgets && (getProceduralFrameCount(node) ?? 1) > 1; + + const widget = (name: string) => node.widgets?.find((entry) => entry?.name === name) ?? null; + const numeric = (name: string, fallback: number = 0): number => { + const value = widget(name)?.value; + if (Array.isArray(value) && value.length > 1) return 1; + const parsed = parseFloat(value as string); + return Number.isFinite(parsed) ? parsed : fallback; + }; + + const frameCount = getProceduralFrameCount(node) ?? 1; + if (frameCount <= 1) return false; if (numeric("seed_step", 0) !== 0) return true; if (numeric("frame_offset_x", 0) !== 0) return true; if (numeric("frame_offset_y", 0) !== 0) return true; - return (node.widgets ?? []).some((entry) => widgetHasAnimatedValues(entry?.value)); + return animatedWidgets; +} + +function getProceduralPlaybackFps(node: ComfyNode): number | null { + const cls = String(node?.comfyClass ?? ""); + if (cls !== "ImageOpsNoise") return null; + const widget = (name: string) => node.widgets?.find((entry) => entry?.name === name) ?? null; + const value = parseFloat(widget("fps")?.value as string); + return Number.isFinite(value) ? Math.max(1, value) : 12; } function getInputIndexByName(node: ComfyNode, name: string): number { @@ -2115,8 +2151,16 @@ export function registerImageOpsLivePreview(): void { } let tick = 0; + const proceduralFrameCount = getProceduralFrameCount(node); + const proceduralFps = proceduralFrameCount != null ? (getProceduralPlaybackFps(node) ?? 12) : null; + const startedAt = performance.now(); const loop = (): void => { - tick++; + if (proceduralFps != null) { + const rawTick = Math.floor(((performance.now() - startedAt) * proceduralFps) / 1000); + tick = proceduralFrameCount != null && proceduralFrameCount > 0 ? (rawTick % proceduralFrameCount) : rawTick; + } else { + tick++; + } renderNode(node, tick); st.rafId = requestAnimationFrame(loop); }; diff --git a/src/preview/ops.ts b/src/preview/ops.ts index 6ab3748..50aef73 100644 --- a/src/preview/ops.ts +++ b/src/preview/ops.ts @@ -474,9 +474,11 @@ function renderNoiseCanvas(node: ComfyNode, maskOnly: boolean = false, frameInde const width = Math.max(1, Math.round(numAny(node, ["width"], 1024))); const height = Math.max(1, Math.round(numAny(node, ["height"], 1024))); const batchSize = Math.max(1, Math.round(numAny(node, ["batch_size"], 1))); - const resolvedFrameIndex = ((Math.max(0, Math.round(frameIndex)) % batchSize) + batchSize) % batchSize; - const low = hexToRgb01(strAny(node, ["low_color"], "#000000")); - const high = hexToRgb01(strAny(node, ["high_color"], "#ffffff")); + const frameLength = Math.max(0, Math.round(numAny(node, ["frame_length"], 0))); + const frameCount = frameLength > 0 ? frameLength : batchSize; + const resolvedFrameIndex = ((Math.max(0, Math.round(frameIndex)) % frameCount) + frameCount) % frameCount; + const low = hexToRgb01(strAny(node, ["low_color"], "#000000", resolvedFrameIndex)); + const high = hexToRgb01(strAny(node, ["high_color"], "#ffffff", resolvedFrameIndex)); const grayValues = buildNoiseField(width, height, { basis: strAny(node, ["basis"], "perlin", resolvedFrameIndex), fractalMode: strAny(node, ["fractal_mode"], "fbm", resolvedFrameIndex), @@ -596,7 +598,7 @@ function renderDistortCanvas( const mapSource = strAny(node, ["map_source"], "source_channel", frameIndex).toLowerCase(); const centeredMap = boolAny(node, ["centered_map"], true, frameIndex); const invertMap = boolAny(node, ["invert_map"], false, frameIndex); - const effectMask = mapSource === "mask" ? null : resolvePreviewMaskCanvas(node, source, rawMask); + const effectMask = mapSource === "mask" ? null : resolvePreviewMaskCanvas(node, source, rawMask, frameIndex); let previewMask: HTMLCanvasElement | null = null; let xField: Float32Array; @@ -1153,10 +1155,11 @@ function resolvePreviewMaskCanvas( node: ComfyNode, source: HTMLCanvasElement, rawMask: HTMLCanvasElement | null, + frameIndex: number = 0, ): HTMLCanvasElement | null { if (!rawMask) return null; const matte = buildMaskAlphaCanvas(rawMask, source.width || 1, source.height || 1); - return boolAny(node, ["invert_mask"], false) ? invertMaskCanvas(matte) : matte; + return boolAny(node, ["invert_mask"], false, frameIndex) ? invertMaskCanvas(matte) : matte; } function compositeProcessedWithMask( @@ -1188,9 +1191,10 @@ function renderMaskedEffectPreview( premultBeforeProcess?: boolean; processMask?: ((mask: HTMLCanvasElement) => HTMLCanvasElement) | null; baseCanvas?: HTMLCanvasElement | null; + frameIndex?: number; } = {}, ): HTMLCanvasElement { - const mask = resolvePreviewMaskCanvas(node, source, rawMask); + const mask = resolvePreviewMaskCanvas(node, source, rawMask, options.frameIndex ?? 0); if (!mask) return processImage(source); const processed = processImage(options.premultBeforeProcess ? premultLayerWithMask(source, mask) : source); const processedMask = options.processMask @@ -1616,7 +1620,7 @@ function mergeChannelInputs(inputs: HTMLCanvasElement[], mode: string): HTMLCanv } export const ops = { - colorAjust(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[] = []): HTMLCanvasElement { + colorAjust(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[] = [], frameIndex: number = 0): HTMLCanvasElement { const source = inputs[0] ?? ctx.canvas; const rawMask = inputs[1] ?? null; return renderMaskedEffectPreview( @@ -1625,24 +1629,25 @@ export const ops = { rawMask, (input) => applyEffectToCanvas(input, (effectCtx, width, height) => { applyColorCorrectReference(effectCtx, width, height, - numAny(node, ["temperature"], 0), - numAny(node, ["hue", "hue_deg"], 0), - numAny(node, ["brightness"], 0), - numAny(node, ["contrast"], 0), - numAny(node, ["saturation", "sat"], 0), - numAny(node, ["gamma"], 1), + numAny(node, ["temperature"], 0, frameIndex), + numAny(node, ["hue", "hue_deg"], 0, frameIndex), + numAny(node, ["brightness"], 0, frameIndex), + numAny(node, ["contrast"], 0, frameIndex), + numAny(node, ["saturation", "sat"], 0, frameIndex), + numAny(node, ["gamma"], 1, frameIndex), ); }), + { frameIndex }, ); }, - colorCorrect(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[] = []): HTMLCanvasElement { - return ops.colorAjust(ctx, W, node, inputs); + colorCorrect(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[] = [], frameIndex: number = 0): HTMLCanvasElement { + return ops.colorAjust(ctx, W, node, inputs, frameIndex); }, - blur(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[] = []): HTMLCanvasElement { + blur(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[] = [], frameIndex: number = 0): HTMLCanvasElement { const source = inputs[0] ?? ctx.canvas; const rawMask = inputs[1] ?? null; - const radius = numAny(node, ["radius", "blur", "blur_radius"], 0); - const sigma = numAny(node, ["sigma", "radius", "blur"], radius); + const radius = numAny(node, ["radius", "blur", "blur_radius"], 0, frameIndex); + const sigma = numAny(node, ["sigma", "radius", "blur"], radius, frameIndex); return renderMaskedEffectPreview( node, source, @@ -1651,6 +1656,7 @@ export const ops = { applyBlur(effectCtx, width, height, radius, sigma); }), { + frameIndex, premultBeforeProcess: true, processMask: (mask) => applyEffectToCanvas(mask, (effectCtx, width, height) => { applyBlur(effectCtx, width, height, radius, sigma); @@ -1658,10 +1664,10 @@ export const ops = { }, ); }, - channel(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, outputSlot?: number | null, inputs: HTMLCanvasElement[] = []): HTMLCanvasElement { + channel(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, outputSlot?: number | null, inputs: HTMLCanvasElement[] = [], frameIndex: number = 0): HTMLCanvasElement { const source = inputs[0] ?? ctx.canvas; const rawMask = inputs[1] ?? null; - const splitMode = strAny(node, ["mode"], "RGBA"); + const splitMode = strAny(node, ["mode"], "RGBA", frameIndex); const hasSingleChannelWidget = !!wAny(node, ["channel"]); if (!hasSingleChannelWidget && outputSlot != null) { return extractSplitChannelCanvas(source, outputSlot, splitMode); @@ -1671,11 +1677,12 @@ export const ops = { source, rawMask, (input) => applyEffectToCanvas(input, (effectCtx, width, height) => { - applyChannel(effectCtx, width, height, strAny(node, ["channel"], "Red")); + applyChannel(effectCtx, width, height, strAny(node, ["channel"], "Red", frameIndex)); }), + { frameIndex }, ); }, - crop(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[] = []): HTMLCanvasElement { + crop(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[] = [], frameIndex: number = 0): HTMLCanvasElement { const source = inputs[0] ?? ctx.canvas; const rawMask = inputs[1] ?? null; return renderMaskedEffectPreview( @@ -1687,20 +1694,21 @@ export const ops = { node, width, height, - str(node, "aspect_ratio", "custom"), - num(node, "width", width), - num(node, "height", height), + str(node, "aspect_ratio", "custom", frameIndex), + num(node, "width", width, frameIndex), + num(node, "height", height, frameIndex), )), { + frameIndex, premultBeforeProcess: true, processMask: (mask) => applyEffectToCanvas(mask, (effectCtx, width, height) => applyCrop( effectCtx, node, width, height, - str(node, "aspect_ratio", "custom"), - num(node, "width", width), - num(node, "height", height), + str(node, "aspect_ratio", "custom", frameIndex), + num(node, "width", width, frameIndex), + num(node, "height", height, frameIndex), )), }, ); @@ -1740,37 +1748,37 @@ export const ops = { const height = Math.max(1, Math.round(cropRegion?.height ?? numAny(bboxNode ?? node, ["height", "crop_h"], sourceHeight))); return cropRectCanvas(source, x, y, Math.min(width, sourceWidth - x), Math.min(height, sourceHeight - y)); }, - transform(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[] = []): HTMLCanvasElement { + transform(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[] = [], frameIndex: number = 0): HTMLCanvasElement { const source = inputs[0] ?? ctx.canvas; const rawMask = inputs[1] ?? null; const transformImage = (input: HTMLCanvasElement): HTMLCanvasElement => { let working = input; - const mirror = strAny(node, ["mirror"], "none").toLowerCase(); - const flipMethod = strAny(node, ["flip_method"], ""); + const mirror = strAny(node, ["mirror"], "none", frameIndex).toLowerCase(); + const flipMethod = strAny(node, ["flip_method"], "", frameIndex); const horizontal = mirror === "horizontal" || flipMethod.startsWith("y"); const vertical = mirror === "vertical" || flipMethod.startsWith("x"); working = flipCanvas(working, horizontal, vertical); - const rotationLabel = strAny(node, ["rotation"], ""); + const rotationLabel = strAny(node, ["rotation"], "", frameIndex); if (rotationLabel.startsWith("90")) working = rotateDiscrete(working, 1); else if (rotationLabel.startsWith("180")) working = rotateDiscrete(working, 2); else if (rotationLabel.startsWith("270")) working = rotateDiscrete(working, 3); - const aspectRatio = numAny(node, ["aspect_ratio"], 1); + const aspectRatio = numAny(node, ["aspect_ratio"], 1, frameIndex); if (Math.abs(aspectRatio - 1) > 0.0001) { const scaled = makeCanvas(working.width || 1, Math.max(1, Math.round((working.height || 1) * aspectRatio))); const sctx = scaled.getContext("2d")!; - setResampleMode(sctx, normalizeFilterName(strAny(node, ["upscale_method", "interpolation", "transform_method", "filter"], "bilinear"))); + setResampleMode(sctx, normalizeFilterName(strAny(node, ["upscale_method", "interpolation", "transform_method", "filter"], "bilinear", frameIndex))); sctx.drawImage(working, 0, 0, scaled.width, scaled.height); working = scaled; } - const tx = numAny(node, ["translate_x", "x", "shift_x"], 0); - const ty = numAny(node, ["translate_y", "y", "shift_y"], 0); - const rot = numAny(node, ["rotate_deg", "rotate"], 0); - const scale = numAny(node, ["scale"], 1); - const filter = normalizeFilterName(strAny(node, ["filter", "upscale_method", "interpolation", "transform_method"], "bilinear")); - const expand = boolAny(node, ["expand"], false); + const tx = numAny(node, ["translate_x", "x", "shift_x"], 0, frameIndex); + const ty = numAny(node, ["translate_y", "y", "shift_y"], 0, frameIndex); + const rot = numAny(node, ["rotate_deg", "rotate"], 0, frameIndex); + const scale = numAny(node, ["scale"], 1, frameIndex); + const filter = normalizeFilterName(strAny(node, ["filter", "upscale_method", "interpolation", "transform_method"], "bilinear", frameIndex)); + const expand = boolAny(node, ["expand"], false, frameIndex); return applyEffectToCanvas(working, (effectCtx, width, height) => { return applyTransform(effectCtx, width, height, tx, ty, rot, scale, filter, expand); @@ -1783,6 +1791,7 @@ export const ops = { rawMask, transformImage, { + frameIndex, premultBeforeProcess: true, processMask: transformImage, }, @@ -1806,7 +1815,7 @@ export const ops = { numAny(node, ["value", "val"], 1), ); }, - invert(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[] = []): HTMLCanvasElement { + invert(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[] = [], frameIndex: number = 0): HTMLCanvasElement { const source = inputs[0] ?? ctx.canvas; const rawMask = inputs[1] ?? null; return renderMaskedEffectPreview( @@ -1814,11 +1823,12 @@ export const ops = { source, rawMask, (input) => applyEffectToCanvas(input, (effectCtx, width, height) => { - applyInvert(effectCtx, width, height, boolAny(node, ["invert_alpha"], false)); + applyInvert(effectCtx, width, height, boolAny(node, ["invert_alpha"], false, frameIndex)); }), + { frameIndex }, ); }, - clamp(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[] = []): HTMLCanvasElement { + clamp(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[] = [], frameIndex: number = 0): HTMLCanvasElement { const source = inputs[0] ?? ctx.canvas; const rawMask = inputs[1] ?? null; return renderMaskedEffectPreview( @@ -1826,8 +1836,9 @@ export const ops = { source, rawMask, (input) => applyEffectToCanvas(input, (effectCtx, width, height) => { - applyClamp(effectCtx, width, height, numAny(node, ["min_v", "min"], 0), numAny(node, ["max_v", "max"], 1)); + applyClamp(effectCtx, width, height, numAny(node, ["min_v", "min"], 0, frameIndex), numAny(node, ["max_v", "max"], 1, frameIndex)); }), + { frameIndex }, ); }, sharpen(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode): void { @@ -1856,20 +1867,20 @@ export const ops = { const { width, height } = getCanvasDimensions(ctx); applyLumaKey(ctx,width,height, numAny(node,["low"],0.1), numAny(node,["high"],0.9), numAny(node,["softness"],0.05)); }, - merge(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, topCanvasOrInputs: HTMLCanvasElement | HTMLCanvasElement[], _opts?: any): HTMLCanvasElement { + merge(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, topCanvasOrInputs: HTMLCanvasElement | HTMLCanvasElement[], _opts?: any, frameIndex: number = 0): HTMLCanvasElement { const inputs = Array.isArray(topCanvasOrInputs) ? topCanvasOrInputs : [ctx.canvas, topCanvasOrInputs]; const base = inputs[0] ?? ctx.canvas; const topCanvas = inputs[1] ?? null; if (!topCanvas) return fitCanvas(base, base.width || 1, base.height || 1); - const mode = strAny(node, ["mode", "blend_mode"], "over"); - const rawOpacity = w(node, "opacity") ? num(node, "opacity", 100) : numAny(node, ["mix", "factor", "fade_factor", "blend_factor", "start_level", "end_level"], 1); + const mode = strAny(node, ["mode", "blend_mode"], "over", frameIndex); + const rawOpacity = w(node, "opacity") ? num(node, "opacity", 100, frameIndex) : numAny(node, ["mix", "factor", "fade_factor", "blend_factor", "start_level", "end_level"], 1, frameIndex); const opacity = rawOpacity > 1 ? rawOpacity / 100 : rawOpacity; const merged = applyEffectToCanvas(base, (effectCtx, width, height) => { blend(effectCtx, width, height, topCanvas, mode, opacity); }); - const effectMask = resolvePreviewMaskCanvas(node, base, inputs[2] ?? null); + const effectMask = resolvePreviewMaskCanvas(node, base, inputs[2] ?? null, frameIndex); return effectMask ? compositeProcessedWithMask(base, merged, effectMask) : merged; }, resize(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode): HTMLCanvasElement { @@ -1989,7 +2000,7 @@ export const ops = { imageOpsMask(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, cls: string, inputs: HTMLCanvasElement[] = [], frameIndex: number = 0): HTMLCanvasElement | null { const source = inputs[0] ?? ctx.canvas; const rawMask = inputs[1] ?? null; - const resolvedMask = resolvePreviewMaskCanvas(node, source, rawMask); + const resolvedMask = resolvePreviewMaskCanvas(node, source, rawMask, frameIndex); if (cls === "ImageOpsNoise") { return renderNoiseCanvas(node, true, frameIndex); @@ -2000,24 +2011,24 @@ export const ops = { } if (cls === "ImageOpsBlur") { - const radius = numAny(node, ["radius", "blur", "blur_radius"], 0); - const sigma = numAny(node, ["sigma", "radius", "blur"], radius); + const radius = numAny(node, ["radius", "blur", "blur_radius"], 0, frameIndex); + const sigma = numAny(node, ["sigma", "radius", "blur"], radius, frameIndex); return applyEffectToCanvas(resolvedMask ?? alphaMaskCanvas(source), (effectCtx, width, height) => { applyBlur(effectCtx, width, height, radius, sigma); }); } if (cls === "ImageOpsTransform") { - return ops.transform(ctx, W, node, [resolvedMask ?? alphaMaskCanvas(source)]); + return ops.transform(ctx, W, node, [resolvedMask ?? alphaMaskCanvas(source)], frameIndex); } if (cls === "ImageOpsCrop") { - return ops.crop(ctx, W, node, [resolvedMask ?? alphaMaskCanvas(source)]); + return ops.crop(ctx, W, node, [resolvedMask ?? alphaMaskCanvas(source)], frameIndex); } if (cls === "ImageOpsChannel") { const extracted = applyEffectToCanvas(source, (effectCtx, width, height) => { - applyChannel(effectCtx, width, height, strAny(node, ["channel"], "Red")); + applyChannel(effectCtx, width, height, strAny(node, ["channel"], "Red", frameIndex)); }); return resolvedMask ? premultLayerWithMask(extracted, resolvedMask) : extracted; } @@ -2025,13 +2036,13 @@ export const ops = { if (cls === "ImageOpsClamp") { if (!resolvedMask) return alphaMaskCanvas(source); return applyEffectToCanvas(resolvedMask, (effectCtx, width, height) => { - applyClamp(effectCtx, width, height, numAny(node, ["min_v", "min"], 0), numAny(node, ["max_v", "max"], 1)); + applyClamp(effectCtx, width, height, numAny(node, ["min_v", "min"], 0, frameIndex), numAny(node, ["max_v", "max"], 1, frameIndex)); }); } if (cls === "ImageOpsInvert") { let mask = resolvedMask ?? alphaMaskCanvas(source); - if (!resolvedMask && boolAny(node, ["invert_alpha"], false)) { + if (!resolvedMask && boolAny(node, ["invert_alpha"], false, frameIndex)) { mask = invertMaskCanvas(mask); } return mask; @@ -2039,7 +2050,7 @@ export const ops = { if (cls === "ImageOpsMerge") { if (resolvedMask) return resolvedMask; - const merged = ops.merge(ctx, W, node, inputs); + const merged = ops.merge(ctx, W, node, inputs, undefined, frameIndex); return alphaMaskCanvas(merged); }