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:
@@ -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 |
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user