feat: Add progress tracking to image operations

- Introduced a new progress tracking system for image operations, allowing for better user feedback during processing.
- Updated `invert.py`, `merge.py`, `noise.py`, `preview.py`, and `transform.py` to utilize the new progress tracking.
- Added unique ID handling for progress tracking to each operation.
- Enhanced the noise generation to support frame length and FPS settings.
- Modified the image operations adapter to accommodate changes in function signatures for progress tracking.
- Implemented procedural animation handling in the preview system to support animated values.
- Created a new `nodes_progress.py` file to encapsulate the progress tracking logic.
This commit is contained in:
MajoorWaldi
2026-03-27 08:49:56 +01:00
parent 8f0249b81c
commit 314efcdbe6
22 changed files with 592 additions and 239 deletions
+1 -1
View File
@@ -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 |
+8 -8
View File
@@ -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") {
+44 -5
View File
@@ -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);
};
+71 -62
View File
@@ -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") {
+29 -4
View File
@@ -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),
+44
View File
@@ -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)
+9 -2
View File
@@ -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")
+8 -1
View File
@@ -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")
+9 -2
View File
@@ -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")
+8
View File
@@ -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")
+11
View File
@@ -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")
+8 -1
View File
@@ -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")
+114 -56
View File
@@ -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")
+8
View File
@@ -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")
+9 -2
View File
@@ -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")
+9 -2
View File
@@ -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")
+27 -6
View File
@@ -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)
+5 -1
View File
@@ -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)}
+45 -16
View File
@@ -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")
+8 -8
View File
@@ -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") {
+49 -5
View File
@@ -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);
};
+68 -57
View File
@@ -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);
}