Files
MajoorWaldi-ComfyUI-Majoor-…/js/preview/ops/implementation.js
T

3500 lines
154 KiB
JavaScript

import { getOpsConstants, initOpsConstants } from "../constants.js";
import { clampCropCenter, clampCropScale, computeCropRect, resolveCropAspectRatio } from "../crop.js";
import { computeCompRect, getCompLayerOutputCorners, getCompSlots, hasCompLayerCornerPin, syncCompLayers } from "../comp.js";
import { renderDrawPreview, resolveDrawOverlayCanvas } from "../draw.js";
import { acquireCanvas, releaseCanvas } from "../shared/canvas-pool.js";
import { applyColorCorrectGL } from "../shared/webgl-color.js";
import { setWidgetValue } from "../shared/widgets.js";
import { blendChannel01 } from "../shared/blend-modes.js";
initOpsConstants();
function w(node, name) {
return node?.widgets?.find((x) => x?.name === name) ?? null;
}
function widgetScalarValue(value, index = 0) {
let current = value;
while (Array.isArray(current) && current.length > 0) {
const resolvedIndex = Math.max(0, Math.min(current.length - 1, index));
current = current[resolvedIndex];
}
return current;
}
function num(node, name, fallback = 0, index = 0) {
const v = widgetScalarValue(w(node, name)?.value, index);
const n = parseFloat(v);
return Number.isFinite(n) ? n : fallback;
}
function str(node, name, fallback = "", index = 0) {
const v = widgetScalarValue(w(node, name)?.value, index);
return typeof v === "string" ? v : fallback;
}
function bool(node, name, fallback = false, index = 0) {
const v = widgetScalarValue(w(node, name)?.value, index);
if (typeof v === "boolean") return v;
if (typeof v === "number") return !!v;
if (typeof v === "string") return v.toLowerCase() === "true";
return fallback;
}
function wAny(node, names) {
for (const name of names) {
const found = w(node, name);
if (found) return found;
}
return null;
}
function numAny(node, names, fallback = 0, index = 0) {
const v = widgetScalarValue(wAny(node, names)?.value, index);
const n = parseFloat(v);
return Number.isFinite(n) ? n : fallback;
}
function strAny(node, names, fallback = "", index = 0) {
const v = widgetScalarValue(wAny(node, names)?.value, index);
return typeof v === "string" ? v : fallback;
}
function resolveConnectedString(node, inputName) {
const inputs = node?.inputs ?? [];
const slotIndex = inputs.findIndex((inp) => inp?.name === inputName);
if (slotIndex < 0) return null;
const link = inputs[slotIndex]?.link;
if (link == null) return null;
const linkData = node?.graph?.links?.[link];
if (!linkData) return null;
const upNode = node?.graph?.getNodeById?.(linkData.origin_id);
if (!upNode) return null;
const upWidget = (upNode?.widgets ?? []).find((w2) => typeof w2?.value === "string");
return upWidget ? String(upWidget.value) : null;
}
function boolAny(node, names, fallback = false, index = 0) {
const v = widgetScalarValue(wAny(node, names)?.value, index);
if (typeof v === "boolean") return v;
if (typeof v === "number") return !!v;
if (typeof v === "string") return v.toLowerCase() === "true";
return fallback;
}
function clamp01(x) {
return Math.max(0, Math.min(1, x));
}
function luma01(r, g, b, lw) {
return lw[0] * r + lw[1] * g + lw[2] * b;
}
function getImageData(ctx, W, H) {
return ctx.getImageData(0, 0, W, H);
}
function putImageData(ctx, img) {
ctx.putImageData(img, 0, 0);
}
function getCanvasDimensions(ctx) {
return {
width: Math.max(1, ctx.canvas.width || 1),
height: Math.max(1, ctx.canvas.height || 1)
};
}
function makeCanvas(width, height) {
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(width));
canvas.height = Math.max(1, Math.round(height));
return canvas;
}
const preparedMaskCache = /* @__PURE__ */ new WeakMap();
const canvasFieldCache = /* @__PURE__ */ new WeakMap();
function prepareMaskCanvasInPlace(canvas) {
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) return canvas;
const width = canvas.width || 1;
const height = canvas.height || 1;
const image = ctx.getImageData(0, 0, width, height);
const data = image.data;
const weights = getOpsConstants().luma_weights;
for (let index = 0; index < data.length; index += 4) {
const alpha = data[index + 3] / 255;
const luma = luma01(data[index] / 255, data[index + 1] / 255, data[index + 2] / 255, weights);
const matte = Math.round(clamp01(luma * alpha) * 255);
const rgb = matte > 0 ? 255 : 0;
data[index] = rgb;
data[index + 1] = rgb;
data[index + 2] = rgb;
data[index + 3] = matte;
}
ctx.putImageData(image, 0, 0);
preparedMaskCache.delete(canvas);
canvasFieldCache.delete(canvas);
return canvas;
}
function markPreparedMaskCanvas(canvas) {
prepareMaskCanvasInPlace(canvas);
canvas.__imageopsPreparedMask = true;
return canvas;
}
function isPreparedMaskCanvas(canvas) {
return !!canvas && canvas.__imageopsPreparedMask === true;
}
function normalizeFilterName(filter) {
const value = String(filter || "bilinear").toLowerCase();
if (value === "nearest") return "nearest-exact";
if (value === "linear") return "bilinear";
if (value === "cubic") return "bicubic";
return value;
}
function parseHexColor(value) {
const raw = String(value || "").trim();
if (/^#[0-9a-f]{6}$/i.test(raw)) return raw;
if (/^#[0-9a-f]{3}$/i.test(raw)) {
const chars = raw.slice(1).split("");
return `#${chars.map((ch) => `${ch}${ch}`).join("")}`;
}
const rgb = raw.match(/^(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})$/);
if (rgb) {
const channels = rgb.slice(1, 4).map((part) => {
const channel = Math.max(0, Math.min(255, parseInt(part, 10) || 0));
return channel.toString(16).padStart(2, "0");
});
return `#${channels.join("")}`;
}
if (raw.toLowerCase() === "white") return "#ffffff";
if (raw.toLowerCase() === "red") return "#ff0000";
if (raw.toLowerCase() === "green") return "#00ff00";
if (raw.toLowerCase() === "blue") return "#0000ff";
return "#000000";
}
function imageLikeInputName(name) {
return /image|images|source|destination|background|foreground|layer|red|green|blue|channel|input/i.test(name);
}
function getPreferredInputIndexes(node) {
const indexes = [];
for (let index = 0; index < (node.inputs?.length ?? 0); index++) {
const slot = node.inputs?.[index];
const name = String(slot?.name ?? "");
const type = String(slot?.type ?? "");
if (/mask|bbox|box|region/i.test(name) || /mask|bbox|box/i.test(type)) continue;
if (slot?.link == null && !node.getInputLink?.(index)) continue;
if (imageLikeInputName(name) || /image|video/i.test(type)) indexes.push(index);
}
if (indexes.length > 0) return indexes;
return [0];
}
function resizeWithMode(source, width, height, filter, mode, fillColor = "#000000", cropPosition = "center") {
const targetWidth = Math.max(1, Math.round(width));
const targetHeight = Math.max(1, Math.round(height));
const output = makeCanvas(targetWidth, targetHeight);
const octx = output.getContext("2d", { willReadFrequently: true });
const normalizedMode = String(mode || "stretch").toLowerCase();
const srcW = Math.max(1, source.width || 1);
const srcH = Math.max(1, source.height || 1);
setResampleMode(octx, filter);
if (normalizedMode.includes("pad") || normalizedMode.includes("pillarbox")) {
octx.fillStyle = parseHexColor(fillColor);
octx.fillRect(0, 0, targetWidth, targetHeight);
} else {
octx.clearRect(0, 0, targetWidth, targetHeight);
}
if (normalizedMode === "stretch" || normalizedMode === "disabled" || normalizedMode === "scale dimensions") {
octx.drawImage(source, 0, 0, targetWidth, targetHeight);
return output;
}
const fitScale = Math.min(targetWidth / srcW, targetHeight / srcH);
const fillScale = Math.max(targetWidth / srcW, targetHeight / srcH);
const useFill = normalizedMode.includes("fill") || normalizedMode.includes("crop") || normalizedMode === "center";
const scale = useFill ? fillScale : fitScale;
const drawWidth = Math.max(1, Math.round(srcW * scale));
const drawHeight = Math.max(1, Math.round(srcH * scale));
let dx = Math.round((targetWidth - drawWidth) / 2);
let dy = Math.round((targetHeight - drawHeight) / 2);
if (cropPosition === "top") dy = 0;
if (cropPosition === "bottom") dy = targetHeight - drawHeight;
if (cropPosition === "left") dx = 0;
if (cropPosition === "right") dx = targetWidth - drawWidth;
octx.drawImage(source, dx, dy, drawWidth, drawHeight);
return output;
}
function fitCanvas(source, width, height) {
if ((source.width || 1) === Math.max(1, Math.round(width)) && (source.height || 1) === Math.max(1, Math.round(height))) {
return source;
}
const output = makeCanvas(width, height);
const octx = output.getContext("2d", { willReadFrequently: true });
setResampleMode(octx, "bicubic");
octx.clearRect(0, 0, output.width, output.height);
octx.drawImage(source, 0, 0, output.width, output.height);
if (isPreparedMaskCanvas(source)) {
markPreparedMaskCanvas(output);
}
return output;
}
function flipCanvas(source, horizontal, vertical) {
if (!horizontal && !vertical) return source;
const output = makeCanvas(source.width || 1, source.height || 1);
const octx = output.getContext("2d", { willReadFrequently: true });
octx.save();
octx.translate(horizontal ? output.width : 0, vertical ? output.height : 0);
octx.scale(horizontal ? -1 : 1, vertical ? -1 : 1);
octx.drawImage(source, 0, 0, output.width, output.height);
octx.restore();
return output;
}
function rotateDiscrete(source, quarterTurns) {
const turns = (quarterTurns % 4 + 4) % 4;
if (turns === 0) return source;
const swap = turns % 2 === 1;
const output = makeCanvas(swap ? source.height : source.width, swap ? source.width : source.height);
const octx = output.getContext("2d", { willReadFrequently: true });
octx.translate(output.width / 2, output.height / 2);
octx.rotate(turns * Math.PI / 2);
octx.drawImage(source, -source.width / 2, -source.height / 2);
return output;
}
function computeMaskBounds(maskCanvas) {
const ctx = maskCanvas.getContext("2d", { willReadFrequently: true });
if (!ctx) return null;
const image = ctx.getImageData(0, 0, maskCanvas.width || 1, maskCanvas.height || 1);
const data = image.data;
let minX = maskCanvas.width;
let minY = maskCanvas.height;
let maxX = -1;
let maxY = -1;
for (let y = 0; y < maskCanvas.height; y++) {
for (let x = 0; x < maskCanvas.width; x++) {
const offset = (y * maskCanvas.width + x) * 4;
const alpha = data[offset + 3] / 255;
const luma = luma01(data[offset] / 255, data[offset + 1] / 255, data[offset + 2] / 255, getOpsConstants().luma_weights);
if (alpha * luma <= 1e-3) continue;
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
}
}
if (maxX < minX || maxY < minY) return null;
return { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 };
}
function compositeAt(base, top, mode, opacity, x, y, width, height) {
const output = makeCanvas(base.width || 1, base.height || 1);
const octx = output.getContext("2d", { willReadFrequently: true });
octx.clearRect(0, 0, output.width, output.height);
octx.drawImage(base, 0, 0, output.width, output.height);
octx.save();
octx.globalAlpha = clamp01(opacity);
octx.globalCompositeOperation = compModeToCanvasOp(mode);
octx.imageSmoothingEnabled = true;
octx.imageSmoothingQuality = "high";
octx.drawImage(top, x, y, width, height);
octx.restore();
return output;
}
function compModeToCanvasOp(mode) {
const normalized = String(mode || "over").toLowerCase().replace(/[-\s]+/g, "_");
if (normalized === "add") return "lighter";
if (normalized === "multiply") return "multiply";
if (normalized === "screen") return "screen";
if (normalized === "overlay") return "overlay";
if (normalized === "soft_light") return "soft-light";
if (normalized === "difference") return "difference";
if (normalized === "lighten") return "lighten";
if (normalized === "darken") return "darken";
if (normalized === "color_dodge") return "color-dodge";
if (normalized === "color_burn") return "color-burn";
if (normalized === "exclusion") return "exclusion";
return "source-over";
}
function hexToRgb01(value) {
const hex = parseHexColor(value);
return [
parseInt(hex.slice(1, 3), 16) / 255,
parseInt(hex.slice(3, 5), 16) / 255,
parseInt(hex.slice(5, 7), 16) / 255
];
}
function renderConstantCanvas(node, maskOnly = false) {
const width = Math.max(1, Math.round(numAny(node, ["width"], 1024)));
const height = Math.max(1, Math.round(numAny(node, ["height"], 1024)));
const alpha = Math.max(0, Math.min(1, numAny(node, ["alpha"], 1)));
const canvas = makeCanvas(width, height);
const out = canvas.getContext("2d", { willReadFrequently: true });
if (maskOnly) {
out.fillStyle = `rgba(255,255,255,${alpha})`;
out.fillRect(0, 0, width, height);
return markPreparedMaskCanvas(canvas);
}
const mode = strAny(node, ["mode"], "constant").toLowerCase().replace(/[-\s]+/g, "_");
const color = parseHexColor(strAny(node, ["color"], "#ffffff"));
const colorB = parseHexColor(strAny(node, ["color_b"], "#000000"));
if (mode === "checkerboard") {
const tile = Math.max(1, Math.round(numAny(node, ["tile_size"], 64)));
const offsetX = Math.round(numAny(node, ["offset_x"], 0));
const offsetY = Math.round(numAny(node, ["offset_y"], 0));
for (let y = 0; y < height; y += tile) {
for (let x = 0; x < width; x += tile) {
const ix = Math.floor((x + offsetX) / tile);
const iy = Math.floor((y + offsetY) / tile);
out.fillStyle = (ix + iy & 1) === 0 ? color : colorB;
out.fillRect(x, y, Math.min(tile, width - x), Math.min(tile, height - y));
}
}
} else {
out.fillStyle = color;
out.fillRect(0, 0, width, height);
}
if (alpha < 1) {
const img = out.getImageData(0, 0, width, height);
const a = Math.round(alpha * 255);
for (let i = 3; i < img.data.length; i += 4) img.data[i] = a;
out.putImageData(img, 0, 0);
}
return canvas;
}
function applyRampCurve(value, mode) {
const normalized = String(mode || "linear").toLowerCase().replace(/[-\s]+/g, "_");
if (normalized === "ease_in") return value * value;
if (normalized === "ease_out") return 1 - (1 - value) * (1 - value);
if (normalized === "smoothstep") return value * value * (3 - 2 * value);
return value;
}
function renderRampCanvas(node, maskOnly = false) {
const width = Math.max(1, Math.round(numAny(node, ["width"], 1024)));
const height = Math.max(1, Math.round(numAny(node, ["height"], 1024)));
const alpha = Math.max(0, Math.min(1, numAny(node, ["alpha"], 1)));
const canvas = makeCanvas(width, height);
const out = canvas.getContext("2d", { willReadFrequently: true });
if (maskOnly) {
out.fillStyle = `rgba(255,255,255,${alpha})`;
out.fillRect(0, 0, width, height);
return markPreparedMaskCanvas(canvas);
}
const colorA = hexToRgb01(strAny(node, ["color_a"], "#ffffff"));
const colorB = hexToRgb01(strAny(node, ["color_b"], "#000000"));
const sx = numAny(node, ["start_x"], 0);
const sy = numAny(node, ["start_y"], 0.5);
const ex = numAny(node, ["end_x"], 1);
const ey = numAny(node, ["end_y"], 0.5);
const dx = ex - sx;
const dy = ey - sy;
const denom = dx * dx + dy * dy;
const invert = boolAny(node, ["invert"], false);
const mode = strAny(node, ["ramp_mode"], "linear");
const shape = strAny(node, ["ramp_shape"], "linear").toLowerCase().replace(/[-\s]+/g, "_");
const image = out.createImageData(width, height);
const data = image.data;
const a = Math.round(alpha * 255);
for (let y = 0; y < height; y++) {
const ny = height > 1 ? y / (height - 1) : 0;
for (let x = 0; x < width; x++) {
const nx = width > 1 ? x / (width - 1) : 0;
let t = 0;
if (denom > 1e-12) {
t = shape === "radial" ? Math.hypot(nx - sx, ny - sy) / Math.sqrt(denom) : ((nx - sx) * dx + (ny - sy) * dy) / denom;
}
t = Math.max(0, Math.min(1, invert ? 1 - t : t));
t = Math.max(0, Math.min(1, applyRampCurve(t, mode)));
const i = (y * width + x) * 4;
data[i] = Math.round((colorA[0] * (1 - t) + colorB[0] * t) * 255);
data[i + 1] = Math.round((colorA[1] * (1 - t) + colorB[1] * t) * 255);
data[i + 2] = Math.round((colorA[2] * (1 - t) + colorB[2] * t) * 255);
data[i + 3] = a;
}
}
out.putImageData(image, 0, 0);
return canvas;
}
function grainRandom01(seed, x, y, channel, frame) {
let v = seed >>> 0 ^ Math.imul(x + 374761393, 668265263) ^ Math.imul(y + 2246822519, 3266489917);
v ^= Math.imul(channel + 1, 1274126177);
v ^= Math.imul(frame + 1, 1597334677);
v ^= v >>> 15;
v = Math.imul(v, 2246822519);
v ^= v >>> 13;
v = Math.imul(v, 3266489917);
v ^= v >>> 16;
return (v >>> 0) / 4294967295;
}
function blendGrainValue(base, noise, amount, mode) {
const normalized = String(mode || "add").toLowerCase().replace(/[-\s]+/g, "_");
const top = Math.max(0, Math.min(1, 0.5 + noise * amount));
if (normalized === "overlay") {
const blended = base <= 0.5 ? 2 * base * top : 1 - 2 * (1 - base) * (1 - top);
return base * (1 - amount) + blended * amount;
}
if (normalized === "soft_light") {
const curve = base <= 0.25 ? ((16 * base - 12) * base + 4) * base : Math.sqrt(Math.max(0, Math.min(1, base)));
const blended = top <= 0.5 ? base - (1 - 2 * top) * base * (1 - base) : base + (2 * top - 1) * (curve - base);
return base * (1 - amount) + blended * amount;
}
return base + noise * amount;
}
function renderGrainCanvas(node, source, rawMask, frameIndex) {
const width = source.width || 1;
const height = source.height || 1;
const output = makeCanvas(width, height);
const octx = output.getContext("2d", { willReadFrequently: true });
octx.drawImage(source, 0, 0, width, height);
const img = octx.getImageData(0, 0, width, height);
const data = img.data;
const amount = Math.max(0, Math.min(1, numAny(node, ["amount"], 0.08, frameIndex)));
const seed = Math.max(0, Math.round(numAny(node, ["seed"], 12345, frameIndex)));
const mono = boolAny(node, ["monochrome"], true, frameIndex);
const animated = boolAny(node, ["animated"], true, frameIndex);
const grainFrame = animated ? Math.max(0, Math.round(frameIndex)) : 0;
const mode = strAny(node, ["blend_mode"], "add", frameIndex);
const mask = resolvePreviewMaskCanvas(node, source, rawMask, frameIndex);
const maskData = mask?.getContext("2d", { willReadFrequently: true })?.getImageData(0, 0, width, height).data ?? null;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = (y * width + x) * 4;
const weight = maskData ? maskData[i + 3] / 255 : 1;
if (weight <= 0 || amount <= 0) continue;
const monoNoise = grainRandom01(seed, x, y, 0, grainFrame) - 0.5;
for (let c = 0; c < 3; c++) {
const base = data[i + c] / 255;
const noise = mono ? monoNoise : grainRandom01(seed, x, y, c, grainFrame) - 0.5;
const grained = Math.max(0, Math.min(1, blendGrainValue(base, noise, amount, mode)));
const mixed = base * (1 - weight) + grained * weight;
data[i + c] = Math.round(mixed * 255);
}
}
}
octx.putImageData(img, 0, 0);
return output;
}
function renderTextCanvas(node, source, rawMask, frameIndex) {
const width = source.width || 1;
const height = source.height || 1;
const output = makeCanvas(width, height);
const octx = output.getContext("2d", { willReadFrequently: true });
octx.drawImage(source, 0, 0, width, height);
const text = resolveConnectedString(node, "text") ?? strAny(node, ["text"], "ImageOps Text", frameIndex);
const opacity = Math.max(0, Math.min(1, numAny(node, ["opacity"], 1, frameIndex)));
if (!text || opacity <= 0) return output;
const mask = resolvePreviewMaskCanvas(node, source, rawMask, frameIndex);
const layer = makeCanvas(width, height);
const lctx = layer.getContext("2d", { willReadFrequently: true });
const fontSize = Math.max(1, Math.round(numAny(node, ["font_size"], 64, frameIndex)));
const align = strAny(node, ["align"], "center", frameIndex).toLowerCase();
const x = numAny(node, ["x"], 0.5, frameIndex) * Math.max(1, width - 1);
const y = numAny(node, ["y"], 0.5, frameIndex) * Math.max(1, height - 1);
const lineSpacing = Math.max(0, Math.round(numAny(node, ["line_spacing"], 4, frameIndex)));
const strokeWidth = Math.max(0, Math.round(numAny(node, ["stroke_width"], 0, frameIndex)));
lctx.font = `${fontSize}px sans-serif`;
lctx.textBaseline = "top";
lctx.textAlign = align === "left" || align === "right" ? align : "center";
lctx.globalAlpha = opacity;
lctx.fillStyle = parseHexColor(strAny(node, ["color"], "#ffffff", frameIndex));
lctx.strokeStyle = parseHexColor(strAny(node, ["stroke_color"], "#000000", frameIndex));
lctx.lineWidth = strokeWidth;
const lines = String(text).split(/\r?\n/);
for (let index = 0; index < lines.length; index++) {
const ty = y + index * (fontSize + lineSpacing);
if (strokeWidth > 0) lctx.strokeText(lines[index], x, ty);
lctx.fillText(lines[index], x, ty);
}
lctx.globalAlpha = 1;
if (mask) {
lctx.globalCompositeOperation = "destination-in";
lctx.drawImage(mask, 0, 0, width, height);
lctx.globalCompositeOperation = "source-over";
}
octx.drawImage(layer, 0, 0, width, height);
return output;
}
function shakeRandom(seed, frame, salt) {
let v = seed >>> 0 ^ Math.imul(frame + 1, 1597334677) ^ Math.imul(salt + 1, 3812015801);
v ^= v >>> 15;
v = Math.imul(v, 2246822519);
v ^= v >>> 13;
v = Math.imul(v, 3266489917);
v ^= v >>> 16;
return (v >>> 0) / 4294967295 * 2 - 1;
}
function smoothShakeValue(seed, frame, salt, amount, smoothing, frequency = 1) {
const smooth = Math.max(0, Math.min(0.98, smoothing));
const sampleFrame = Math.max(0, frame * Math.max(0.01, frequency));
const baseFrame = Math.floor(sampleFrame);
const t = sampleFrame - baseFrame;
const valueAt = (targetFrame) => {
let currentValue = shakeRandom(seed, 0, salt) * amount;
for (let i = 0; i <= Math.max(0, Math.round(targetFrame)); i++) {
const target = shakeRandom(seed, i, salt) * amount;
currentValue = currentValue * smooth + target * (1 - smooth);
}
return currentValue;
};
if (t <= 1e-6) return valueAt(baseFrame);
return valueAt(baseFrame) * (1 - t) + valueAt(baseFrame + 1) * t;
}
function noiseFade(t) {
return t * t * t * (t * (t * 6 - 15) + 10);
}
function noiseLerp(a, b, t) {
return a + (b - a) * t;
}
const NOISE_GRAD3_X = new Float32Array([1, -1, 1, -1, 1, -1, 1, -1, 0, 0, 0, 0, 1, -1, 0, 0]);
const NOISE_GRAD3_Y = new Float32Array([1, 1, -1, -1, 0, 0, 0, 0, 1, -1, 1, -1, 1, 1, -1, 1]);
const NOISE_GRAD3_Z = new Float32Array([0, 0, 0, 0, 1, 1, -1, -1, 1, 1, -1, -1, 0, 0, 1, -1]);
function noiseHash3D(x, y, z, seed) {
let h = Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(z | 0, 2246822519) + Math.imul(seed | 0, 1442695041) | 0;
h ^= h >>> 13;
h = Math.imul(h, 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967295;
}
function wrapNoiseIndex(value, period) {
if (period <= 0) return value;
return (value % period + period) % period;
}
function sampleWhiteNoise(x, y, z, seed, periodX = 0, periodY = 0) {
const x0 = Math.floor(x);
const y0 = Math.floor(y);
const z0 = Math.floor(z);
const z1 = z0 + 1;
const tz = z - z0;
const ix = wrapNoiseIndex(x0, periodX);
const iy = wrapNoiseIndex(y0, periodY);
const n0 = noiseHash3D(ix, iy, z0, seed) * 2 - 1;
const n1 = noiseHash3D(ix, iy, z1, seed) * 2 - 1;
return noiseLerp(n0, n1, noiseFade(tz));
}
function sampleValueNoise(x, y, z, seed, periodX = 0, periodY = 0) {
const x0 = Math.floor(x);
const y0 = Math.floor(y);
const z0 = Math.floor(z);
const x1 = x0 + 1;
const y1 = y0 + 1;
const z1 = z0 + 1;
const tx = x - x0;
const ty = y - y0;
const tz = z - z0;
const u = noiseFade(tx);
const v = noiseFade(ty);
const w2 = noiseFade(tz);
const ix0 = wrapNoiseIndex(x0, periodX);
const ix1 = wrapNoiseIndex(x1, periodX);
const iy0 = wrapNoiseIndex(y0, periodY);
const iy1 = wrapNoiseIndex(y1, periodY);
const v000 = noiseHash3D(ix0, iy0, z0, seed) * 2 - 1;
const v100 = noiseHash3D(ix1, iy0, z0, seed) * 2 - 1;
const v010 = noiseHash3D(ix0, iy1, z0, seed) * 2 - 1;
const v110 = noiseHash3D(ix1, iy1, z0, seed) * 2 - 1;
const v001 = noiseHash3D(ix0, iy0, z1, seed) * 2 - 1;
const v101 = noiseHash3D(ix1, iy0, z1, seed) * 2 - 1;
const v011 = noiseHash3D(ix0, iy1, z1, seed) * 2 - 1;
const v111 = noiseHash3D(ix1, iy1, z1, seed) * 2 - 1;
const x00 = noiseLerp(v000, v100, u);
const x10 = noiseLerp(v010, v110, u);
const x01 = noiseLerp(v001, v101, u);
const x11 = noiseLerp(v011, v111, u);
return noiseLerp(noiseLerp(x00, x10, v), noiseLerp(x01, x11, v), w2);
}
function gradientDot3(ix, iy, iz, x, y, z, seed, periodX = 0, periodY = 0) {
const hashX = wrapNoiseIndex(ix, periodX);
const hashY = wrapNoiseIndex(iy, periodY);
const h = noiseHash3D(hashX, hashY, iz, seed);
const gi = Math.floor(h * 16) & 15;
return NOISE_GRAD3_X[gi] * (x - ix) + NOISE_GRAD3_Y[gi] * (y - iy) + NOISE_GRAD3_Z[gi] * (z - iz);
}
function samplePerlinNoise(x, y, z, seed, periodX = 0, periodY = 0) {
const x0 = Math.floor(x);
const y0 = Math.floor(y);
const z0 = Math.floor(z);
const x1 = x0 + 1;
const y1 = y0 + 1;
const z1 = z0 + 1;
const tx = x - x0;
const ty = y - y0;
const tz = z - z0;
const u = noiseFade(tx);
const v = noiseFade(ty);
const w2 = noiseFade(tz);
const n000 = gradientDot3(x0, y0, z0, x, y, z, seed, periodX, periodY);
const n100 = gradientDot3(x1, y0, z0, x, y, z, seed, periodX, periodY);
const n010 = gradientDot3(x0, y1, z0, x, y, z, seed, periodX, periodY);
const n110 = gradientDot3(x1, y1, z0, x, y, z, seed, periodX, periodY);
const n001 = gradientDot3(x0, y0, z1, x, y, z, seed, periodX, periodY);
const n101 = gradientDot3(x1, y0, z1, x, y, z, seed, periodX, periodY);
const n011 = gradientDot3(x0, y1, z1, x, y, z, seed, periodX, periodY);
const n111 = gradientDot3(x1, y1, z1, x, y, z, seed, periodX, periodY);
const x00 = noiseLerp(n000, n100, u);
const x10 = noiseLerp(n010, n110, u);
const x01 = noiseLerp(n001, n101, u);
const x11 = noiseLerp(n011, n111, u);
return Math.max(-1, Math.min(1, noiseLerp(noiseLerp(x00, x10, v), noiseLerp(x01, x11, v), w2) * 1.15470053838));
}
function sampleNoiseBasis(basis, sampleX, sampleY, sampleZ, rawX, rawY, rawZ, seed, periodX = 0, periodY = 0) {
const normalized = String(basis || "perlin").toLowerCase();
if (normalized === "value") return sampleValueNoise(sampleX, sampleY, sampleZ, seed, periodX, periodY);
if (normalized === "white") return sampleWhiteNoise(rawX, rawY, rawZ, seed, periodX > 0 ? Math.max(1, Math.round(periodX)) : 0, periodY > 0 ? Math.max(1, Math.round(periodY)) : 0);
return samplePerlinNoise(sampleX, sampleY, sampleZ, seed, periodX, periodY);
}
function buildNoiseField(width, height, options) {
const basis = options.basis;
const fractalMode = options.fractalMode.toLowerCase();
const frameIndex = Math.max(0, Math.round(options.frameIndex ?? 0));
const seed = Math.round(options.seed + frameIndex * (options.seedStep ?? 0));
const scale = Math.max(1, options.scale);
const octaves = Math.max(1, Math.round(options.octaves));
const lacunarity = Math.max(1.01, options.lacunarity);
const gain = Math.max(0, options.gain);
const offsetX = options.offsetX + frameIndex * (options.frameOffsetX ?? 0);
const offsetY = options.offsetY + frameIndex * (options.frameOffsetY ?? 0);
const offsetZ = (options.offsetZ ?? 0) + frameIndex * (options.frameOffsetZ ?? 0);
const seamless = !!options.seamless;
const contrast = Math.max(0, options.contrast);
const invert = options.invert;
const grayValues = new Float32Array(width * height);
let minGray = Number.POSITIVE_INFINITY;
let maxGray = Number.NEGATIVE_INFINITY;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const rawX = x + offsetX;
const rawY = y + offsetY;
let gray = 0;
if (fractalMode === "none") {
const useWhitePeriod = String(basis || "perlin").toLowerCase() === "white";
const periodX = seamless ? useWhitePeriod ? width : Math.max(1, Math.round(width / scale)) : 0;
const periodY = seamless ? useWhitePeriod ? height : Math.max(1, Math.round(height / scale)) : 0;
const sampleX = seamless ? x * (periodX / Math.max(1, width)) + offsetX / scale : rawX / scale;
const sampleY = seamless ? y * (periodY / Math.max(1, height)) + offsetY / scale : rawY / scale;
const signed = sampleNoiseBasis(basis, sampleX, sampleY, offsetZ / scale, rawX, rawY, offsetZ, seed, periodX, periodY);
gray = clamp01(signed * 0.5 + 0.5);
} else {
let total = 0;
let amplitude = 1;
let amplitudeSum = 0;
let currentScale = scale;
for (let octave = 0; octave < octaves; octave++) {
const octaveSeed = seed + octave * 10007;
const useWhitePeriod = String(basis || "perlin").toLowerCase() === "white";
const periodX = seamless ? useWhitePeriod ? width : Math.max(1, Math.round(width / currentScale)) : 0;
const periodY = seamless ? useWhitePeriod ? height : Math.max(1, Math.round(height / currentScale)) : 0;
const sampleX = seamless ? x * (periodX / Math.max(1, width)) + offsetX / currentScale : rawX / currentScale;
const sampleY = seamless ? y * (periodY / Math.max(1, height)) + offsetY / currentScale : rawY / currentScale;
const signed = sampleNoiseBasis(basis, sampleX, sampleY, offsetZ / currentScale, rawX, rawY, offsetZ, octaveSeed, periodX, periodY);
let contribution = signed;
if (fractalMode === "turbulence") contribution = Math.abs(signed);
else if (fractalMode === "ridged") contribution = 1 - Math.abs(signed);
total += contribution * amplitude;
amplitudeSum += amplitude;
amplitude *= gain;
currentScale = Math.max(1, currentScale / lacunarity);
}
const normalized = amplitudeSum > 0 ? total / amplitudeSum : 0;
gray = fractalMode === "fbm" ? clamp01(normalized * 0.5 + 0.5) : clamp01(normalized);
}
const index = y * width + x;
grayValues[index] = gray;
if (gray < minGray) minGray = gray;
if (gray > maxGray) maxGray = gray;
}
}
const grayRange = maxGray - minGray;
for (let index = 0; index < grayValues.length; index++) {
let gray = grayRange > 1e-6 ? (grayValues[index] - minGray) / grayRange : 0;
gray = clamp01((gray - 0.5) * contrast + 0.5);
if (invert) gray = 1 - gray;
grayValues[index] = gray;
}
return grayValues;
}
function renderNoiseFieldCanvas(width, height, grayValues, low, high, maskOnly = false) {
const canvas = makeCanvas(width, height);
const context = canvas.getContext("2d", { willReadFrequently: true });
const image = context.createImageData(width, height);
const data = image.data;
for (let index = 0; index < grayValues.length; index++) {
const gray = grayValues[index];
const offset = index * 4;
if (maskOnly) {
const channel = Math.round(gray * 255);
const rgb = channel > 0 ? 255 : 0;
data[offset] = rgb;
data[offset + 1] = rgb;
data[offset + 2] = rgb;
data[offset + 3] = channel;
} else {
data[offset] = Math.round(clamp01(low[0] + gray * (high[0] - low[0])) * 255);
data[offset + 1] = Math.round(clamp01(low[1] + gray * (high[1] - low[1])) * 255);
data[offset + 2] = Math.round(clamp01(low[2] + gray * (high[2] - low[2])) * 255);
data[offset + 3] = 255;
}
}
context.putImageData(image, 0, 0);
return maskOnly ? markPreparedMaskCanvas(canvas) : canvas;
}
const NOISE_CANVAS_MAX = 256;
function renderNoiseCanvas(node, maskOnly = false, frameIndex = 0, canvasSize = 512) {
const fullWidth = Math.max(1, Math.round(numAny(node, ["width"], 1024)));
const fullHeight = Math.max(1, Math.round(numAny(node, ["height"], 1024)));
const scaleFactor = Math.min(1, Math.max(1, Math.min(canvasSize, NOISE_CANVAS_MAX)) / Math.max(fullWidth, fullHeight));
const width = Math.max(1, Math.round(fullWidth * scaleFactor));
const height = Math.max(1, Math.round(fullHeight * scaleFactor));
const batchSize = Math.max(1, Math.round(numAny(node, ["batch_size"], 1)));
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"], "#ffffff", resolvedFrameIndex));
const high = hexToRgb01(strAny(node, ["high_color"], "#000000", resolvedFrameIndex));
const animSpeed = numAny(node, ["animation_speed", "frame_offset_z"], 0, resolvedFrameIndex);
const grayValues = buildNoiseField(width, height, {
basis: strAny(node, ["basis"], "perlin", resolvedFrameIndex),
fractalMode: strAny(node, ["fractal_mode"], "fbm", resolvedFrameIndex),
seed: numAny(node, ["seed"], 0, resolvedFrameIndex),
seedStep: numAny(node, ["seed_step"], 0, resolvedFrameIndex),
scale: numAny(node, ["scale"], 160, resolvedFrameIndex),
octaves: numAny(node, ["octaves"], 5, resolvedFrameIndex),
lacunarity: numAny(node, ["lacunarity"], 2, resolvedFrameIndex),
gain: numAny(node, ["gain"], 0.5, resolvedFrameIndex),
offsetX: numAny(node, ["offset_x"], 0, resolvedFrameIndex),
offsetY: numAny(node, ["offset_y"], 0, resolvedFrameIndex),
offsetZ: numAny(node, ["offset_z"], 0, resolvedFrameIndex),
frameOffsetX: numAny(node, ["frame_offset_x"], 0, resolvedFrameIndex),
frameOffsetY: numAny(node, ["frame_offset_y"], 0, resolvedFrameIndex),
frameOffsetZ: animSpeed,
seamless: boolAny(node, ["seamless"], false, resolvedFrameIndex),
contrast: numAny(node, ["contrast"], 1, resolvedFrameIndex),
invert: boolAny(node, ["invert"], false, resolvedFrameIndex),
frameIndex
});
return renderNoiseFieldCanvas(width, height, grayValues, low, high, maskOnly);
}
function distortConnectedInputs(node, inputs) {
const source = inputs[0];
const displacementConnected = (node.inputs?.[1]?.link ?? null) != null;
const maskConnected = (node.inputs?.[2]?.link ?? null) != null;
let cursor = 1;
const displacement = displacementConnected ? inputs[cursor++] ?? null : null;
const mask = maskConnected ? inputs[cursor] ?? null : null;
return { source, displacement, mask };
}
function extractCanvasField(canvas, width, height, channel) {
const normalized = String(channel || "red").toLowerCase();
const cacheKey = `${Math.max(1, width)}x${Math.max(1, height)}:${normalized}`;
const cachedField = canvasFieldCache.get(canvas)?.get(cacheKey);
if (cachedField) return cachedField;
const fitted = (canvas.width || 1) === width && (canvas.height || 1) === height ? canvas : fitCanvas(canvas, width, height);
const data = fitted.getContext("2d", { willReadFrequently: true }).getImageData(0, 0, width, height).data;
const field = new Float32Array(width * height);
const weights = getOpsConstants().luma_weights;
const preparedMask = isPreparedMaskCanvas(fitted);
for (let index = 0; index < field.length; index++) {
const offset = index * 4;
const r = data[offset] / 255;
const g = data[offset + 1] / 255;
const b = data[offset + 2] / 255;
const a = data[offset + 3] / 255;
if (preparedMask) {
field[index] = a;
continue;
}
if (normalized === "green") field[index] = g;
else if (normalized === "blue") field[index] = b;
else if (normalized === "alpha") field[index] = a;
else if (normalized === "luma") field[index] = clamp01(luma01(r, g, b, weights));
else field[index] = r;
}
const cache = canvasFieldCache.get(canvas) ?? /* @__PURE__ */ new Map();
cache.set(cacheKey, field);
canvasFieldCache.set(canvas, cache);
return field;
}
function neutralField(width, height, centered) {
const field = new Float32Array(width * height);
field.fill(centered ? 0.5 : 0);
return field;
}
function blurField(field, width, height, radiusPx) {
const r = Math.max(0, Math.round(radiusPx));
if (r <= 0 || width < 2 || height < 2) return field;
const passes = 3;
let src = new Float32Array(field);
let dst = new Float32Array(field.length);
const win = 2 * r + 1;
for (let p = 0; p < passes; p++) {
for (let y = 0; y < height; y++) {
const row = y * width;
let sum = 0;
for (let i = -r; i <= r; i++) {
const xi = i < 0 ? -i : i;
sum += src[row + Math.min(width - 1, xi)];
}
dst[row] = sum / win;
for (let x = 1; x < width; x++) {
const addX = x + r;
const remX = x - r - 1;
const addCoord = addX >= width ? 2 * (width - 1) - addX : addX;
const remCoord = remX < 0 ? -remX : remX;
sum += src[row + Math.max(0, Math.min(width - 1, addCoord))];
sum -= src[row + Math.max(0, Math.min(width - 1, remCoord))];
dst[row + x] = sum / win;
}
}
[src, dst] = [dst, src];
for (let x = 0; x < width; x++) {
let sum = 0;
for (let i = -r; i <= r; i++) {
const yi = i < 0 ? -i : i;
sum += src[Math.min(height - 1, yi) * width + x];
}
dst[x] = sum / win;
for (let y = 1; y < height; y++) {
const addY = y + r;
const remY = y - r - 1;
const addCoord = addY >= height ? 2 * (height - 1) - addY : addY;
const remCoord = remY < 0 ? -remY : remY;
sum += src[Math.max(0, Math.min(height - 1, addCoord)) * width + x];
sum -= src[Math.max(0, Math.min(height - 1, remCoord)) * width + x];
dst[y * width + x] = sum / win;
}
}
[src, dst] = [dst, src];
}
return src;
}
function reflectCoordinate(value, size) {
if (size <= 1) return 0;
let coord = value;
const max = size - 1;
while (coord < 0 || coord > max) {
if (coord < 0) coord = -coord;
if (coord > max) coord = max - (coord - max);
}
return coord;
}
function sampleChannel(data, width, height, x, y, edgeMode) {
let px = x;
let py = y;
if (edgeMode === "zeros") {
if (px < 0 || py < 0 || px >= width || py >= height) return [0, 0, 0, 0];
} else if (edgeMode === "reflection") {
px = reflectCoordinate(px, width);
py = reflectCoordinate(py, height);
} else {
px = Math.max(0, Math.min(width - 1, px));
py = Math.max(0, Math.min(height - 1, py));
}
const offset = (py * width + px) * 4;
return [data[offset], data[offset + 1], data[offset + 2], data[offset + 3]];
}
function bilinearSample(data, width, height, x, y, edgeMode) {
const x0 = Math.floor(x);
const y0 = Math.floor(y);
const x1 = x0 + 1;
const y1 = y0 + 1;
const tx = x - x0;
const ty = y - y0;
const c00 = sampleChannel(data, width, height, x0, y0, edgeMode);
const c10 = sampleChannel(data, width, height, x1, y0, edgeMode);
const c01 = sampleChannel(data, width, height, x0, y1, edgeMode);
const c11 = sampleChannel(data, width, height, x1, y1, edgeMode);
const out = [0, 0, 0, 0];
for (let c = 0; c < 4; c++) {
const top = noiseLerp(c00[c], c10[c], tx);
const bottom = noiseLerp(c01[c], c11[c], tx);
out[c] = noiseLerp(top, bottom, ty);
}
return out;
}
function renderDistortCanvas(node, inputs, frameIndex = 0) {
const { source, displacement, mask: rawMask } = distortConnectedInputs(node, inputs);
const width = source.width || 1;
const height = source.height || 1;
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, frameIndex);
let previewMask = null;
let xField;
let yField;
if (mapSource === "mask") {
if (rawMask) {
const maskCanvas = buildMaskAlphaCanvas(rawMask, width, height);
previewMask = invertMap ? invertMaskCanvas(maskCanvas) : maskCanvas;
xField = extractCanvasField(previewMask, width, height, "red");
yField = xField;
} else {
xField = neutralField(width, height, centeredMap);
yField = xField;
}
} else {
const driver = mapSource === "displacement_channel" && displacement ? displacement : source;
const xChannel = strAny(node, ["x_channel"], "Red", frameIndex);
const yChannel = strAny(node, ["y_channel"], "Green", frameIndex);
xField = extractCanvasField(driver, width, height, xChannel);
yField = String(xChannel).toLowerCase() === String(yChannel).toLowerCase() ? xField : extractCanvasField(driver, width, height, yChannel);
}
const blurRadius = Math.max(0, Math.round(numAny(node, ["blur_map"], 0, frameIndex)));
if (blurRadius > 0) {
xField = blurField(xField, width, height, blurRadius);
yField = xField === yField ? xField : blurField(yField, width, height, blurRadius);
}
const sourceCanvas = source;
const sourceCtx = sourceCanvas.getContext("2d", { willReadFrequently: true });
const sourceData = sourceCtx.getImageData(0, 0, width, height);
const output = makeCanvas(width, height);
const outCtx = output.getContext("2d", { willReadFrequently: true });
const outImage = outCtx.createImageData(width, height);
const outData = outImage.data;
const strengthX = numAny(node, ["strength_x"], 40, frameIndex);
const strengthY = numAny(node, ["strength_y"], 40, frameIndex);
const filter = strAny(node, ["filter"], "bilinear", frameIndex).toLowerCase();
const edgeMode = strAny(node, ["edge_mode"], "border", frameIndex).toLowerCase();
const useNearest = filter === "nearest";
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const index = y * width + x;
let fx = xField[index];
let fy = yField[index];
if (invertMap && mapSource !== "mask") {
fx = 1 - fx;
fy = 1 - fy;
}
if (centeredMap) {
fx = fx * 2 - 1;
fy = fy * 2 - 1;
}
const sampleX = x - fx * strengthX;
const sampleY = y - fy * strengthY;
const rgba = useNearest ? sampleChannel(sourceData.data, width, height, Math.round(sampleX), Math.round(sampleY), edgeMode) : bilinearSample(sourceData.data, width, height, sampleX, sampleY, edgeMode);
const offset = index * 4;
outData[offset] = Math.round(clamp01(rgba[0] / 255) * 255);
outData[offset + 1] = Math.round(clamp01(rgba[1] / 255) * 255);
outData[offset + 2] = Math.round(clamp01(rgba[2] / 255) * 255);
outData[offset + 3] = Math.round(clamp01(rgba[3] / 255) * 255);
}
}
outCtx.putImageData(outImage, 0, 0);
const finalImage = effectMask ? compositeProcessedWithMask(sourceCanvas, output, effectMask) : output;
if (effectMask) return { image: finalImage, mask: effectMask };
if (previewMask) return { image: finalImage, mask: previewMask };
return { image: finalImage, mask: alphaMaskCanvas(finalImage) };
}
function setResampleMode(ctx, filter) {
const mode = String(filter || "bilinear").toLowerCase();
ctx.imageSmoothingEnabled = mode !== "nearest";
if (ctx.imageSmoothingEnabled) {
ctx.imageSmoothingQuality = mode === "bicubic" ? "high" : "medium";
}
}
function applyLevels(ctx, W, H, inMin, inMax, gamma, outMin, outMax) {
const { epsilon: EPS, preview_gamma_epsilon: GE } = getOpsConstants();
const img = getImageData(ctx, W, H);
const d = img.data;
const ig = 1 / Math.max(GE, gamma);
for (let i = 0; i < d.length; i += 4) {
for (let c = 0; c < 3; c++) {
let v = d[i + c] / 255;
v = (v - inMin) / Math.max(EPS, inMax - inMin);
v = clamp01(v);
v = Math.pow(v, ig);
v = outMin + v * (outMax - outMin);
d[i + c] = Math.round(clamp01(v) * 255);
}
}
putImageData(ctx, img);
}
function applyHueSat(ctx, W, H, hueDeg, sat, val) {
const { epsilon: EPS } = getOpsConstants();
const img = getImageData(ctx, W, H);
const d = img.data;
const hue = hueDeg % 360 * Math.PI / 180;
for (let i = 0; i < d.length; i += 4) {
let r = d[i] / 255, g = d[i + 1] / 255, b = d[i + 2] / 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const delta = max - min;
let h0 = 0;
if (delta > EPS) {
if (max === r) h0 = (g - b) / delta % 6;
else if (max === g) h0 = (b - r) / delta + 2;
else h0 = (r - g) / delta + 4;
h0 *= Math.PI / 3;
}
let s0 = max === 0 ? 0 : delta / max;
let v0 = max;
h0 += hue;
s0 = clamp01(s0 * sat);
v0 = clamp01(v0 * val);
const c = v0 * s0;
const x = c * (1 - Math.abs(h0 / (Math.PI / 3) % 2 - 1));
const m = v0 - c;
let rp = 0, gp = 0, bp = 0;
const hh = (h0 % (2 * Math.PI) + 2 * Math.PI) % (2 * Math.PI);
const sector = Math.floor(hh / (Math.PI / 3));
switch (sector) {
case 0:
rp = c;
gp = x;
bp = 0;
break;
case 1:
rp = x;
gp = c;
bp = 0;
break;
case 2:
rp = 0;
gp = c;
bp = x;
break;
case 3:
rp = 0;
gp = x;
bp = c;
break;
case 4:
rp = x;
gp = 0;
bp = c;
break;
case 5:
rp = c;
gp = 0;
bp = x;
break;
}
d[i] = Math.round(clamp01(rp + m) * 255);
d[i + 1] = Math.round(clamp01(gp + m) * 255);
d[i + 2] = Math.round(clamp01(bp + m) * 255);
}
putImageData(ctx, img);
}
function applyInvert(ctx, W, H, invertAlpha = false) {
const img = getImageData(ctx, W, H);
const d = img.data;
for (let i = 0; i < d.length; i += 4) {
d[i] = 255 - d[i];
d[i + 1] = 255 - d[i + 1];
d[i + 2] = 255 - d[i + 2];
if (invertAlpha) d[i + 3] = 255 - d[i + 3];
}
putImageData(ctx, img);
}
function applyClamp(ctx, W, H, minV, maxV) {
const lo = Math.min(minV, maxV);
const hi = Math.max(minV, maxV);
const mn = Math.round(clamp01(lo) * 255);
const mx = Math.round(clamp01(hi) * 255);
const img = getImageData(ctx, W, H);
const d = img.data;
for (let i = 0; i < d.length; i += 4) {
d[i] = Math.max(mn, Math.min(mx, d[i]));
d[i + 1] = Math.max(mn, Math.min(mx, d[i + 1]));
d[i + 2] = Math.max(mn, Math.min(mx, d[i + 2]));
d[i + 3] = Math.max(mn, Math.min(mx, d[i + 3]));
}
putImageData(ctx, img);
}
function applyColorCorrect(ctx, W, H, brightness, contrast, gamma, saturation) {
const { luma_weights: LW, gamma_safe_min: GMIN, gamma_max: GMAX, preview_gamma_epsilon: GE } = getOpsConstants();
const img = getImageData(ctx, W, H);
const d = img.data;
const g = Math.max(GMIN, Math.min(GMAX, gamma));
const invGamma = 1 / Math.max(GE, g);
for (let i = 0; i < d.length; i += 4) {
let r = d[i] / 255, gr = d[i + 1] / 255, b = d[i + 2] / 255;
r += brightness;
gr += brightness;
b += brightness;
r = (r - 0.5) * contrast + 0.5;
gr = (gr - 0.5) * contrast + 0.5;
b = (b - 0.5) * contrast + 0.5;
r = clamp01(r);
gr = clamp01(gr);
b = clamp01(b);
r = Math.pow(r, invGamma);
gr = Math.pow(gr, invGamma);
b = Math.pow(b, invGamma);
const l = luma01(r, gr, b, LW);
r = l + (r - l) * saturation;
gr = l + (gr - l) * saturation;
b = l + (b - l) * saturation;
d[i] = Math.round(clamp01(r) * 255);
d[i + 1] = Math.round(clamp01(gr) * 255);
d[i + 2] = Math.round(clamp01(b) * 255);
}
putImageData(ctx, img);
}
function applyColorCorrectReference(ctx, W, H, temperature, tint, hue, brightness, contrast, saturation, vibrance, gamma, shadowsHue, shadowsAmount, midtonesHue, midtonesAmount, highlightsHue, highlightsAmount, perZone) {
const { luma_weights: LW } = getOpsConstants();
const okGL = applyColorCorrectGL(ctx, W, H, {
temperature,
tint,
hue,
brightness,
contrast,
saturation,
vibrance,
gamma,
shadowsHue,
shadowsAmount,
midtonesHue,
midtonesAmount,
highlightsHue,
highlightsAmount,
lumaWeights: [LW[0], LW[1], LW[2]],
...perZone ?? {}
});
if (okGL) return;
const img = getImageData(ctx, W, H);
const d = img.data;
const brightnessFactor = 1 + brightness / 100;
const contrastFactor = 1 + contrast / 100;
const temperatureFactor = temperature / 100;
const tintFactor = tint / 100;
const vibranceFactor = vibrance / 100;
const safeGamma = Math.max(0.2, Math.min(2.2, gamma));
const wheelTint = (hueDeg, amount) => {
const sat = clamp01(amount / 100);
const rad = (hueDeg % 360 + 360) % 360;
const sector = rad / 60;
const c = sat;
const x = c * (1 - Math.abs(sector % 2 - 1));
let rp = 0, gp = 0, bp = 0;
if (sector < 1) [rp, gp, bp] = [c, x, 0];
else if (sector < 2) [rp, gp, bp] = [x, c, 0];
else if (sector < 3) [rp, gp, bp] = [0, c, x];
else if (sector < 4) [rp, gp, bp] = [0, x, c];
else if (sector < 5) [rp, gp, bp] = [x, 0, c];
else [rp, gp, bp] = [c, 0, x];
return [rp, gp, bp];
};
let meanLuma = 0;
const pixelCount = Math.max(1, W * H);
for (let i = 0; i < d.length; i += 4) {
const r = clamp01(d[i] / 255 * brightnessFactor);
const g = clamp01(d[i + 1] / 255 * brightnessFactor);
const b = clamp01(d[i + 2] / 255 * brightnessFactor);
meanLuma += luma01(r, g, b, LW);
}
meanLuma /= pixelCount;
for (let i = 0; i < d.length; i += 4) {
let r = clamp01(d[i] / 255 * brightnessFactor);
let g = clamp01(d[i + 1] / 255 * brightnessFactor);
let b = clamp01(d[i + 2] / 255 * brightnessFactor);
r = meanLuma + (r - meanLuma) * contrastFactor;
g = meanLuma + (g - meanLuma) * contrastFactor;
b = meanLuma + (b - meanLuma) * contrastFactor;
if (temperatureFactor > 0) {
r *= 1 + temperatureFactor;
g *= 1 + temperatureFactor * 0.4;
} else if (temperatureFactor < 0) {
b *= 1 - temperatureFactor;
}
if (tintFactor > 0) {
r *= 1 + tintFactor * 0.25;
b *= 1 + tintFactor * 0.35;
g *= 1 - tintFactor * 0.2;
} else if (tintFactor < 0) {
g *= 1 + -tintFactor * 0.3;
r *= 1 - -tintFactor * 0.12;
b *= 1 - -tintFactor * 0.08;
}
const luma = luma01(r, g, b, LW);
const maxChroma = Math.max(r, g, b);
const minChroma = Math.min(r, g, b);
const chroma = maxChroma - minChroma;
const muted = 1 - clamp01(chroma);
const vibranceBoost = 1 + vibranceFactor * muted;
r = luma + (r - luma) * vibranceBoost;
g = luma + (g - luma) * vibranceBoost;
b = luma + (b - luma) * vibranceBoost;
const shadowMask = Math.max(0, Math.min(1, (0.5 - luma) / 0.5)) ** 2;
const highlightMask = Math.max(0, Math.min(1, (luma - 0.5) / 0.5)) ** 2;
const midMask = Math.max(0, Math.min(1, 1 - shadowMask - highlightMask));
const [sr, sg, sb] = wheelTint(shadowsHue, shadowsAmount);
const [mr, mg, mb] = wheelTint(midtonesHue, midtonesAmount);
const [hr, hg, hb] = wheelTint(highlightsHue, highlightsAmount);
r *= 1 + ((sr - 0.5) * shadowMask + (mr - 0.5) * midMask + (hr - 0.5) * highlightMask) * 0.85;
g *= 1 + ((sg - 0.5) * shadowMask + (mg - 0.5) * midMask + (hg - 0.5) * highlightMask) * 0.85;
b *= 1 + ((sb - 0.5) * shadowMask + (mb - 0.5) * midMask + (hb - 0.5) * highlightMask) * 0.85;
d[i] = Math.round(clamp01(Math.pow(clamp01(r), safeGamma)) * 255);
d[i + 1] = Math.round(clamp01(Math.pow(clamp01(g), safeGamma)) * 255);
d[i + 2] = Math.round(clamp01(Math.pow(clamp01(b), safeGamma)) * 255);
}
putImageData(ctx, img);
applyHueSat(ctx, W, H, hue, 1 + saturation / 100, 1);
}
function applyUnsharp(ctx, W, H, amount = 1) {
const tmp = document.createElement("canvas");
tmp.width = W;
tmp.height = H;
const tctx = tmp.getContext("2d", { willReadFrequently: true });
tctx.filter = "blur(2px)";
tctx.drawImage(ctx.canvas, 0, 0);
tctx.filter = "none";
const o = getImageData(ctx, W, H);
const b = tctx.getImageData(0, 0, W, H);
const d = o.data, bd = b.data;
const a = Math.max(0, amount);
for (let i = 0; i < d.length; i += 4) {
d[i] = Math.max(0, Math.min(255, d[i] + a * (d[i] - bd[i])));
d[i + 1] = Math.max(0, Math.min(255, d[i + 1] + a * (d[i + 1] - bd[i + 1])));
d[i + 2] = Math.max(0, Math.min(255, d[i + 2] + a * (d[i + 2] - bd[i + 2])));
}
putImageData(ctx, o);
}
function applyEdgeDetect(ctx, W, H, strength = 1) {
const { luma_weights: LW } = getOpsConstants();
const img = getImageData(ctx, W, H);
const d = img.data;
const gr = new Float32Array(W * H);
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const i = (y * W + x) * 4;
gr[y * W + x] = luma01(d[i] / 255, d[i + 1] / 255, d[i + 2] / 255, LW);
}
}
const out = new Uint8ClampedArray(d.length);
const k = strength;
for (let y = 1; y < H - 1; y++) {
for (let x = 1; x < W - 1; x++) {
const gx = -1 * gr[(y - 1) * W + (x - 1)] + 1 * gr[(y - 1) * W + (x + 1)] + -2 * gr[y * W + (x - 1)] + 2 * gr[y * W + (x + 1)] + -1 * gr[(y + 1) * W + (x - 1)] + 1 * gr[(y + 1) * W + (x + 1)];
const gy = -1 * gr[(y - 1) * W + (x - 1)] + -2 * gr[(y - 1) * W + x] + -1 * gr[(y - 1) * W + (x + 1)] + 1 * gr[(y + 1) * W + (x - 1)] + 2 * gr[(y + 1) * W + x] + 1 * gr[(y + 1) * W + (x + 1)];
const mag = clamp01(Math.sqrt(gx * gx + gy * gy) * k);
const v = Math.round(mag * 255);
const i = (y * W + x) * 4;
out[i] = v;
out[i + 1] = v;
out[i + 2] = v;
out[i + 3] = 255;
}
}
img.data.set(out);
putImageData(ctx, img);
}
function applyBlur(ctx, W, H, radiusPx, sigmaPx) {
const blurPx = resolveBlurRadiusPx(radiusPx, sigmaPx);
if (blurPx <= 0) return;
const safeSigma = Math.max(0, sigmaPx);
const cssSigma = safeSigma > 0 ? Math.min(blurPx, safeSigma) : Math.max(0.1, blurPx / 3);
const tmp = acquireCanvas(W, H);
try {
const tctx = tmp.getContext("2d", { willReadFrequently: true });
tctx.filter = `blur(${cssSigma}px)`;
tctx.drawImage(ctx.canvas, 0, 0);
tctx.filter = "none";
ctx.clearRect(0, 0, W, H);
ctx.drawImage(tmp, 0, 0);
} finally {
releaseCanvas(tmp);
}
}
function resolveBlurRadiusPx(radiusPx, sigmaPx) {
const safeRadius = Math.max(0, Math.round(radiusPx));
void sigmaPx;
return safeRadius;
}
function applyChannel(ctx, W, H, channel) {
const img = getImageData(ctx, W, H);
const d = img.data;
const normalized = String(channel || "Red").trim().toLowerCase();
const index = normalized === "green" || normalized === "g" ? 1 : normalized === "blue" || normalized === "b" ? 2 : normalized === "alpha" || normalized === "a" ? 3 : 0;
for (let i = 0; i < d.length; i += 4) {
const value = index === 3 ? d[i + 3] : d[i + index];
d[i] = value;
d[i + 1] = value;
d[i + 2] = value;
if (index === 3) d[i + 3] = value;
}
putImageData(ctx, img);
}
function applyDesaturate(ctx, W, H, factor = 1) {
const img = getImageData(ctx, W, H);
const d = img.data;
const amount = clamp01(factor);
const lw = getOpsConstants().luma_weights;
for (let i = 0; i < d.length; i += 4) {
const r = d[i] / 255;
const g = d[i + 1] / 255;
const b = d[i + 2] / 255;
const l = luma01(r, g, b, lw);
d[i] = Math.round(clamp01(r * (1 - amount) + l * amount) * 255);
d[i + 1] = Math.round(clamp01(g * (1 - amount) + l * amount) * 255);
d[i + 2] = Math.round(clamp01(b * (1 - amount) + l * amount) * 255);
}
putImageData(ctx, img);
}
function normalizeAffineFillMode(value) {
const normalized = String(value || "transparent").trim().toLowerCase().replace(/[-\s]+/g, "_");
if (normalized === "border" || normalized === "expand" || normalized === "edge" || normalized === "edge_extend" || normalized === "replicate" || normalized === "extend") return "expand";
if (normalized === "reflect" || normalized === "reflection" || normalized === "mirror") return "mirror";
if (normalized === "stretch" || normalized === "fill" || normalized === "cover") return "stretch";
if (normalized === "color" || normalized === "colour" || normalized === "constant" || normalized === "solid") return "color";
return "transparent";
}
function applyTransform(ctx, W, H, tx, ty, rotDeg, scale, filter, expand, fillMode = "transparent", fillColor = "#000000") {
void expand;
const safeScale = Math.max(0.01, scale || 1);
const normalizedFill = normalizeAffineFillMode(fillMode);
const rad = rotDeg * Math.PI / 180;
const needsScale = Math.abs(safeScale - 1) > 1e-4;
const needsRotate = Math.abs(rotDeg) > 1e-4;
const needsTranslate = tx !== 0 || ty !== 0;
if (!needsScale && !needsRotate && !needsTranslate) return;
const sourceCtx = ctx.canvas.getContext("2d", { willReadFrequently: true });
if (!sourceCtx) return;
const output = makeCanvas(W, H);
const octx = output.getContext("2d", { willReadFrequently: true });
setResampleMode(octx, filter);
octx.clearRect(0, 0, W, H);
if (normalizedFill === "color") {
octx.fillStyle = parseHexColor(fillColor);
octx.fillRect(0, 0, W, H);
} else if (normalizedFill === "stretch") {
octx.drawImage(ctx.canvas, 0, 0, W, H);
}
const srcImage = sourceCtx.getImageData(0, 0, W, H);
const srcData = srcImage.data;
const outImage = octx.getImageData(0, 0, W, H);
const outData = outImage.data;
const useNearest = filter === "nearest" || filter === "nearest-exact";
const cos = Math.cos(rad);
const sin = Math.sin(rad);
const centerX = W / 2;
const centerY = H / 2;
const invScale = 1 / safeScale;
for (let y = 0; y < H; y++) {
const py = y + 0.5 - (centerY + ty);
for (let x = 0; x < W; x++) {
const px = x + 0.5 - (centerX + tx);
let sx = (cos * px + sin * py) * invScale + centerX - 0.5;
let sy = (-sin * px + cos * py) * invScale + centerY - 0.5;
const inside = sx >= 0 && sx <= W - 1 && sy >= 0 && sy <= H - 1;
if (!inside) {
if (normalizedFill === "expand") {
sx = Math.max(0, Math.min(W - 1, sx));
sy = Math.max(0, Math.min(H - 1, sy));
} else if (normalizedFill === "mirror") {
sx = reflectCoord(sx, W - 1);
sy = reflectCoord(sy, H - 1);
} else {
continue;
}
}
const offset = (y * W + x) * 4;
const sr = useNearest ? sampleChannelNearest(srcData, W, H, sx, sy, 0) : sampleChannelBilinear(srcData, W, H, sx, sy, 0);
const sg = useNearest ? sampleChannelNearest(srcData, W, H, sx, sy, 1) : sampleChannelBilinear(srcData, W, H, sx, sy, 1);
const sb = useNearest ? sampleChannelNearest(srcData, W, H, sx, sy, 2) : sampleChannelBilinear(srcData, W, H, sx, sy, 2);
const sa = useNearest ? sampleChannelNearest(srcData, W, H, sx, sy, 3) : sampleChannelBilinear(srcData, W, H, sx, sy, 3);
const fgA = clamp01(sa / 255);
const bgA = clamp01(outData[offset + 3] / 255);
const outA = fgA + bgA * (1 - fgA);
const bgR = outData[offset] / 255;
const bgG = outData[offset + 1] / 255;
const bgB = outData[offset + 2] / 255;
const premulR = sr / 255 * fgA + bgR * bgA * (1 - fgA);
const premulG = sg / 255 * fgA + bgG * bgA * (1 - fgA);
const premulB = sb / 255 * fgA + bgB * bgA * (1 - fgA);
outData[offset] = outA > 1e-6 ? Math.round(clamp01(premulR / outA) * 255) : 0;
outData[offset + 1] = outA > 1e-6 ? Math.round(clamp01(premulG / outA) * 255) : 0;
outData[offset + 2] = outA > 1e-6 ? Math.round(clamp01(premulB / outA) * 255) : 0;
outData[offset + 3] = Math.round(clamp01(outA) * 255);
}
}
octx.putImageData(outImage, 0, 0);
return output;
}
function applyGlow(ctx, W, H, threshold, intensity, blurPx) {
const { luma_weights: LW } = getOpsConstants();
const base = getImageData(ctx, W, H);
const d = base.data;
const hi = new Uint8ClampedArray(d.length);
for (let i = 0; i < d.length; i += 4) {
const l = luma01(d[i] / 255, d[i + 1] / 255, d[i + 2] / 255, LW);
if (l >= threshold) {
hi[i] = d[i];
hi[i + 1] = d[i + 1];
hi[i + 2] = d[i + 2];
hi[i + 3] = d[i + 3];
}
}
const tmp = document.createElement("canvas");
tmp.width = W;
tmp.height = H;
const tctx = tmp.getContext("2d", { willReadFrequently: true });
tctx.putImageData(new ImageData(hi, W, H), 0, 0);
const blur = document.createElement("canvas");
blur.width = W;
blur.height = H;
const bctx = blur.getContext("2d", { willReadFrequently: true });
bctx.filter = `blur(${Math.max(0, blurPx)}px)`;
bctx.drawImage(tmp, 0, 0);
bctx.filter = "none";
ctx.save();
ctx.globalAlpha = Math.max(0, Math.min(1, intensity));
ctx.globalCompositeOperation = "lighter";
ctx.drawImage(blur, 0, 0);
ctx.restore();
}
function applyCropReformat(ctx, W, H, x, y, cw, ch, padding, outW, outH, mode) {
const cropW = Math.max(1, Math.round(cw));
const cropH = Math.max(1, Math.round(ch));
const pad = Math.max(0, Math.round(padding));
const tmp = document.createElement("canvas");
tmp.width = cropW + pad * 2;
tmp.height = cropH + pad * 2;
const tctx = tmp.getContext("2d", { willReadFrequently: true });
tctx.clearRect(0, 0, tmp.width, tmp.height);
tctx.drawImage(ctx.canvas, -Math.round(x) + pad, -Math.round(y) + pad);
const finalW = outW > 0 ? Math.round(outW) : tmp.width;
const finalH = outH > 0 ? Math.round(outH) : tmp.height;
const dst = document.createElement("canvas");
dst.width = finalW;
dst.height = finalH;
const dctx = dst.getContext("2d", { willReadFrequently: true });
dctx.clearRect(0, 0, finalW, finalH);
if (mode === "stretch") {
dctx.drawImage(tmp, 0, 0, finalW, finalH);
} else {
const s = mode === "fill" ? Math.max(finalW / tmp.width, finalH / tmp.height) : Math.min(finalW / tmp.width, finalH / tmp.height);
const dw = Math.floor(tmp.width * s);
const dh = Math.floor(tmp.height * s);
const dx = Math.floor((finalW - dw) / 2);
const dy = Math.floor((finalH - dh) / 2);
dctx.drawImage(tmp, dx, dy, dw, dh);
}
ctx.clearRect(0, 0, W, H);
ctx.drawImage(dst, 0, 0, W, H);
}
function applyCrop(ctx, node, sourceWidth, sourceHeight, aspectRatio, outW, outH) {
const finalW = Math.max(1, Math.round(outW));
const finalH = Math.max(1, Math.round(outH));
const ratio = resolveCropAspectRatio(aspectRatio, finalW, finalH);
const crop = computeCropRect(
sourceWidth,
sourceHeight,
ratio,
clampCropCenter(num(node, "crop_center_x", 0.5)),
clampCropCenter(num(node, "crop_center_y", 0.5)),
clampCropScale(num(node, "crop_scale", 1))
);
const output = makeCanvas(finalW, finalH);
const octx = output.getContext("2d", { willReadFrequently: true });
octx.imageSmoothingEnabled = true;
octx.imageSmoothingQuality = "high";
octx.clearRect(0, 0, finalW, finalH);
octx.drawImage(
ctx.canvas,
crop.x,
crop.y,
crop.cropWidth,
crop.cropHeight,
0,
0,
finalW,
finalH
);
return output;
}
function resolvePadOutGeometry(sourceWidth, sourceHeight, node, frameIndex = 0) {
const snap = Math.max(1, Math.round(numAny(node, ["snap_to_multiple"], 1, frameIndex)));
const snapPad = (value) => snap <= 1 ? Math.max(0, Math.round(value)) : Math.max(0, Math.round(Math.round(value) / snap) * snap);
let padLeft = snapPad(numAny(node, ["pad_left"], 0, frameIndex));
let padTop = snapPad(numAny(node, ["pad_top"], 0, frameIndex));
let padRight = snapPad(numAny(node, ["pad_right"], 0, frameIndex));
let padBottom = snapPad(numAny(node, ["pad_bottom"], 0, frameIndex));
const outWidth = Math.max(1, sourceWidth + padLeft + padRight);
const outHeight = Math.max(1, sourceHeight + padTop + padBottom);
return { padLeft, padTop, padRight, padBottom, outWidth, outHeight };
}
function renderPadOutCanvases(node, source, frameIndex = 0, applyInvertMask = true) {
const invertMask = applyInvertMask && boolAny(node, ["invert_mask"], false, frameIndex);
const sourceWidth = source.width || 1;
const sourceHeight = source.height || 1;
const { padLeft, padTop, padRight, padBottom, outWidth, outHeight } = resolvePadOutGeometry(sourceWidth, sourceHeight, node, frameIndex);
const image = makeCanvas(outWidth, outHeight);
const imageCtx = image.getContext("2d", { willReadFrequently: true });
imageCtx.fillStyle = "#000000";
imageCtx.fillRect(0, 0, outWidth, outHeight);
imageCtx.drawImage(source, padLeft, padTop, sourceWidth, sourceHeight);
const mask = makeCanvas(outWidth, outHeight);
const maskCtx = mask.getContext("2d", { willReadFrequently: true });
if (invertMask) {
maskCtx.fillStyle = "#FFFFFF";
maskCtx.fillRect(padLeft, padTop, sourceWidth, sourceHeight);
} else {
maskCtx.fillStyle = "#FFFFFF";
maskCtx.fillRect(0, 0, outWidth, outHeight);
maskCtx.clearRect(padLeft, padTop, sourceWidth, sourceHeight);
}
markPreparedMaskCanvas(mask);
return { image, mask };
}
function solveLinear8x8(matrix, vector) {
const n = 8;
const A = matrix.map((row) => row.slice());
const b = vector.slice();
for (let col = 0; col < n; col++) {
let pivot = col;
let pivotAbs = Math.abs(A[col][col]);
for (let row = col + 1; row < n; row++) {
const valueAbs = Math.abs(A[row][col]);
if (valueAbs > pivotAbs) {
pivot = row;
pivotAbs = valueAbs;
}
}
if (pivotAbs < 1e-10) return null;
if (pivot !== col) {
const tmp = A[col];
A[col] = A[pivot];
A[pivot] = tmp;
const vb = b[col];
b[col] = b[pivot];
b[pivot] = vb;
}
const inv = 1 / A[col][col];
for (let c = col; c < n; c++) A[col][c] *= inv;
b[col] *= inv;
for (let row = 0; row < n; row++) {
if (row === col) continue;
const factor = A[row][col];
if (Math.abs(factor) < 1e-12) continue;
for (let c = col; c < n; c++) A[row][c] -= factor * A[col][c];
b[row] -= factor * b[col];
}
}
return b;
}
function invert3x3(m) {
const a = m[0], b = m[1], c = m[2];
const d = m[3], e = m[4], f = m[5];
const g = m[6], h = m[7], i = m[8];
const A = e * i - f * h;
const B = -(d * i - f * g);
const C = d * h - e * g;
const D = -(b * i - c * h);
const E = a * i - c * g;
const F = -(a * h - b * g);
const G = b * f - c * e;
const H = -(a * f - c * d);
const I = a * e - b * d;
const det = a * A + b * B + c * C;
if (!Number.isFinite(det) || Math.abs(det) < 1e-10) return null;
const invDet = 1 / det;
return [A * invDet, D * invDet, G * invDet, B * invDet, E * invDet, H * invDet, C * invDet, F * invDet, I * invDet];
}
function solveCornerPinInverseHomography(node, width, height, frameIndex = 0) {
const src = [
[0, 0],
[Math.max(0, width - 1), 0],
[0, Math.max(0, height - 1)],
[Math.max(0, width - 1), Math.max(0, height - 1)]
];
const dst = [
[numAny(node, ["tl_x"], 0, frameIndex) * Math.max(0, width - 1), numAny(node, ["tl_y"], 0, frameIndex) * Math.max(0, height - 1)],
[numAny(node, ["tr_x"], 1, frameIndex) * Math.max(0, width - 1), numAny(node, ["tr_y"], 0, frameIndex) * Math.max(0, height - 1)],
[numAny(node, ["bl_x"], 0, frameIndex) * Math.max(0, width - 1), numAny(node, ["bl_y"], 1, frameIndex) * Math.max(0, height - 1)],
[numAny(node, ["br_x"], 1, frameIndex) * Math.max(0, width - 1), numAny(node, ["br_y"], 1, frameIndex) * Math.max(0, height - 1)]
];
const A = [];
const b = [];
for (let idx = 0; idx < 4; idx++) {
const x = src[idx][0];
const y = src[idx][1];
const u = dst[idx][0];
const v = dst[idx][1];
A.push([x, y, 1, 0, 0, 0, -u * x, -u * y]);
b.push(u);
A.push([0, 0, 0, x, y, 1, -v * x, -v * y]);
b.push(v);
}
const solved = solveLinear8x8(A, b);
if (!solved) return null;
const H = [solved[0], solved[1], solved[2], solved[3], solved[4], solved[5], solved[6], solved[7], 1];
return invert3x3(H);
}
function reflectCoord(value, maxInclusive) {
if (maxInclusive <= 0) return 0;
const period = maxInclusive * 2;
let x = value % period;
if (x < 0) x += period;
if (x > maxInclusive) x = period - x;
return x;
}
function sampleChannelNearest(data, width, height, x, y, channel) {
const ix = Math.max(0, Math.min(width - 1, Math.round(x)));
const iy = Math.max(0, Math.min(height - 1, Math.round(y)));
return data[(iy * width + ix) * 4 + channel];
}
function sampleChannelBilinear(data, width, height, x, y, channel) {
const x0 = Math.floor(x);
const y0 = Math.floor(y);
const x1 = Math.min(width - 1, x0 + 1);
const y1 = Math.min(height - 1, y0 + 1);
const fx = x - x0;
const fy = y - y0;
const c00 = data[(Math.max(0, y0) * width + Math.max(0, x0)) * 4 + channel];
const c10 = data[(Math.max(0, y0) * width + Math.max(0, x1)) * 4 + channel];
const c01 = data[(Math.max(0, y1) * width + Math.max(0, x0)) * 4 + channel];
const c11 = data[(Math.max(0, y1) * width + Math.max(0, x1)) * 4 + channel];
return (c00 * (1 - fx) + c10 * fx) * (1 - fy) + (c01 * (1 - fx) + c11 * fx) * fy;
}
function cubicHermite(a, b, c, d, t) {
const a1 = -0.5 * a + 1.5 * b - 1.5 * c + 0.5 * d;
const a2 = a - 2.5 * b + 2 * c - 0.5 * d;
const a3 = -0.5 * a + 0.5 * c;
return ((a1 * t + a2) * t + a3) * t + b;
}
function sampleChannelBicubic(data, width, height, x, y, channel) {
const x1 = Math.floor(x);
const y1 = Math.floor(y);
const tx = x - x1;
const ty = y - y1;
const rows = new Array(4);
for (let row = -1; row <= 2; row++) {
const iy = Math.max(0, Math.min(height - 1, y1 + row));
const p0 = data[(iy * width + Math.max(0, Math.min(width - 1, x1 - 1))) * 4 + channel];
const p1 = data[(iy * width + Math.max(0, Math.min(width - 1, x1))) * 4 + channel];
const p2 = data[(iy * width + Math.max(0, Math.min(width - 1, x1 + 1))) * 4 + channel];
const p3 = data[(iy * width + Math.max(0, Math.min(width - 1, x1 + 2))) * 4 + channel];
rows[row + 1] = cubicHermite(p0, p1, p2, p3, tx);
}
return cubicHermite(rows[0], rows[1], rows[2], rows[3], ty);
}
function solveInverseHomographyFromCorners(sourceWidth, sourceHeight, corners) {
const src = [
[0, 0],
[Math.max(0, sourceWidth - 1), 0],
[0, Math.max(0, sourceHeight - 1)],
[Math.max(0, sourceWidth - 1), Math.max(0, sourceHeight - 1)]
];
const dst = [
[corners.tl.x, corners.tl.y],
[corners.tr.x, corners.tr.y],
[corners.bl.x, corners.bl.y],
[corners.br.x, corners.br.y]
];
const A = [];
const b = [];
for (let idx = 0; idx < 4; idx++) {
const x = src[idx][0];
const y = src[idx][1];
const u = dst[idx][0];
const v = dst[idx][1];
A.push([x, y, 1, 0, 0, 0, -u * x, -u * y]);
b.push(u);
A.push([0, 0, 0, x, y, 1, -v * x, -v * y]);
b.push(v);
}
const solved = solveLinear8x8(A, b);
if (!solved) return null;
return invert3x3([solved[0], solved[1], solved[2], solved[3], solved[4], solved[5], solved[6], solved[7], 1]);
}
function warpCanvasToQuad(source, outputWidth, outputHeight, corners, filter = "bilinear") {
const width = source.width || 1;
const height = source.height || 1;
const inverse = solveInverseHomographyFromCorners(width, height, corners);
const image = makeCanvas(outputWidth, outputHeight);
const mask = makeCanvas(outputWidth, outputHeight);
if (!inverse) {
markPreparedMaskCanvas(mask);
return { image, mask };
}
const sourceCtx = source.getContext("2d", { willReadFrequently: true });
const srcImage = sourceCtx.getImageData(0, 0, width, height);
const srcData = srcImage.data;
const imageCtx = image.getContext("2d", { willReadFrequently: true });
const outImage = imageCtx.createImageData(outputWidth, outputHeight);
const outData = outImage.data;
const maskCtx = mask.getContext("2d", { willReadFrequently: true });
const outMask = maskCtx.createImageData(outputWidth, outputHeight);
const outMaskData = outMask.data;
const useNearest = filter === "nearest";
const useBicubic = filter === "bicubic";
for (let y = 0; y < outputHeight; y++) {
for (let x = 0; x < outputWidth; x++) {
const outOffset = (y * outputWidth + x) * 4;
const denom = inverse[6] * x + inverse[7] * y + inverse[8];
const safeDenom = Math.abs(denom) < 1e-8 ? denom < 0 ? -1e-8 : 1e-8 : denom;
const sx = (inverse[0] * x + inverse[1] * y + inverse[2]) / safeDenom;
const sy = (inverse[3] * x + inverse[4] * y + inverse[5]) / safeDenom;
const inside = sx >= 0 && sx <= width - 1 && sy >= 0 && sy <= height - 1;
if (!inside) continue;
const r = useNearest ? sampleChannelNearest(srcData, width, height, sx, sy, 0) : useBicubic ? sampleChannelBicubic(srcData, width, height, sx, sy, 0) : sampleChannelBilinear(srcData, width, height, sx, sy, 0);
const g = useNearest ? sampleChannelNearest(srcData, width, height, sx, sy, 1) : useBicubic ? sampleChannelBicubic(srcData, width, height, sx, sy, 1) : sampleChannelBilinear(srcData, width, height, sx, sy, 1);
const b = useNearest ? sampleChannelNearest(srcData, width, height, sx, sy, 2) : useBicubic ? sampleChannelBicubic(srcData, width, height, sx, sy, 2) : sampleChannelBilinear(srcData, width, height, sx, sy, 2);
const a = useNearest ? sampleChannelNearest(srcData, width, height, sx, sy, 3) : useBicubic ? sampleChannelBicubic(srcData, width, height, sx, sy, 3) : sampleChannelBilinear(srcData, width, height, sx, sy, 3);
outData[outOffset] = Math.round(clamp01(r / 255) * 255);
outData[outOffset + 1] = Math.round(clamp01(g / 255) * 255);
outData[outOffset + 2] = Math.round(clamp01(b / 255) * 255);
outData[outOffset + 3] = Math.round(clamp01(a / 255) * 255);
const alpha = Math.round(clamp01(a / 255) * 255);
outMaskData[outOffset] = 255;
outMaskData[outOffset + 1] = 255;
outMaskData[outOffset + 2] = 255;
outMaskData[outOffset + 3] = alpha;
}
}
imageCtx.putImageData(outImage, 0, 0);
maskCtx.putImageData(outMask, 0, 0);
markPreparedMaskCanvas(mask);
return { image, mask };
}
function renderCornerPinCanvases(node, source, frameIndex = 0) {
const width = source.width || 1;
const height = source.height || 1;
const filter = strAny(node, ["filter"], "bilinear", frameIndex).toLowerCase();
const fillMode = normalizeAffineFillMode(strAny(node, ["fill_mode", "edge_mode"], "transparent", frameIndex));
const fillColor = parseHexColor(strAny(node, ["fill_color"], "#000000", frameIndex));
const supersample = Math.max(1, Math.min(4, Math.round(numAny(node, ["supersample"], 1, frameIndex))));
const invertMask = boolAny(node, ["invert_mask"], false, frameIndex);
const bypass = boolAny(node, ["bypass"], false, frameIndex);
if (bypass) {
const image2 = fitCanvas(source, width, height);
const mask2 = makeCanvas(width, height);
const maskCtx2 = mask2.getContext("2d", { willReadFrequently: true });
if (!invertMask) {
maskCtx2.fillStyle = "#FFFFFF";
maskCtx2.fillRect(0, 0, width, height);
}
markPreparedMaskCanvas(mask2);
return { image: image2, mask: mask2 };
}
const inverse = solveCornerPinInverseHomography(node, width, height, frameIndex);
if (!inverse) {
const image2 = fitCanvas(source, width, height);
const mask2 = makeCanvas(width, height);
const maskCtx2 = mask2.getContext("2d", { willReadFrequently: true });
if (invertMask) {
maskCtx2.fillStyle = "#FFFFFF";
maskCtx2.fillRect(0, 0, width, height);
}
markPreparedMaskCanvas(mask2);
return { image: image2, mask: mask2 };
}
const sourceCtx = source.getContext("2d", { willReadFrequently: true });
const srcImage = sourceCtx.getImageData(0, 0, width, height);
const srcData = srcImage.data;
const image = makeCanvas(width, height);
const imageCtx = image.getContext("2d", { willReadFrequently: true });
if (fillMode === "color") {
imageCtx.fillStyle = fillColor;
imageCtx.fillRect(0, 0, width, height);
} else if (fillMode === "stretch") {
imageCtx.drawImage(source, 0, 0, width, height);
}
const outImage = imageCtx.getImageData(0, 0, width, height);
const outData = outImage.data;
const mask = makeCanvas(width, height);
const maskCtx = mask.getContext("2d", { willReadFrequently: true });
const outMask = maskCtx.createImageData(width, height);
const outMaskData = outMask.data;
const useNearest = filter === "nearest";
const useBicubic = filter === "bicubic";
const sampleCount = supersample * supersample;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const outOffset = (y * width + x) * 4;
let premulR = 0;
let premulG = 0;
let premulB = 0;
let alphaSum = 0;
let insideSum = 0;
let coveredAlphaSum = 0;
for (let subY = 0; subY < supersample; subY++) {
const dstY = supersample === 1 ? y : y + (subY + 0.5) / supersample - 0.5;
for (let subX = 0; subX < supersample; subX++) {
const dstX = supersample === 1 ? x : x + (subX + 0.5) / supersample - 0.5;
const denom = inverse[6] * dstX + inverse[7] * dstY + inverse[8];
const safeDenom = Math.abs(denom) < 1e-8 ? denom < 0 ? -1e-8 : 1e-8 : denom;
let sx = (inverse[0] * dstX + inverse[1] * dstY + inverse[2]) / safeDenom;
let sy = (inverse[3] * dstX + inverse[4] * dstY + inverse[5]) / safeDenom;
const inside = sx >= 0 && sx <= width - 1 && sy >= 0 && sy <= height - 1;
if (inside) insideSum += 1;
if (!inside) {
if (fillMode === "expand") {
sx = Math.max(0, Math.min(width - 1, sx));
sy = Math.max(0, Math.min(height - 1, sy));
} else if (fillMode === "mirror") {
sx = reflectCoord(sx, width - 1);
sy = reflectCoord(sy, height - 1);
} else {
continue;
}
}
const r = useNearest ? sampleChannelNearest(srcData, width, height, sx, sy, 0) : useBicubic ? sampleChannelBicubic(srcData, width, height, sx, sy, 0) : sampleChannelBilinear(srcData, width, height, sx, sy, 0);
const g = useNearest ? sampleChannelNearest(srcData, width, height, sx, sy, 1) : useBicubic ? sampleChannelBicubic(srcData, width, height, sx, sy, 1) : sampleChannelBilinear(srcData, width, height, sx, sy, 1);
const b = useNearest ? sampleChannelNearest(srcData, width, height, sx, sy, 2) : useBicubic ? sampleChannelBicubic(srcData, width, height, sx, sy, 2) : sampleChannelBilinear(srcData, width, height, sx, sy, 2);
const a = useNearest ? sampleChannelNearest(srcData, width, height, sx, sy, 3) : useBicubic ? sampleChannelBicubic(srcData, width, height, sx, sy, 3) : sampleChannelBilinear(srcData, width, height, sx, sy, 3);
premulR += r * (a / 255);
premulG += g * (a / 255);
premulB += b * (a / 255);
alphaSum += a;
if (inside) coveredAlphaSum += a;
}
}
const alpha = alphaSum / sampleCount;
const alpha01 = alpha / 255;
const fgR = alpha01 > 1e-6 ? clamp01(premulR / sampleCount / alpha01 / 255) : 0;
const fgG = alpha01 > 1e-6 ? clamp01(premulG / sampleCount / alpha01 / 255) : 0;
const fgB = alpha01 > 1e-6 ? clamp01(premulB / sampleCount / alpha01 / 255) : 0;
const bgA = clamp01(outData[outOffset + 3] / 255);
const bgR = outData[outOffset] / 255;
const bgG = outData[outOffset + 1] / 255;
const bgB = outData[outOffset + 2] / 255;
const outA = alpha01 + bgA * (1 - alpha01);
const premulOutR = fgR * alpha01 + bgR * bgA * (1 - alpha01);
const premulOutG = fgG * alpha01 + bgG * bgA * (1 - alpha01);
const premulOutB = fgB * alpha01 + bgB * bgA * (1 - alpha01);
outData[outOffset] = outA > 1e-6 ? Math.round(clamp01(premulOutR / outA) * 255) : 0;
outData[outOffset + 1] = outA > 1e-6 ? Math.round(clamp01(premulOutG / outA) * 255) : 0;
outData[outOffset + 2] = outA > 1e-6 ? Math.round(clamp01(premulOutB / outA) * 255) : 0;
outData[outOffset + 3] = Math.round(clamp01(outA) * 255);
const maskValue = fillMode === "transparent" ? Math.round(clamp01(coveredAlphaSum / sampleCount / 255) * 255) : 255;
const finalMask = invertMask ? 255 - maskValue : maskValue;
outMaskData[outOffset] = 255;
outMaskData[outOffset + 1] = 255;
outMaskData[outOffset + 2] = 255;
outMaskData[outOffset + 3] = finalMask;
}
}
imageCtx.putImageData(outImage, 0, 0);
maskCtx.putImageData(outMask, 0, 0);
markPreparedMaskCanvas(mask);
return { image, mask };
}
function buildMaskAlphaCanvas(maskCanvas, width, height) {
if (isPreparedMaskCanvas(maskCanvas) && (maskCanvas.width || 1) === width && (maskCanvas.height || 1) === height) {
return maskCanvas;
}
const cacheKey = `${Math.max(1, width)}x${Math.max(1, height)}`;
const cachedPrepared = preparedMaskCache.get(maskCanvas)?.get(cacheKey);
if (cachedPrepared) return cachedPrepared;
const output = makeCanvas(width, height);
const octx = output.getContext("2d", { willReadFrequently: true });
octx.clearRect(0, 0, width, height);
octx.imageSmoothingEnabled = true;
octx.imageSmoothingQuality = "high";
octx.drawImage(maskCanvas, 0, 0, width, height);
const image = octx.getImageData(0, 0, width, height);
const data = image.data;
for (let index = 0; index < data.length; index += 4) {
const alpha = data[index + 3] / 255;
const luma = luma01(data[index] / 255, data[index + 1] / 255, data[index + 2] / 255, getOpsConstants().luma_weights);
const matte = Math.round(clamp01(luma * alpha) * 255);
data[index] = 255;
data[index + 1] = 255;
data[index + 2] = 255;
data[index + 3] = matte;
}
octx.putImageData(image, 0, 0);
const prepared = markPreparedMaskCanvas(output);
const cache = preparedMaskCache.get(maskCanvas) ?? /* @__PURE__ */ new Map();
cache.set(cacheKey, prepared);
preparedMaskCache.set(maskCanvas, cache);
return prepared;
}
function maskCanvasToPreviewCanvas(maskCanvas, includeAlpha = false) {
const prepared = buildMaskAlphaCanvas(maskCanvas, maskCanvas.width || 1, maskCanvas.height || 1);
const output = makeCanvas(prepared.width || 1, prepared.height || 1);
const octx = output.getContext("2d", { willReadFrequently: true });
octx.drawImage(prepared, 0, 0, output.width, output.height);
const image = octx.getImageData(0, 0, output.width, output.height);
const data = image.data;
for (let index = 0; index < data.length; index += 4) {
const matte = data[index + 3];
data[index] = matte;
data[index + 1] = matte;
data[index + 2] = matte;
data[index + 3] = includeAlpha ? matte : 255;
}
octx.putImageData(image, 0, 0);
return output;
}
function alphaMaskCanvas(source) {
const output = makeCanvas(source.width || 1, source.height || 1);
const octx = output.getContext("2d", { willReadFrequently: true });
octx.clearRect(0, 0, output.width, output.height);
octx.drawImage(source, 0, 0, output.width, output.height);
const image = octx.getImageData(0, 0, output.width, output.height);
const data = image.data;
for (let index = 0; index < data.length; index += 4) {
const matte = data[index + 3];
const rgb = matte > 0 ? 255 : 0;
data[index] = rgb;
data[index + 1] = rgb;
data[index + 2] = rgb;
data[index + 3] = matte;
}
octx.putImageData(image, 0, 0);
return markPreparedMaskCanvas(output);
}
function canvasHasVisibleTransparency(source) {
const ctx = source.getContext("2d", { willReadFrequently: true });
if (!ctx) return false;
const data = ctx.getImageData(0, 0, source.width || 1, source.height || 1).data;
for (let index = 3; index < data.length; index += 4) {
if (data[index] < 255) return true;
}
return false;
}
function maskConvertSourceValue(data, index, sourceMode, useAlpha, lumaWeights) {
const normalized = String(sourceMode || "auto").toLowerCase().replace(/[-\s]+/g, "_");
const r = data[index] / 255;
const g = data[index + 1] / 255;
const b = data[index + 2] / 255;
const a = data[index + 3] / 255;
if (normalized === "auto" && useAlpha) return a;
if (normalized === "alpha") return a;
if (normalized === "red" || normalized === "r") return r;
if (normalized === "green" || normalized === "g") return g;
if (normalized === "blue" || normalized === "b") return b;
if (normalized === "max_rgb" || normalized === "max" || normalized === "value" || normalized === "v") return Math.max(r, g, b);
if (normalized === "saturation" || normalized === "sat" || normalized === "chroma") {
const hi = Math.max(r, g, b);
const lo = Math.min(r, g, b);
return hi > 1e-6 ? (hi - lo) / hi : 0;
}
return luma01(r, g, b, lumaWeights);
}
function applyMaskConvertLevels(value, blackPoint, whitePoint) {
const black = clamp01(blackPoint);
const white = Math.max(black + 1e-6, clamp01(whitePoint));
return clamp01((value - black) / (white - black));
}
function imageToMaskPreviewCanvas(source, node, frameIndex = 0) {
const output = makeCanvas(source.width || 1, source.height || 1);
const octx = output.getContext("2d", { willReadFrequently: true });
octx.drawImage(source, 0, 0, output.width, output.height);
const image = octx.getImageData(0, 0, output.width, output.height);
const data = image.data;
let minAlpha = 255;
let maxAlpha = 255;
for (let index = 0; index < data.length; index += 4) {
const alpha = data[index + 3];
if (alpha < minAlpha) minAlpha = alpha;
if (alpha > maxAlpha) maxAlpha = alpha;
}
const useAlpha = maxAlpha - minAlpha > 0 || minAlpha < 255;
const lw = getOpsConstants().luma_weights;
const sourceMode = strAny(node ?? {}, ["mask_source", "source", "channel"], "auto", frameIndex);
for (let index = 0; index < data.length; index += 4) {
const matte = Math.round(clamp01(maskConvertSourceValue(data, index, sourceMode, useAlpha, lw)) * 255);
data[index] = matte;
data[index + 1] = matte;
data[index + 2] = matte;
data[index + 3] = 255;
}
octx.putImageData(image, 0, 0);
const antialiasRadius = Math.max(0, numAny(node ?? {}, ["antialias_radius", "mask_antialias", "antialias"], 0, frameIndex));
const levelsSource = antialiasRadius > 0 ? makeCanvas(output.width, output.height) : output;
if (antialiasRadius > 0) {
const blurCtx = levelsSource.getContext("2d", { willReadFrequently: true });
blurCtx.filter = `blur(${antialiasRadius}px)`;
blurCtx.drawImage(output, 0, 0);
blurCtx.filter = "none";
}
const blackPoint = numAny(node ?? {}, ["black_point", "black"], 0, frameIndex);
const whitePoint = numAny(node ?? {}, ["white_point", "white"], 1, frameIndex);
const levelsCtx = levelsSource.getContext("2d", { willReadFrequently: true });
const levelsImage = levelsCtx.getImageData(0, 0, levelsSource.width, levelsSource.height);
const levelsData = levelsImage.data;
for (let index = 0; index < levelsData.length; index += 4) {
const matte = Math.round(applyMaskConvertLevels(levelsData[index] / 255, blackPoint, whitePoint) * 255);
const rgb = matte > 0 ? 255 : 0;
levelsData[index] = rgb;
levelsData[index + 1] = rgb;
levelsData[index + 2] = rgb;
levelsData[index + 3] = matte;
}
levelsCtx.putImageData(levelsImage, 0, 0);
return markPreparedMaskCanvas(levelsSource);
}
function applyEffectToCanvas(source, effect) {
const output = makeCanvas(source.width || 1, source.height || 1);
const copyCtx = output.getContext("2d", { willReadFrequently: true });
copyCtx.drawImage(source, 0, 0, output.width, output.height);
const octx = output.getContext("2d", { willReadFrequently: true });
const result = effect(octx, output.width, output.height);
return result instanceof HTMLCanvasElement ? result : output;
}
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, frameIndex) ? invertMaskCanvas(matte) : matte;
}
function compositeProcessedWithMask(baseCanvas, processedCanvas, maskCanvas) {
if (!maskCanvas) return processedCanvas;
const output = makeCanvas(processedCanvas.width || 1, processedCanvas.height || 1);
const octx = output.getContext("2d", { willReadFrequently: true });
octx.imageSmoothingEnabled = true;
octx.imageSmoothingQuality = "high";
octx.clearRect(0, 0, output.width, output.height);
octx.drawImage(baseCanvas, 0, 0, output.width, output.height);
octx.drawImage(
premultLayerWithMask(processedCanvas, maskCanvas),
0,
0,
output.width,
output.height
);
return output;
}
function renderMaskedEffectPreview(node, source, rawMask, processImage, options = {}) {
const mask = resolvePreviewMaskCanvas(node, source, rawMask, options.frameIndex ?? 0);
if (!mask) return processImage(source);
const processed = processImage(options.premultBeforeProcess ? premultLayerWithMask(source, mask) : source);
if (options.compositeWithBase === false) return processed;
const processedMask = options.processMask ? options.processMask(mask) : fitCanvas(mask, processed.width || 1, processed.height || 1);
return compositeProcessedWithMask(options.baseCanvas ?? source, processed, processedMask);
}
function premultLayerWithMask(imageCanvas, maskCanvas) {
if (!maskCanvas) return imageCanvas;
const output = makeCanvas(imageCanvas.width || 1, imageCanvas.height || 1);
const octx = output.getContext("2d", { willReadFrequently: true });
octx.clearRect(0, 0, output.width, output.height);
octx.imageSmoothingEnabled = true;
octx.imageSmoothingQuality = "high";
octx.drawImage(imageCanvas, 0, 0, output.width, output.height);
octx.globalCompositeOperation = "destination-in";
const preparedMask = isPreparedMaskCanvas(maskCanvas) && (maskCanvas.width || 1) === output.width && (maskCanvas.height || 1) === output.height ? maskCanvas : buildMaskAlphaCanvas(maskCanvas, output.width, output.height);
octx.drawImage(preparedMask, 0, 0, output.width, output.height);
octx.globalCompositeOperation = "source-over";
return output;
}
function invertMaskCanvas(maskCanvas) {
const prepared = buildMaskAlphaCanvas(maskCanvas, maskCanvas.width || 1, maskCanvas.height || 1);
const output = makeCanvas(prepared.width || 1, prepared.height || 1);
const octx = output.getContext("2d", { willReadFrequently: true });
octx.drawImage(prepared, 0, 0, output.width, output.height);
const image = octx.getImageData(0, 0, output.width, output.height);
const data = image.data;
for (let i = 0; i < data.length; i += 4) {
data[i] = 255;
data[i + 1] = 255;
data[i + 2] = 255;
data[i + 3] = 255 - data[i + 3];
}
octx.putImageData(image, 0, 0);
return markPreparedMaskCanvas(output);
}
function invertMaskAlphaCanvas(maskCanvas) {
return invertMaskCanvas(maskCanvas);
}
function normalizePreparedMaskCanvas(maskCanvas) {
const prepared = isPreparedMaskCanvas(maskCanvas) ? maskCanvas : buildMaskAlphaCanvas(maskCanvas, maskCanvas.width || 1, maskCanvas.height || 1);
const output = makeCanvas(prepared.width || 1, prepared.height || 1);
const octx = output.getContext("2d", { willReadFrequently: true });
octx.drawImage(prepared, 0, 0, output.width, output.height);
const image = octx.getImageData(0, 0, output.width, output.height);
const data = image.data;
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] <= 0) {
data[i] = 0;
data[i + 1] = 0;
data[i + 2] = 0;
continue;
}
data[i] = 255;
data[i + 1] = 255;
data[i + 2] = 255;
}
octx.putImageData(image, 0, 0);
return markPreparedMaskCanvas(output);
}
function blurMaskAlphaCanvas(maskCanvas, radius) {
const prepared = buildMaskAlphaCanvas(maskCanvas, maskCanvas.width || 1, maskCanvas.height || 1);
const safeRadius = Math.max(0, radius);
if (safeRadius <= 0) return prepared;
const output = makeCanvas(prepared.width || 1, prepared.height || 1);
const octx = output.getContext("2d", { willReadFrequently: true });
octx.filter = `blur(${safeRadius}px)`;
octx.drawImage(prepared, 0, 0, output.width, output.height);
octx.filter = "none";
return normalizePreparedMaskCanvas(output);
}
function emptyMaskCanvas(width, height) {
return markPreparedMaskCanvas(makeCanvas(width, height));
}
function renderSpherizeMaskCanvas(node, source, rawMask, frameIndex = 0) {
let width = source.width || 1;
let height = source.height || 1;
if (strAny(node, ["size_mode"], "from_input", frameIndex).toLowerCase().trim() === "custom") {
width = Math.max(64, Math.round(numAny(node, ["width"], width, frameIndex)));
height = Math.max(64, Math.round(numAny(node, ["height"], height, frameIndex)));
}
const output = makeCanvas(width, height);
const octx = output.getContext("2d", { willReadFrequently: true });
const image = octx.createImageData(width, height);
const data = image.data;
const fittedMask = rawMask ? buildMaskAlphaCanvas(rawMask, width, height) : null;
const maskData = fittedMask?.getContext("2d", { willReadFrequently: true }).getImageData(0, 0, width, height).data ?? null;
for (let y = 0; y < height; y++) {
const gy = height > 1 ? y / (height - 1) * 2 - 1 : 0;
for (let x = 0; x < width; x++) {
const gx = width > 1 ? x / (width - 1) * 2 - 1 : 0;
const offset = (y * width + x) * 4;
const circleMask = gx * gx + gy * gy <= 1 ? 255 : 0;
const matte = maskData ? Math.round(circleMask / 255 * (maskData[offset + 3] / 255) * 255) : circleMask;
data[offset] = 255;
data[offset + 1] = 255;
data[offset + 2] = 255;
data[offset + 3] = matte;
}
}
octx.putImageData(image, 0, 0);
return markPreparedMaskCanvas(output);
}
function renderCompPreview(node, inputLayers) {
const slots = getCompSlots(node);
const allLayers = syncCompLayers(str(node, "layers_json", ""), slots);
const layerBySlot = new Map(allLayers.map((layer) => [layer.slot, layer]));
const firstInput = inputLayers[0] ?? null;
const useAutoLayering = bool(node, "auto_layering", false);
const useFirst = bool(node, "use_first_layer_size", true);
const largestWidth = inputLayers.reduce((value, entry) => Math.max(value, entry.sourceWidth || entry.image.width || 1), 1);
const largestHeight = inputLayers.reduce((value, entry) => Math.max(value, entry.sourceHeight || entry.image.height || 1), 1);
const customAspect = str(node, "aspect_ratio", "custom").trim().toLowerCase();
const customRatio = customAspect === "1:1" || customAspect === "1/1" ? 1 : customAspect === "3:4" || customAspect === "3/4" ? 3 / 4 : customAspect === "4:3" || customAspect === "4/3" ? 4 / 3 : customAspect === "16:9" || customAspect === "16/9" ? 16 / 9 : customAspect === "9:16" || customAspect === "9/16" ? 9 / 16 : null;
const outputWidth = useAutoLayering ? largestWidth : useFirst && firstInput ? Math.max(1, firstInput.sourceWidth || firstInput.image.width || 1) : Math.max(1, Math.round(num(node, "width", firstInput?.sourceWidth ?? firstInput?.image.width ?? 1024)));
const outputHeight = useAutoLayering ? largestHeight : useFirst && firstInput ? Math.max(1, firstInput.sourceHeight || firstInput.image.height || 1) : customRatio ? Math.max(1, Math.round(outputWidth / customRatio)) : Math.max(1, Math.round(num(node, "height", firstInput?.sourceHeight ?? firstInput?.image.height ?? 1024)));
if (useAutoLayering || useFirst && firstInput || !useFirst && !useAutoLayering && customRatio) {
const ww = w(node, "width");
const hw = w(node, "height");
setWidgetValue(ww, outputWidth);
setWidgetValue(hw, outputHeight);
}
const output = makeCanvas(outputWidth, outputHeight);
const octx = output.getContext("2d", { willReadFrequently: true });
const alphaCanvas = makeCanvas(outputWidth, outputHeight);
const alphaCtx = alphaCanvas.getContext("2d", { willReadFrequently: true });
octx.fillStyle = parseHexColor(str(node, "background_color", "#000000"));
octx.fillRect(0, 0, outputWidth, outputHeight);
alphaCtx.clearRect(0, 0, outputWidth, outputHeight);
const geometries = [];
for (let index = 0; index < inputLayers.length; index++) {
const entry = inputLayers[index];
const input = premultLayerWithMask(entry.image, entry.mask ?? null);
const layer = layerBySlot.get(entry.slot);
if (!input || !layer || layer.enabled === false) continue;
const sourceWidth = Math.max(1, entry.sourceWidth || entry.image.width || 1);
const sourceHeight = Math.max(1, entry.sourceHeight || entry.image.height || 1);
const rect = computeCompRect(outputWidth, outputHeight, sourceWidth, sourceHeight, layer);
const corners = getCompLayerOutputCorners(outputWidth, outputHeight, sourceWidth, sourceHeight, layer);
const cornerPinned = hasCompLayerCornerPin(layer);
geometries.push({
slot: entry.slot,
layerNumber: entry.layerNumber,
inputIndex: entry.inputIndex,
sourceWidth,
sourceHeight,
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
centerX: rect.centerX,
centerY: rect.centerY,
drawWidth: rect.drawWidth,
drawHeight: rect.drawHeight,
rotationDeg: rect.rotationDeg,
corners,
cornerPinned
});
if (cornerPinned) {
const warped = warpCanvasToQuad(input, outputWidth, outputHeight, corners, "bilinear");
octx.save();
octx.globalAlpha = Math.max(0, Math.min(1, layer.opacity));
octx.globalCompositeOperation = compModeToCanvasOp(layer.mode);
octx.imageSmoothingEnabled = true;
octx.imageSmoothingQuality = "high";
octx.drawImage(warped.image, 0, 0, outputWidth, outputHeight);
octx.restore();
alphaCtx.save();
alphaCtx.globalAlpha = Math.max(0, Math.min(1, layer.opacity));
alphaCtx.globalCompositeOperation = "source-over";
alphaCtx.imageSmoothingEnabled = true;
alphaCtx.imageSmoothingQuality = "high";
alphaCtx.drawImage(warped.mask, 0, 0, outputWidth, outputHeight);
alphaCtx.restore();
continue;
}
octx.save();
octx.globalAlpha = Math.max(0, Math.min(1, layer.opacity));
octx.globalCompositeOperation = compModeToCanvasOp(layer.mode);
octx.imageSmoothingEnabled = true;
octx.imageSmoothingQuality = "high";
octx.translate(rect.centerX, rect.centerY);
octx.rotate(rect.rotationDeg * Math.PI / 180);
octx.drawImage(input, -rect.drawWidth / 2, -rect.drawHeight / 2, rect.drawWidth, rect.drawHeight);
octx.restore();
alphaCtx.save();
alphaCtx.globalAlpha = Math.max(0, Math.min(1, layer.opacity));
alphaCtx.globalCompositeOperation = "source-over";
alphaCtx.imageSmoothingEnabled = true;
alphaCtx.imageSmoothingQuality = "high";
alphaCtx.translate(rect.centerX, rect.centerY);
alphaCtx.rotate(rect.rotationDeg * Math.PI / 180);
alphaCtx.drawImage(input, -rect.drawWidth / 2, -rect.drawHeight / 2, rect.drawWidth, rect.drawHeight);
alphaCtx.restore();
}
const image = octx.getImageData(0, 0, outputWidth, outputHeight);
const alphaImage = alphaCtx.getImageData(0, 0, outputWidth, outputHeight);
const data = image.data;
const alphaData = alphaImage.data;
for (let index = 0; index < data.length; index += 4) {
data[index + 3] = alphaData[index + 3];
}
octx.putImageData(image, 0, 0);
return { canvas: output, layers: geometries };
}
function resolveCompPreviewInputs(node, inputs) {
const connectedSlots = getCompSlots(node).filter((slot) => (node.inputs?.[slot.inputIndex]?.link ?? null) != null);
const resolved = [];
let cursor = 0;
for (const slot of connectedSlots) {
const image = inputs[cursor++] ?? null;
if (!image) continue;
let mask = null;
if (slot.maskInputIndex != null && (node.inputs?.[slot.maskInputIndex]?.link ?? null) != null) {
mask = inputs[cursor++] ?? null;
}
resolved.push({
image,
mask,
slot: slot.slot,
layerNumber: slot.layerNumber,
inputIndex: slot.inputIndex
});
}
return resolved;
}
async function renderDrawNodePreview(node, baseCanvas = null) {
return await renderDrawPreview(node, baseCanvas);
}
function applyLumaKey(ctx, W, H, low, high, softness) {
const { epsilon: EPS, luma_weights: LW } = getOpsConstants();
const img = getImageData(ctx, W, H);
const d = img.data;
for (let i = 0; i < d.length; i += 4) {
const l = luma01(d[i] / 255, d[i + 1] / 255, d[i + 2] / 255, LW);
let a = 0;
if (l <= low) a = 0;
else if (l >= high) a = 1;
else {
const t = (l - low) / Math.max(EPS, high - low);
const s = Math.max(0, Math.min(1, softness * 10));
a = t * (1 - s) + t * t * (3 - 2 * t) * s;
}
d[i + 3] = Math.round(clamp01(a) * 255);
}
putImageData(ctx, img);
}
function rgbToHsv01(r, g, b) {
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const delta = max - min;
let hue = 0;
if (delta > 1e-4) {
if (max === r) hue = (g - b) / delta % 6;
else if (max === g) hue = (b - r) / delta + 2;
else hue = (r - g) / delta + 4;
hue /= 6;
if (hue < 0) hue += 1;
}
const sat = max <= 0 ? 0 : delta / max;
return [hue, sat, max];
}
function smoothRange01(value, low, high, softness) {
const lo = Math.min(low, high);
const hi = Math.max(low, high);
const soft = Math.max(0, softness);
if (soft <= 1e-6) return value >= lo && value <= hi ? 1 : 0;
const lower = clamp01((value - (lo - soft)) / soft);
const upper = clamp01((hi + soft - value) / soft);
const smoothLower = lower * lower * (3 - 2 * lower);
const smoothUpper = upper * upper * (3 - 2 * upper);
return clamp01(Math.min(smoothLower, smoothUpper));
}
function softKeyDistance(distance, tolerance, softness) {
const tol = clamp01(tolerance);
const soft = clamp01(softness);
if (soft <= 1e-6) return distance <= tol ? 1 : 0;
const t = clamp01((tol + soft - distance) / soft);
return t * t * (3 - 2 * t);
}
function blurMaskCanvas(source, radius) {
const normalizedRadius = Math.max(0, radius);
if (normalizedRadius <= 1e-3) return source;
const output = makeCanvas(source.width || 1, source.height || 1);
const octx = output.getContext("2d", { willReadFrequently: true });
octx.clearRect(0, 0, output.width, output.height);
octx.filter = `blur(${normalizedRadius}px)`;
octx.drawImage(source, 0, 0, output.width, output.height);
octx.filter = "none";
return output;
}
function parseKeyColors(value) {
const raw = String(value ?? "").trim();
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
const out = [];
for (const item of parsed) {
if (typeof item === "string") out.push(hexToRgb01(item));
}
return out;
} catch {
return [];
}
}
function renderKeyerCanvases(node, source, rawMask, frameIndex) {
const width = source.width || 1;
const height = source.height || 1;
const image = makeCanvas(width, height);
const mask = makeCanvas(width, height);
const ictx = image.getContext("2d", { willReadFrequently: true });
const mctx = mask.getContext("2d", { willReadFrequently: true });
ictx.drawImage(source, 0, 0, width, height);
const img = ictx.getImageData(0, 0, width, height);
const data = img.data;
const sourceAlpha = new Uint8ClampedArray(data.length / 4);
const maskImg = mctx.createImageData(width, height);
const maskData = maskImg.data;
const extMask = resolvePreviewMaskCanvas(node, source, rawMask, frameIndex);
const extData = extMask?.getContext("2d", { willReadFrequently: true })?.getImageData(0, 0, width, height).data ?? null;
const keyMode = strAny(node, ["mode", "key_mode"], "color", frameIndex).toLowerCase();
const keyColors = parseKeyColors(strAny(node, ["key_colors"], "", frameIndex));
const keyTargets = keyColors.length > 0 ? keyColors : [hexToRgb01(strAny(node, ["key_color"], "#00ff00", frameIndex))];
const tolerance = numAny(node, ["tolerance"], 0.25, frameIndex);
const softness = numAny(node, ["softness"], 0.1, frameIndex);
const gain = Math.max(0, numAny(node, ["gain"], 1, frameIndex));
const blur = Math.max(0, numAny(node, ["blur"], 0, frameIndex));
const invert = boolAny(node, ["invert"], false, frameIndex);
const LW = getOpsConstants().luma_weights;
for (let i = 0; i < data.length; i += 4) {
sourceAlpha[i / 4] = data[i + 3];
const r = data[i] / 255;
const g = data[i + 1] / 255;
const b = data[i + 2] / 255;
let distance = 0;
if (keyMode === "luma" || keyMode === "luminance") distance = luma01(r, g, b, LW);
else {
let minDistance = Number.POSITIVE_INFINITY;
for (const keyColor of keyTargets) {
minDistance = Math.min(minDistance, Math.hypot(r - keyColor[0], g - keyColor[1], b - keyColor[2]) / Math.sqrt(3));
}
distance = Number.isFinite(minDistance) ? minDistance : 0;
}
const matte = clamp01((1 - softKeyDistance(distance, tolerance, softness)) * gain);
maskData[i] = 255;
maskData[i + 1] = 255;
maskData[i + 2] = 255;
maskData[i + 3] = Math.round(matte * 255);
}
mctx.putImageData(maskImg, 0, 0);
const finalMask = blur > 1e-3 ? blurMaskCanvas(mask, blur) : mask;
const finalMaskData = finalMask.getContext("2d", { willReadFrequently: true })?.getImageData(0, 0, width, height).data ?? null;
if (finalMaskData) {
for (let i = 0; i < data.length; i += 4) {
let matte = finalMaskData[i + 3] / 255;
if (extData) matte *= extData[i + 3] / 255;
if (invert) matte = 1 - matte;
data[i + 3] = Math.round(clamp01(sourceAlpha[i / 4] / 255 * matte) * 255);
}
}
ictx.putImageData(img, 0, 0);
markPreparedMaskCanvas(finalMask);
return { image, mask: finalMask };
}
function srgbToLinear01(value) {
const v = clamp01(value);
return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
}
function linearToSrgb01(value) {
const v = clamp01(value);
return v <= 31308e-7 ? v * 12.92 : 1.055 * Math.pow(v, 1 / 2.4) - 0.055;
}
function normalizeBlendModeName(mode) {
const normalized = String(mode || "over").toLowerCase().replace(/[-\s]+/g, "_");
return normalized === "normal" ? "over" : normalized;
}
function fitMergeForeground(topCanvas, width, height, fitMode) {
const mode = String(fitMode || "stretch").toLowerCase().replace(/[-\s]+/g, "_");
const out = makeCanvas(width, height);
const octx = out.getContext("2d", { willReadFrequently: true });
octx.clearRect(0, 0, out.width, out.height);
octx.imageSmoothingEnabled = true;
octx.imageSmoothingQuality = "high";
const srcW = Math.max(1, topCanvas.width || 1);
const srcH = Math.max(1, topCanvas.height || 1);
if (mode === "stretch" || mode === "scale" || mode === "resize" || mode === "fit_exact") {
octx.drawImage(topCanvas, 0, 0, out.width, out.height);
return out;
}
let drawW = srcW;
let drawH = srcH;
if (!(mode === "none" || mode === "center" || mode === "original")) {
const fitScale = Math.min(out.width / srcW, out.height / srcH);
const fillScale = Math.max(out.width / srcW, out.height / srcH);
const scale = mode === "cover" || mode === "fill" || mode === "crop" ? fillScale : fitScale;
drawW = Math.max(1, Math.round(srcW * scale));
drawH = Math.max(1, Math.round(srcH * scale));
}
const dx = Math.round((out.width - drawW) / 2);
const dy = Math.round((out.height - drawH) / 2);
octx.drawImage(topCanvas, dx, dy, drawW, drawH);
return out;
}
function blend(ctx, W, H, topCanvas, mode, mix, foregroundFit = "stretch", blendSpace = "linear") {
const m = Math.max(0, Math.min(1, mix));
if (m <= 0) return;
const scaledTop = fitMergeForeground(topCanvas, W, H, foregroundFit);
const base = getImageData(ctx, W, H);
const top = scaledTop.getContext("2d", { willReadFrequently: true }).getImageData(0, 0, W, H);
const bd = base.data;
const td = top.data;
const blendMode = normalizeBlendModeName(mode);
const linear = String(blendSpace || "linear").toLowerCase().replace(/[-\s]+/g, "_") !== "srgb";
for (let i = 0; i < bd.length; i += 4) {
const arSrgb = bd[i] / 255;
const agSrgb = bd[i + 1] / 255;
const abSrgb = bd[i + 2] / 255;
const aa = bd[i + 3] / 255;
const brSrgb = td[i] / 255;
const bgSrgb = td[i + 1] / 255;
const bbSrgb = td[i + 2] / 255;
const ba = td[i + 3] / 255;
const ar = linear ? srgbToLinear01(arSrgb) : arSrgb;
const ag = linear ? srgbToLinear01(agSrgb) : agSrgb;
const ab = linear ? srgbToLinear01(abSrgb) : abSrgb;
const br = linear ? srgbToLinear01(brSrgb) : brSrgb;
const bg = linear ? srgbToLinear01(bgSrgb) : bgSrgb;
const bb = linear ? srgbToLinear01(bbSrgb) : bbSrgb;
if (blendMode === "over") {
const rr2 = br * ba + ar * (1 - ba);
const rg2 = bg * ba + ag * (1 - ba);
const rb2 = bb * ba + ab * (1 - ba);
const outR2 = clamp01(ar * (1 - m) + rr2 * m);
const outG2 = clamp01(ag * (1 - m) + rg2 * m);
const outB2 = clamp01(ab * (1 - m) + rb2 * m);
const mergedAlpha = ba + aa * (1 - ba);
const outA = clamp01(aa * (1 - m) + mergedAlpha * m);
bd[i] = Math.round((linear ? linearToSrgb01(outR2) : outR2) * 255);
bd[i + 1] = Math.round((linear ? linearToSrgb01(outG2) : outG2) * 255);
bd[i + 2] = Math.round((linear ? linearToSrgb01(outB2) : outB2) * 255);
bd[i + 3] = Math.round(outA * 255);
continue;
}
const rr = blendChannel01(ar, br, blendMode) * ba + ar * (1 - ba);
const rg = blendChannel01(ag, bg, blendMode) * ba + ag * (1 - ba);
const rb = blendChannel01(ab, bb, blendMode) * ba + ab * (1 - ba);
const outR = clamp01(ar * (1 - m) + clamp01(rr) * m);
const outG = clamp01(ag * (1 - m) + clamp01(rg) * m);
const outB = clamp01(ab * (1 - m) + clamp01(rb) * m);
bd[i] = Math.round((linear ? linearToSrgb01(outR) : outR) * 255);
bd[i + 1] = Math.round((linear ? linearToSrgb01(outG) : outG) * 255);
bd[i + 2] = Math.round((linear ? linearToSrgb01(outB) : outB) * 255);
bd[i + 3] = Math.round(clamp01(aa) * 255);
}
putImageData(ctx, base);
}
function resolveResizeDimensions(node, sourceWidth, sourceHeight) {
let width = Math.round(numAny(node, ["target_width", "width", "largest_size"], 0));
let height = Math.round(numAny(node, ["target_height", "height", "largest_size"], 0));
const scaleBy = numAny(node, ["scale_by", "multiplier"], 0);
const megapixels = numAny(node, ["megapixels"], 0);
const size = Math.round(numAny(node, ["size", "longer_size", "shorter_size"], 0));
const filter = normalizeFilterName(strAny(node, ["upscale_method", "interpolation", "transform_method", "filter"], "bilinear"));
const crop = strAny(node, ["crop", "crop_method"], "disabled");
const method = strAny(node, ["keep_proportion", "method", "resize_type"], crop === "center" ? "crop" : "stretch");
const keepProportion = boolAny(node, ["keep_proportion"], false);
const fillColor = strAny(node, ["pad_color", "padding_color", "background_color", "color"], "#000000");
const cropPosition = strAny(node, ["crop_position"], "center");
if (scaleBy > 0) {
width = Math.max(1, Math.round(sourceWidth * scaleBy));
height = Math.max(1, Math.round(sourceHeight * scaleBy));
} else if (size > 0) {
const useLonger = !w(node, "mode") || bool(node, "mode", true) || String(strAny(node, ["resize_type"], "")).includes("longer");
const dominant = useLonger ? Math.max(sourceWidth, sourceHeight) : Math.min(sourceWidth, sourceHeight);
const ratio = size / Math.max(1, dominant);
width = Math.max(1, Math.round(sourceWidth * ratio));
height = Math.max(1, Math.round(sourceHeight * ratio));
} else if (megapixels > 0) {
const total = megapixels * 1024 * 1024;
const ratio = sourceWidth / Math.max(1, sourceHeight);
height = Math.max(1, Math.round(Math.sqrt(total / Math.max(1e-4, ratio))));
width = Math.max(1, Math.round(height * ratio));
} else if (width === 0 && height === 0) {
width = sourceWidth;
height = sourceHeight;
} else if (width === 0) {
width = Math.max(1, Math.round(sourceWidth * (height / Math.max(1, sourceHeight))));
} else if (height === 0) {
height = Math.max(1, Math.round(sourceHeight * (width / Math.max(1, sourceWidth))));
}
const multiple = Math.round(numAny(node, ["multiple_of", "divisible_by", "resolution_steps"], 0));
if (multiple > 1) {
width = Math.max(1, width - width % multiple);
height = Math.max(1, height - height % multiple);
}
let mode = "stretch";
const normalizedMethod = String(method).toLowerCase();
if (keepProportion && normalizedMethod === "stretch" && crop !== "center") mode = "fit";
else if (normalizedMethod.includes("pad") || normalizedMethod.includes("pillarbox")) mode = "pad";
else if (normalizedMethod.includes("keep proportion") || normalizedMethod === "resize" || normalizedMethod.includes("fit")) mode = "fit";
else if (normalizedMethod.includes("fill") || normalizedMethod.includes("crop") || crop === "center") mode = "crop";
return {
width: Math.max(1, width),
height: Math.max(1, height),
mode,
filter,
fillColor,
cropPosition
};
}
function cropRectCanvas(source, x, y, width, height) {
const out = makeCanvas(Math.max(1, width), Math.max(1, height));
const octx = out.getContext("2d", { willReadFrequently: true });
octx.clearRect(0, 0, out.width, out.height);
octx.drawImage(source, x, y, width, height, 0, 0, out.width, out.height);
return out;
}
function extractMaskDrivenCrop(source, maskCanvas, padding, targetWidth, targetHeight) {
if (!maskCanvas) return resizeWithMode(source, targetWidth, targetHeight, "bicubic", "crop");
const fittedMask = fitCanvas(maskCanvas, source.width || 1, source.height || 1);
const bounds = computeMaskBounds(fittedMask);
if (!bounds) return resizeWithMode(source, targetWidth, targetHeight, "bicubic", "crop");
const pad = Math.max(0, Math.round(padding));
const x = Math.max(0, bounds.x - pad);
const y = Math.max(0, bounds.y - pad);
const right = Math.min(source.width, bounds.x + bounds.width + pad);
const bottom = Math.min(source.height, bounds.y + bounds.height + pad);
const cropped = cropRectCanvas(source, x, y, Math.max(1, right - x), Math.max(1, bottom - y));
return resizeWithMode(cropped, targetWidth, targetHeight, "bicubic", "crop");
}
function parseCropStitchBBox(node, sourceWidth, sourceHeight, frameIndex = 0) {
const raw = resolveConnectedString(node, "crop_bbox") ?? strAny(node, ["crop_bbox", "bbox"], "");
if (!raw) return null;
let payload = raw;
try {
payload = JSON.parse(raw);
} catch {
return null;
}
const root = Array.isArray(payload) ? payload[0] : payload;
if (!root || typeof root !== "object") return null;
const obj = root;
const frames = Array.isArray(obj.frames) ? obj.frames : null;
const bbox = frames ? frames[Math.max(0, Math.min(frames.length - 1, Math.round(frameIndex)))] : obj.bbox;
if (!bbox || typeof bbox !== "object") return null;
const width = Math.max(1, Math.min(sourceWidth, Math.round(Number(bbox.width) || sourceWidth)));
const height = Math.max(1, Math.min(sourceHeight, Math.round(Number(bbox.height) || sourceHeight)));
const x = Math.max(0, Math.min(sourceWidth - width, Math.round(Number(bbox.x) || 0)));
const y = Math.max(0, Math.min(sourceHeight - height, Math.round(Number(bbox.y) || 0)));
return { x, y, width, height };
}
function cropStitchBBoxFromMask(maskCanvas, width, height) {
if (!maskCanvas) return { x: 0, y: 0, width, height };
const fittedMask = buildMaskAlphaCanvas(maskCanvas, width, height);
const bounds = computeMaskBounds(fittedMask);
return bounds ?? { x: 0, y: 0, width, height };
}
function makeCropStitchRectMask(width, height, bbox) {
const mask = makeCanvas(width, height);
const mctx = mask.getContext("2d", { willReadFrequently: true });
mctx.clearRect(0, 0, width, height);
mctx.fillStyle = "#ffffff";
mctx.fillRect(bbox.x, bbox.y, bbox.width, bbox.height);
return markPreparedMaskCanvas(mask);
}
function renderCropStitchCanvases(node, inputs, frameIndex = 0) {
const original = inputs[0] ?? makeCanvas(1, 1);
const crop = inputs[1] ?? original;
const width = Math.max(1, original.width || 1);
const height = Math.max(1, original.height || 1);
const cropMaskInput = inputs[2] ?? null;
const bbox = parseCropStitchBBox(node, width, height, frameIndex) ?? cropStitchBBoxFromMask(cropMaskInput, width, height);
const fittedCrop = fitCanvas(crop, bbox.width, bbox.height);
const stitchLayer = makeCanvas(width, height);
const layerCtx = stitchLayer.getContext("2d", { willReadFrequently: true });
layerCtx.clearRect(0, 0, width, height);
layerCtx.drawImage(fittedCrop, bbox.x, bbox.y, bbox.width, bbox.height);
let mask = cropMaskInput ? buildMaskAlphaCanvas(cropMaskInput, width, height) : makeCropStitchRectMask(width, height, bbox);
const feather = Math.max(0, Math.round(numAny(node, ["feather"], 0, frameIndex)));
if (feather > 0) mask = blurMaskAlphaCanvas(mask, feather);
const image = boolAny(node, ["bypass"], false, frameIndex) ? original : compositeProcessedWithMask(original, stitchLayer, mask);
return { image, mask, crop: fittedCrop, bbox };
}
function drawCropStitchPanel(ctx, source, x, y, width, height, label, bbox) {
ctx.save();
ctx.fillStyle = "rgba(0,0,0,0.45)";
ctx.fillRect(x, y, width, height);
ctx.strokeStyle = "rgba(255,255,255,0.16)";
ctx.strokeRect(x + 0.5, y + 0.5, width - 1, height - 1);
const scale = Math.min(width / Math.max(1, source.width || 1), height / Math.max(1, source.height || 1));
const drawWidth = Math.max(1, Math.round((source.width || 1) * scale));
const drawHeight = Math.max(1, Math.round((source.height || 1) * scale));
const dx = x + Math.round((width - drawWidth) / 2);
const dy = y + Math.round((height - drawHeight) / 2);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
ctx.drawImage(source, dx, dy, drawWidth, drawHeight);
if (bbox) {
const sx = drawWidth / Math.max(1, source.width || 1);
const sy = drawHeight / Math.max(1, source.height || 1);
ctx.strokeStyle = "rgba(235,239,140,0.96)";
ctx.lineWidth = 1.5;
ctx.setLineDash([5, 3]);
ctx.strokeRect(dx + bbox.x * sx + 0.5, dy + bbox.y * sy + 0.5, bbox.width * sx, bbox.height * sy);
}
ctx.setLineDash([]);
ctx.fillStyle = "rgba(0,0,0,0.62)";
ctx.fillRect(x, y, Math.min(width, 92), 20);
ctx.fillStyle = "rgba(255,255,255,0.9)";
ctx.font = "11px sans-serif";
ctx.textBaseline = "middle";
ctx.fillText(label, x + 7, y + 10);
ctx.restore();
}
function composeCropStitchPreview(image, crop, mask, bbox) {
const maskPreview = maskCanvasToPreviewCanvas(mask);
const mainW = Math.max(1, image.width || 1);
const mainH = Math.max(1, image.height || 1);
const gap = Math.max(8, Math.round(Math.min(mainW, mainH) * 0.025));
const sideW = Math.max(96, Math.round(mainW * 0.42));
const sideH = Math.max(64, Math.round((mainH - gap) / 2));
const output = makeCanvas(mainW + gap + sideW, Math.max(mainH, sideH * 2 + gap));
const octx = output.getContext("2d", { willReadFrequently: true });
octx.fillStyle = "#111111";
octx.fillRect(0, 0, output.width, output.height);
drawCropStitchPanel(octx, image, 0, 0, mainW, output.height, "Stitched", bbox);
drawCropStitchPanel(octx, crop, mainW + gap, 0, sideW, sideH, "Edited crop");
drawCropStitchPanel(octx, maskPreview, mainW + gap, sideH + gap, sideW, sideH, "Crop mask");
return output;
}
function stitchCanvases(a, b, direction, spacingWidth, spacingColor, matchSize) {
const normalizedDirection = String(direction || "right").toLowerCase();
const spacing = Math.max(0, Math.round(spacingWidth));
const second = matchSize ? resizeWithMode(b, normalizedDirection === "up" || normalizedDirection === "down" ? a.width : b.width, normalizedDirection === "right" || normalizedDirection === "left" ? a.height : b.height, "bicubic", "stretch") : b;
const horizontal = normalizedDirection === "right" || normalizedDirection === "left";
const width = horizontal ? a.width + second.width + spacing : Math.max(a.width, second.width);
const height = horizontal ? Math.max(a.height, second.height) : a.height + second.height + spacing;
const output = makeCanvas(width, height);
const octx = output.getContext("2d", { willReadFrequently: true });
octx.fillStyle = parseHexColor(spacingColor);
octx.fillRect(0, 0, width, height);
if (normalizedDirection === "left") {
octx.drawImage(second, 0, 0);
octx.drawImage(a, second.width + spacing, 0);
} else if (normalizedDirection === "up") {
octx.drawImage(second, 0, 0);
octx.drawImage(a, 0, second.height + spacing);
} else if (normalizedDirection === "down") {
octx.drawImage(a, 0, 0);
octx.drawImage(second, 0, a.height + spacing);
} else {
octx.drawImage(a, 0, 0);
octx.drawImage(second, a.width + spacing, 0);
}
return output;
}
function extractSplitChannelCanvas(source, outputSlot, mode) {
const normalizedMode = String(mode || "rgba").toLowerCase();
const channelIndex = Math.max(0, Math.min(3, outputSlot ?? 0));
const output = makeCanvas(source.width || 1, source.height || 1);
const octx = output.getContext("2d", { willReadFrequently: true });
octx.clearRect(0, 0, output.width, output.height);
octx.drawImage(source, 0, 0, output.width, output.height);
if (normalizedMode === "rgba" || normalizedMode === "rgb") {
applyChannel(octx, output.width, output.height, channelIndex === 1 ? "Green" : channelIndex === 2 ? "Blue" : channelIndex === 3 ? "Alpha" : "Red");
return output;
}
const img = octx.getImageData(0, 0, output.width, output.height);
const data = img.data;
for (let i = 0; i < data.length; i += 4) {
const r = data[i] / 255;
const g = data[i + 1] / 255;
const b = data[i + 2] / 255;
let value = 0;
if (normalizedMode === "hsv") {
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const delta = max - min;
let hue = 0;
if (delta > 1e-4) {
if (max === r) hue = (g - b) / delta % 6;
else if (max === g) hue = (b - r) / delta + 2;
else hue = (r - g) / delta + 4;
hue /= 6;
if (hue < 0) hue += 1;
}
const sat = max <= 0 ? 0 : delta / max;
value = channelIndex === 1 ? sat : channelIndex === 2 ? max : channelIndex === 3 ? data[i + 3] / 255 : hue;
} else if (normalizedMode === "ycbcr") {
const y = clamp01(0.299 * r + 0.587 * g + 0.114 * b);
const cb = clamp01(0.5 + (-0.168736 * r - 0.331264 * g + 0.5 * b));
const cr = clamp01(0.5 + (0.5 * r - 0.418688 * g - 0.081312 * b));
value = channelIndex === 1 ? cb : channelIndex === 2 ? cr : channelIndex === 3 ? data[i + 3] / 255 : y;
} else {
const l = clamp01(luma01(r, g, b, getOpsConstants().luma_weights));
value = channelIndex === 1 ? clamp01(0.5 + (r - g) * 0.5) : channelIndex === 2 ? clamp01(0.5 + (b - g) * 0.5) : channelIndex === 3 ? data[i + 3] / 255 : l;
}
const channel = Math.round(clamp01(value) * 255);
data[i] = channel;
data[i + 1] = channel;
data[i + 2] = channel;
data[i + 3] = 255;
}
octx.putImageData(img, 0, 0);
return output;
}
function mergeChannelInputs(inputs, mode) {
if (inputs.length < 3) return null;
const width = inputs[0].width || 1;
const height = inputs[0].height || 1;
const channels = inputs.slice(0, 4).map((input) => fitCanvas(input, width, height));
const output = makeCanvas(width, height);
const octx = output.getContext("2d", { willReadFrequently: true });
const images = channels.map((canvas) => canvas.getContext("2d", { willReadFrequently: true }).getImageData(0, 0, width, height).data);
const out = octx.createImageData(width, height);
const data = out.data;
const normalizedMode = String(mode || "rgba").toLowerCase();
for (let i = 0; i < data.length; i += 4) {
const c1 = images[0]?.[i] ?? 0;
const c2 = images[1]?.[i] ?? 0;
const c3 = images[2]?.[i] ?? 0;
const c4 = images[3]?.[i] ?? 255;
if (normalizedMode === "rgba" || normalizedMode === "rgb") {
data[i] = c1;
data[i + 1] = c2;
data[i + 2] = c3;
data[i + 3] = images[3] ? c4 : 255;
} else {
data[i] = c1;
data[i + 1] = c2;
data[i + 2] = c3;
data[i + 3] = images[3] ? c4 : 255;
}
}
octx.putImageData(out, 0, 0);
return output;
}
function applySpherize(ctx, width, height, mode, strength, invert) {
const src = ctx.getImageData(0, 0, width, height);
const dst = ctx.createImageData(width, height);
const sd = src.data;
const dd = dst.data;
const s = Math.max(0, strength);
const m = String(mode || "spherize").toLowerCase();
for (let py = 0; py < height; py++) {
for (let px = 0; px < width; px++) {
const nx = px / (width - 1) * 2 - 1;
const ny = py / (height - 1) * 2 - 1;
const dstIdx = (py * width + px) * 4;
const isDiskMode = m !== "latlong" && m !== "unlatlong";
if (isDiskMode && nx * nx + ny * ny > 1) {
dd[dstIdx] = 0;
dd[dstIdx + 1] = 0;
dd[dstIdx + 2] = 0;
dd[dstIdx + 3] = 0;
continue;
}
let srcNx;
let srcNy;
if (!invert) {
[srcNx, srcNy] = _spherizeMapFwd(nx, ny, m, s);
} else {
[srcNx, srcNy] = _spherizeMapInv(nx, ny, m, s);
}
const sx = (srcNx + 1) * 0.5 * (width - 1);
const sy = (srcNy + 1) * 0.5 * (height - 1);
const x0 = Math.floor(sx);
const y0 = Math.floor(sy);
const x1 = x0 + 1;
const y1 = y0 + 1;
const fx = sx - x0;
const fy = sy - y0;
const cx0 = Math.max(0, Math.min(width - 1, x0));
const cx1 = Math.max(0, Math.min(width - 1, x1));
const cy0 = Math.max(0, Math.min(height - 1, y0));
const cy1 = Math.max(0, Math.min(height - 1, y1));
for (let c = 0; c < 4; c++) {
const v00 = sd[(cy0 * width + cx0) * 4 + c];
const v10 = sd[(cy0 * width + cx1) * 4 + c];
const v01 = sd[(cy1 * width + cx0) * 4 + c];
const v11 = sd[(cy1 * width + cx1) * 4 + c];
dd[dstIdx + c] = Math.round(
v00 * (1 - fx) * (1 - fy) + v10 * fx * (1 - fy) + v01 * (1 - fx) * fy + v11 * fx * fy
);
}
}
}
ctx.putImageData(dst, 0, 0);
}
function _spherizeMapFwd(nx, ny, mode, s) {
const r = Math.sqrt(nx * nx + ny * ny);
if (r < 1e-7) return [0, 0];
if (mode === "spherize") {
const t = r * Math.PI * 0.5;
const scale = Math.sin(t) / r * s + (1 - s);
return [nx * scale, ny * scale];
}
if (mode === "fisheye") {
if (Math.abs(s) <= 1e-6) return [nx, ny];
const angle = r * Math.PI * 0.5 * s;
const rSrc = Math.sin(angle);
return [nx / r * rSrc, ny / r * rSrc];
}
if (mode === "defisheye") {
if (Math.abs(s) <= 1e-6) return [nx, ny];
const angle = r * Math.PI * 0.5 * s;
const rDst = Math.tan(Math.min(angle, 1.5)) / (Math.PI * 0.5 * s + 1e-8);
const scale = rDst / (r + 1e-8);
return [nx * scale, ny * scale];
}
if (mode === "latlong") {
const fovTan = Math.max(s * 2, 1e-6);
const atanFov = Math.atan(fovTan);
const lon = Math.atan(nx * fovTan);
const lat = Math.atan(ny * fovTan);
return [lon / atanFov, lat / atanFov];
}
if (mode === "unlatlong") {
const fovTan = Math.max(s * 2, 1e-6);
const atanFov = Math.atan(fovTan);
const clamp = atanFov * 0.9999;
const srcX = Math.tan(Math.max(-clamp, Math.min(clamp, nx * atanFov))) / fovTan;
const srcY = Math.tan(Math.max(-clamp, Math.min(clamp, ny * atanFov))) / fovTan;
return [srcX, srcY];
}
return [nx, ny];
}
function _spherizeMapInv(nx, ny, mode, s) {
const r = Math.sqrt(nx * nx + ny * ny);
if (r < 1e-7) return [0, 0];
if (mode === "spherize") {
const rClamped = Math.min(r, 1);
const scale = Math.asin(rClamped) / (Math.PI * 0.5 * r + 1e-8) * s + (1 - s);
return [nx * scale, ny * scale];
}
if (mode === "fisheye") {
return _spherizeMapFwd(nx, ny, "defisheye", s);
}
if (mode === "defisheye") {
return _spherizeMapFwd(nx, ny, "fisheye", s);
}
if (mode === "latlong") {
return _spherizeMapFwd(nx, ny, "unlatlong", s);
}
if (mode === "unlatlong") {
return _spherizeMapFwd(nx, ny, "latlong", s);
}
return [nx, ny];
}
const ops = {
colorAjust(ctx, W, node, inputs = [], frameIndex = 0) {
const source = inputs[0] ?? ctx.canvas;
const rawMask = inputs[1] ?? null;
return renderMaskedEffectPreview(
node,
source,
rawMask,
(input) => applyEffectToCanvas(input, (effectCtx, width, height) => {
applyColorCorrectReference(
effectCtx,
width,
height,
numAny(node, ["temperature"], 0, frameIndex),
numAny(node, ["tint"], 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, ["vibrance"], 0, frameIndex),
numAny(node, ["gamma"], 1, frameIndex),
numAny(node, ["shadows_hue"], 0, frameIndex),
numAny(node, ["shadows_amount"], 0, frameIndex),
numAny(node, ["midtones_hue"], 0, frameIndex),
numAny(node, ["midtones_amount"], 0, frameIndex),
numAny(node, ["highlights_hue"], 0, frameIndex),
numAny(node, ["highlights_amount"], 0, frameIndex),
{
shadowsTemperature: numAny(node, ["shadows_temperature"], 0, frameIndex),
shadowsTint: numAny(node, ["shadows_tint"], 0, frameIndex),
shadowsContrast: numAny(node, ["shadows_contrast"], 0, frameIndex),
shadowsSaturation: numAny(node, ["shadows_saturation"], 0, frameIndex),
shadowsVibrance: numAny(node, ["shadows_vibrance"], 0, frameIndex),
shadowsGamma: numAny(node, ["shadows_gamma"], 1, frameIndex),
shadowsBrightness: numAny(node, ["shadows_brightness"], 0, frameIndex),
midtonesTemperature: numAny(node, ["midtones_temperature"], 0, frameIndex),
midtonesTint: numAny(node, ["midtones_tint"], 0, frameIndex),
midtonesContrast: numAny(node, ["midtones_contrast"], 0, frameIndex),
midtonesSaturation: numAny(node, ["midtones_saturation"], 0, frameIndex),
midtonesVibrance: numAny(node, ["midtones_vibrance"], 0, frameIndex),
midtonesGamma: numAny(node, ["midtones_gamma"], 1, frameIndex),
midtonesBrightness: numAny(node, ["midtones_brightness"], 0, frameIndex),
highlightsTemperature: numAny(node, ["highlights_temperature"], 0, frameIndex),
highlightsTint: numAny(node, ["highlights_tint"], 0, frameIndex),
highlightsContrast: numAny(node, ["highlights_contrast"], 0, frameIndex),
highlightsSaturation: numAny(node, ["highlights_saturation"], 0, frameIndex),
highlightsVibrance: numAny(node, ["highlights_vibrance"], 0, frameIndex),
highlightsGamma: numAny(node, ["highlights_gamma"], 1, frameIndex),
highlightsBrightness: numAny(node, ["highlights_brightness"], 0, frameIndex)
}
);
}),
{ frameIndex }
);
},
colorCorrect(ctx, W, node, inputs = [], frameIndex = 0) {
return ops.colorAjust(ctx, W, node, inputs, frameIndex);
},
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, frameIndex);
const blurType = strAny(node, ["blur_type"], "gaussian", frameIndex);
const sigmaFallback = radius > 0 ? Math.max(0.1, radius / 3) : 0;
const sigma = numAny(node, ["sigma"], sigmaFallback, frameIndex);
let cssSigma;
if (blurType === "box") {
cssSigma = radius > 0 ? Math.max(0.1, radius / Math.sqrt(3)) : 0;
} else if (blurType === "defocus") {
cssSigma = radius > 0 ? Math.max(0.1, radius * 0.6) : 0;
} else {
cssSigma = sigma > 0 ? sigma : sigmaFallback;
}
const blurFn = (input) => applyEffectToCanvas(input, (effectCtx, width, height) => {
applyBlur(effectCtx, width, height, radius, cssSigma);
});
if (!rawMask) return blurFn(source);
const mask = resolvePreviewMaskCanvas(node, source, rawMask, frameIndex);
if (!mask) return blurFn(source);
const sw = source.width || 1;
const sh = source.height || 1;
const blurred = blurFn(source);
const fittedMask = buildMaskAlphaCanvas(mask, sw, sh);
const output = makeCanvas(sw, sh);
const outCtx = output.getContext("2d", { willReadFrequently: true });
const blurredMasked = acquireCanvas(sw, sh);
try {
const bmCtx = blurredMasked.getContext("2d", { willReadFrequently: true });
bmCtx.clearRect(0, 0, sw, sh);
bmCtx.globalCompositeOperation = "source-over";
bmCtx.drawImage(blurred, 0, 0);
bmCtx.globalCompositeOperation = "destination-in";
bmCtx.drawImage(fittedMask, 0, 0);
bmCtx.globalCompositeOperation = "source-over";
outCtx.clearRect(0, 0, sw, sh);
outCtx.drawImage(source, 0, 0);
outCtx.globalCompositeOperation = "destination-out";
outCtx.drawImage(fittedMask, 0, 0);
outCtx.globalCompositeOperation = "source-over";
outCtx.drawImage(blurredMasked, 0, 0);
} finally {
releaseCanvas(blurredMasked);
}
return output;
},
channel(ctx, W, node, outputSlot, inputs = [], frameIndex = 0) {
const source = inputs[0] ?? ctx.canvas;
const splitMode = strAny(node, ["mode"], "RGBA", frameIndex);
const hasSingleChannelWidget = !!wAny(node, ["channel"]);
if (!hasSingleChannelWidget && outputSlot != null) {
return extractSplitChannelCanvas(source, outputSlot, splitMode);
}
const extracted = applyEffectToCanvas(source, (effectCtx, width, height) => {
applyChannel(effectCtx, width, height, strAny(node, ["channel"], "Red", frameIndex));
});
const ectx = extracted.getContext("2d", { willReadFrequently: true });
const img = ectx.getImageData(0, 0, extracted.width, extracted.height);
const data = img.data;
for (let i = 3; i < data.length; i += 4) data[i] = 255;
ectx.putImageData(img, 0, 0);
return extracted;
},
crop(ctx, W, node, inputs = [], frameIndex = 0) {
const source = inputs[0] ?? ctx.canvas;
const rawMask = inputs[1] ?? null;
return renderMaskedEffectPreview(
node,
source,
rawMask,
(input) => applyEffectToCanvas(input, (effectCtx, width, height) => applyCrop(
effectCtx,
node,
width,
height,
str(node, "aspect_ratio", "custom", frameIndex),
num(node, "width", width, frameIndex),
num(node, "height", height, frameIndex)
)),
{
frameIndex,
premultBeforeProcess: true,
compositeWithBase: false
}
);
},
cropStitch(ctx, W, node, inputs = [], frameIndex = 0) {
if (inputs.length < 2) return inputs[0] ?? ctx.canvas;
const rendered = renderCropStitchCanvases(node, inputs, frameIndex);
return composeCropStitchPreview(rendered.image, rendered.crop, rendered.mask, rendered.bbox);
},
padOut(ctx, W, node, inputs = [], frameIndex = 0) {
const source = inputs[0] ?? ctx.canvas;
return renderPadOutCanvases(node, source, frameIndex).image;
},
cornerPin(ctx, W, node, inputs = [], frameIndex = 0) {
const source = inputs[0] ?? ctx.canvas;
return renderCornerPinCanvases(node, source, frameIndex).image;
},
cropGeneric(ctx, W, node, inputs = [], inputInfos = []) {
const source = inputs[0] ?? ctx.canvas;
const sourceWidth = source.width || 1;
const sourceHeight = source.height || 1;
let targetWidth = Math.max(1, Math.round(numAny(node, ["width", "target_width", "base_resolution"], sourceWidth)));
let targetHeight = Math.max(1, Math.round(numAny(node, ["height", "target_height", "base_resolution"], sourceHeight)));
const maskInput = inputs[1] ?? null;
if (maskInput) {
if (!wAny(node, ["width", "target_width", "height", "target_height"]) && !!w(node, "base_resolution")) {
const fittedMask = fitCanvas(maskInput, sourceWidth, sourceHeight);
const bounds = computeMaskBounds(fittedMask);
if (bounds) {
const aspect = bounds.width / Math.max(1, bounds.height);
const baseResolution = Math.max(1, Math.round(numAny(node, ["base_resolution"], Math.max(bounds.width, bounds.height))));
if (aspect >= 1) {
targetWidth = baseResolution;
targetHeight = Math.max(1, Math.round(baseResolution / aspect));
} else {
targetHeight = baseResolution;
targetWidth = Math.max(1, Math.round(baseResolution * aspect));
}
}
}
return extractMaskDrivenCrop(source, maskInput, numAny(node, ["padding"], 0), targetWidth, targetHeight);
}
const cropRegion = w(node, "crop_region")?.value;
const bboxNode = inputInfos[1]?.upstreamNode ?? null;
const x = Math.max(0, Math.round(cropRegion?.x ?? numAny(bboxNode ?? node, ["x", "x_offset"], 0)));
const y = Math.max(0, Math.round(cropRegion?.y ?? numAny(bboxNode ?? node, ["y", "y_offset"], 0)));
const width = Math.max(1, Math.round(cropRegion?.width ?? numAny(bboxNode ?? node, ["width", "crop_w"], sourceWidth)));
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 = [], frameIndex = 0) {
const source = inputs[0] ?? ctx.canvas;
const rawMask = inputs[1] ?? null;
const transformImage = (input) => {
let working = input;
const flip = strAny(node, ["flip", "mirror"], "none", frameIndex).toLowerCase();
const flipMethod = strAny(node, ["flip_method"], "", frameIndex);
const horizontal = flip === "horizontal" || flipMethod.startsWith("y");
const vertical = flip === "vertical" || flipMethod.startsWith("x");
working = flipCanvas(working, horizontal, vertical);
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, 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", { willReadFrequently: true });
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, 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);
const fillMode = strAny(node, ["fill_mode", "edge_mode"], "transparent", frameIndex);
const fillColor = strAny(node, ["fill_color", "background_color", "color"], "#000000", frameIndex);
return applyEffectToCanvas(working, (effectCtx, width, height) => {
return applyTransform(effectCtx, width, height, tx, ty, rot, scale, filter, expand, fillMode, fillColor);
});
};
return renderMaskedEffectPreview(
node,
source,
rawMask,
transformImage,
{
frameIndex,
premultBeforeProcess: true,
compositeWithBase: false
}
);
},
cameraShake(ctx, W, node, inputs = [], frameIndex = 0) {
const source = inputs[0] ?? ctx.canvas;
const transformImage = (input) => {
const translate = Math.max(0, numAny(node, ["translate_px"], 12, frameIndex));
const rotate = Math.max(0, numAny(node, ["rotate_deg"], 1.5, frameIndex));
const zoom = Math.max(0, numAny(node, ["zoom"], 0.03, frameIndex));
const smoothing = numAny(node, ["smoothing"], 0.65, frameIndex);
const frequency = Math.max(0.01, numAny(node, ["shake_frequency", "frequency"], 1, frameIndex));
const seed = Math.max(0, Math.round(numAny(node, ["seed"], 12345, frameIndex)));
const tx = smoothShakeValue(seed + 11, frameIndex, 1, translate, smoothing, frequency);
const ty = smoothShakeValue(seed + 23, frameIndex, 2, translate, smoothing, frequency);
const rot = smoothShakeValue(seed + 37, frameIndex, 3, rotate, smoothing, frequency);
const scale = Math.max(0.01, 1 + smoothShakeValue(seed + 53, frameIndex, 4, zoom, smoothing, frequency));
return applyEffectToCanvas(input, (effectCtx, width, height) => {
return applyTransform(
effectCtx,
width,
height,
tx,
ty,
rot,
scale,
strAny(node, ["filter"], "bilinear", frameIndex),
false,
strAny(node, ["fill_mode"], "mirror", frameIndex),
strAny(node, ["fill_color"], "#000000", frameIndex)
);
});
};
return renderMaskedEffectPreview(node, source, inputs[1] ?? null, transformImage, {
frameIndex,
premultBeforeProcess: true,
compositeWithBase: false
});
},
levels(ctx, W, node, _opts) {
const { width, height } = getCanvasDimensions(ctx);
applyLevels(
ctx,
width,
height,
numAny(node, ["in_min", "min"], 0),
numAny(node, ["in_max", "max"], 1),
numAny(node, ["gamma", "mid"], 1),
numAny(node, ["out_min"], 0),
numAny(node, ["out_max"], 1)
);
},
hueSat(ctx, W, node, _opts) {
const { width, height } = getCanvasDimensions(ctx);
applyHueSat(
ctx,
width,
height,
numAny(node, ["hue_deg", "hue"], 0),
numAny(node, ["saturation", "sat"], 1),
numAny(node, ["value", "val"], 1)
);
},
invert(ctx, W, node, inputs = [], frameIndex = 0) {
const source = inputs[0] ?? ctx.canvas;
return applyEffectToCanvas(source, (effectCtx, width, height) => {
applyInvert(effectCtx, width, height, boolAny(node, ["invert_alpha"], false, frameIndex));
});
},
clamp(ctx, W, node, inputs = [], frameIndex = 0) {
const source = inputs[0] ?? ctx.canvas;
return applyEffectToCanvas(source, (effectCtx, width, height) => {
applyClamp(effectCtx, width, height, numAny(node, ["min_v", "min"], 0, frameIndex), numAny(node, ["max_v", "max"], 1, frameIndex));
});
},
grain(ctx, W, node, inputs = [], frameIndex = 0) {
const source = inputs[0] ?? ctx.canvas;
return renderGrainCanvas(node, source, inputs[1] ?? null, frameIndex);
},
text(ctx, W, node, inputs = [], frameIndex = 0) {
const source = inputs[0] ?? ctx.canvas;
return renderTextCanvas(node, source, inputs[1] ?? null, frameIndex);
},
keyer(ctx, W, node, inputs = [], frameIndex = 0) {
const source = inputs[0] ?? ctx.canvas;
return renderKeyerCanvases(node, source, inputs[1] ?? null, frameIndex).image;
},
sharpen(ctx, W, node) {
const { width, height } = getCanvasDimensions(ctx);
applyUnsharp(ctx, width, height, numAny(node, ["amount", "strength", "factor"], 1));
},
edgeDetect(ctx, W, node) {
const { width, height } = getCanvasDimensions(ctx);
applyEdgeDetect(ctx, width, height, numAny(node, ["strength"], 1));
},
glow(ctx, W, node) {
const { width, height } = getCanvasDimensions(ctx);
applyGlow(ctx, width, height, numAny(node, ["threshold"], 0.8), numAny(node, ["intensity"], 0.75), Math.round(numAny(node, ["blur_px", "blur", "radius"], 6)));
},
cropReformat(ctx, W, node) {
const { width, height } = getCanvasDimensions(ctx);
applyCropReformat(
ctx,
width,
height,
numAny(node, ["x"], 0),
numAny(node, ["y"], 0),
numAny(node, ["crop_w", "width"], width),
numAny(node, ["crop_h", "height"], height),
numAny(node, ["padding"], 0),
numAny(node, ["out_w", "target_width"], 0),
numAny(node, ["out_h", "target_height"], 0),
strAny(node, ["mode", "method"], "fit")
);
},
lumaKey(ctx, W, node) {
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, 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", frameIndex);
const foregroundFit = strAny(node, ["foreground_fit", "fit_mode"], "stretch", frameIndex);
const blendSpace = strAny(node, ["blend_space", "color_space"], "linear", 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, foregroundFit, blendSpace);
});
const effectMask = resolvePreviewMaskCanvas(node, base, inputs[2] ?? null, frameIndex);
return effectMask ? compositeProcessedWithMask(base, merged, effectMask) : merged;
},
resize(ctx, W, node) {
const { width, height } = getCanvasDimensions(ctx);
const resolved = resolveResizeDimensions(node, width, height);
return resizeWithMode(ctx.canvas, resolved.width, resolved.height, resolved.filter, resolved.mode, resolved.fillColor, resolved.cropPosition);
},
pad(ctx, W, node, inputs = []) {
const source = inputs[0] ?? ctx.canvas;
const top = Math.max(0, Math.round(numAny(node, ["top"], 0)));
const bottom = Math.max(0, Math.round(numAny(node, ["bottom"], 0)));
const left = Math.max(0, Math.round(numAny(node, ["left"], 0)));
const right = Math.max(0, Math.round(numAny(node, ["right"], 0)));
const output = makeCanvas((source.width || 1) + left + right, (source.height || 1) + top + bottom);
const octx = output.getContext("2d", { willReadFrequently: true });
octx.fillStyle = parseHexColor(strAny(node, ["color", "background_color", "pad_color", "padding_color"], "#808080"));
octx.fillRect(0, 0, output.width, output.height);
octx.drawImage(source, left, top, source.width || 1, source.height || 1);
return output;
},
flipRotate(ctx, W, node) {
const flipMethod = strAny(node, ["flip_method"], "");
const horizontal = flipMethod.startsWith("y");
const vertical = flipMethod.startsWith("x");
let working = flipCanvas(ctx.canvas, horizontal, vertical);
const rotation = strAny(node, ["rotation"], "");
if (rotation.startsWith("90")) working = rotateDiscrete(working, 1);
else if (rotation.startsWith("180")) working = rotateDiscrete(working, 2);
else if (rotation.startsWith("270")) working = rotateDiscrete(working, 3);
return working;
},
desaturate(ctx, W, node) {
const { width, height } = getCanvasDimensions(ctx);
const factor = numAny(node, ["factor", "amount"], 1);
applyDesaturate(ctx, width, height, factor);
},
composite(ctx, W, node, inputs) {
const base = inputs[0] ?? ctx.canvas;
const topInput = inputs[1] ?? null;
if (!topInput) return base;
const rawMask = inputs[2] ?? null;
const maskInput = rawMask && boolAny(node, ["invert_mask"], false) ? invertMaskCanvas(rawMask) : rawMask;
const top = premultLayerWithMask(topInput, maskInput);
const x = Math.round(numAny(node, ["x"], 0) + numAny(node, ["offset_x"], 0));
const y = Math.round(numAny(node, ["y"], 0) + numAny(node, ["offset_y"], 0));
const mode = strAny(node, ["mode", "blend_mode"], "over");
const rawOpacity = w(node, "opacity") ? num(node, "opacity", 100) : numAny(node, ["mix", "factor", "blend_factor", "start_level", "end_level"], 1);
const opacity = rawOpacity > 1 ? rawOpacity / 100 : rawOpacity;
return compositeAt(base, top, mode, opacity, x, y, top.width || 1, top.height || 1);
},
stitch(ctx, W, node, inputs) {
const first = inputs[0] ?? ctx.canvas;
const second = inputs[1] ?? null;
if (!second) return first;
return stitchCanvases(
first,
second,
strAny(node, ["direction"], "right"),
numAny(node, ["spacing_width"], 0),
strAny(node, ["spacing_color"], "black"),
boolAny(node, ["match_image_size"], true)
);
},
channelSplit(ctx, W, node, outputSlot) {
return extractSplitChannelCanvas(ctx.canvas, outputSlot, strAny(node, ["mode"], "RGBA"));
},
channelMerge(ctx, W, node, inputs) {
return mergeChannelInputs(inputs, strAny(node, ["mode"], "RGBA")) ?? (inputs[0] ?? ctx.canvas);
},
channelApply(ctx, W, node, inputs) {
const base = fitCanvas(inputs[0] ?? ctx.canvas, (inputs[0] ?? ctx.canvas).width || 1, (inputs[0] ?? ctx.canvas).height || 1);
const mask = inputs[1] ? fitCanvas(inputs[1], base.width, base.height) : null;
if (!mask) return base;
const bctx = base.getContext("2d", { willReadFrequently: true });
const image = bctx.getImageData(0, 0, base.width, base.height);
const data = image.data;
const matte = mask.getContext("2d", { willReadFrequently: true }).getImageData(0, 0, base.width, base.height).data;
const channel = strAny(node, ["channel"], "A").toLowerCase();
const channelIndex = channel === "g" || channel === "green" ? 1 : channel === "b" || channel === "blue" ? 2 : channel === "a" || channel === "alpha" ? 3 : 0;
for (let i = 0; i < data.length; i += 4) {
const value = Math.round(clamp01(matte[i] / 255 * (matte[i + 3] / 255)) * 255);
data[i + channelIndex] = value;
}
bctx.putImageData(image, 0, 0);
return base;
},
comp(ctx, W, node, inputs) {
return renderCompPreview(
node,
resolveCompPreviewInputs(node, inputs)
).canvas;
},
distort(ctx, W, node, inputs, frameIndex = 0) {
return renderDistortCanvas(node, inputs, frameIndex).image;
},
spherize(ctx, W, node, inputs = [], frameIndex = 0) {
let source = inputs[0] ?? ctx.canvas;
const sizeMode = strAny(node, ["size_mode"], "from_input", frameIndex).toLowerCase().trim();
if (sizeMode === "custom") {
const tw = Math.max(64, Math.round(numAny(node, ["width"], 512, frameIndex)));
const th = Math.max(64, Math.round(numAny(node, ["height"], 512, frameIndex)));
if (tw !== source.width || th !== source.height) {
const resized = makeCanvas(tw, th);
resized.getContext("2d", { willReadFrequently: true }).drawImage(source, 0, 0, tw, th);
source = resized;
}
} else {
const ww = w(node, "width");
const hw = w(node, "height");
setWidgetValue(ww, Math.max(64, source.width));
setWidgetValue(hw, Math.max(64, source.height));
}
return applyEffectToCanvas(source, (effectCtx, width, height) => {
applySpherize(
effectCtx,
width,
height,
strAny(node, ["mode"], "spherize", frameIndex),
numAny(node, ["strength"], 1, frameIndex),
boolAny(node, ["invert"], false, frameIndex)
);
});
},
constant(ctx, W, node) {
return renderConstantCanvas(node, false);
},
ramp(ctx, W, node) {
return renderRampCanvas(node, false);
},
noise(ctx, W, node, frameIndex = 0) {
return renderNoiseCanvas(node, false, frameIndex, W);
},
async draw(ctx, W, node, inputs) {
return await renderDrawPreview(node, inputs[0] ?? null);
},
async drawMask(ctx, W, node, inputs) {
const base = inputs[0] ?? null;
const width = base?.width || Math.max(1, Math.round(numAny(node, ["width"], 1024)));
const height = base?.height || Math.max(1, Math.round(numAny(node, ["height"], 1024)));
const overlay = await resolveDrawOverlayCanvas(node, width, height);
const ow = overlay.width || 1;
const oh = overlay.height || 1;
const matte = makeCanvas(ow, oh);
const mctx = matte.getContext("2d", { willReadFrequently: true });
mctx.drawImage(overlay, 0, 0);
const img = mctx.getImageData(0, 0, ow, oh);
const data = img.data;
for (let i = 0; i < data.length; i += 4) {
const a = data[i + 3];
data[i] = 255;
data[i + 1] = 255;
data[i + 2] = 255;
data[i + 3] = a;
}
mctx.putImageData(img, 0, 0);
const mask = markPreparedMaskCanvas(matte);
return boolAny(node, ["invert_mask"], false) ? invertMaskCanvas(mask) : mask;
},
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, frameIndex);
if (cls === "ImageOpsMaskConvert") {
return boolAny(node, ["reverse"], false, frameIndex) ? imageToMaskPreviewCanvas(source, node, frameIndex) : buildMaskAlphaCanvas(source, source.width || 1, source.height || 1);
}
if (cls === "ImageOpsNoise") {
return renderNoiseCanvas(node, true, frameIndex, W);
}
if (cls === "ImageOpsConstant") {
return renderConstantCanvas(node, true);
}
if (cls === "ImageOpsRamp") {
return renderRampCanvas(node, true);
}
if (cls === "ImageOpsDistort") {
return renderDistortCanvas(node, inputs, frameIndex).mask;
}
if (cls === "ImageOpsBlur") {
return resolvedMask ?? alphaMaskCanvas(source);
}
if (cls === "ImageOpsTransform") {
return ops.transform(ctx, W, node, [resolvedMask ?? alphaMaskCanvas(source)], frameIndex);
}
if (cls === "ImageOpsCrop") {
return ops.crop(ctx, W, node, [resolvedMask ?? alphaMaskCanvas(source)], frameIndex);
}
if (cls === "ImageOpsCropStitch") {
return renderCropStitchCanvases(node, inputs, frameIndex).mask;
}
if (cls === "ImageOpsPadOut") {
return renderPadOutCanvases(node, source, frameIndex).mask;
}
if (cls === "ImageOpsCornerPin") {
return renderCornerPinCanvases(node, source, frameIndex).mask;
}
if (cls === "ImageOpsChannel") {
const extracted = applyEffectToCanvas(source, (effectCtx, width, height) => {
applyChannel(effectCtx, width, height, strAny(node, ["channel"], "Red", frameIndex));
});
return buildMaskAlphaCanvas(extracted, extracted.width || 1, extracted.height || 1);
}
if (cls === "ImageOpsClamp") {
if (!resolvedMask) return alphaMaskCanvas(source);
const lo = numAny(node, ["min_v", "min"], 0, frameIndex);
const hi = numAny(node, ["max_v", "max"], 1, frameIndex);
const mn = Math.round(clamp01(Math.min(lo, hi)) * 255);
const mx = Math.round(clamp01(Math.max(lo, hi)) * 255);
const clampMaskOut = makeCanvas(resolvedMask.width || 1, resolvedMask.height || 1);
const clampMaskCtx = clampMaskOut.getContext("2d", { willReadFrequently: true });
clampMaskCtx.drawImage(resolvedMask, 0, 0);
const clampImg = clampMaskCtx.getImageData(0, 0, clampMaskOut.width, clampMaskOut.height);
const clampData = clampImg.data;
for (let ci = 0; ci < clampData.length; ci += 4) {
clampData[ci] = 255;
clampData[ci + 1] = 255;
clampData[ci + 2] = 255;
clampData[ci + 3] = Math.max(mn, Math.min(mx, clampData[ci + 3]));
}
clampMaskCtx.putImageData(clampImg, 0, 0);
return markPreparedMaskCanvas(clampMaskOut);
}
if (cls === "ImageOpsKeyer") {
return renderKeyerCanvases(node, source, rawMask, frameIndex).mask;
}
if (cls === "ImageOpsInvert") {
const mask = resolvedMask ?? alphaMaskCanvas(source);
return mask;
}
if (cls === "ImageOpsSpherize") {
return renderSpherizeMaskCanvas(node, source, rawMask, frameIndex);
}
if (cls === "ImageOpsMerge") {
const mergeMaskInput = inputs[2] ?? null;
if (mergeMaskInput) {
const mergeResolvedMask = resolvePreviewMaskCanvas(node, source, mergeMaskInput, frameIndex);
if (mergeResolvedMask) return mergeResolvedMask;
}
const merged = ops.merge(ctx, W, node, inputs, void 0, frameIndex);
return alphaMaskCanvas(merged);
}
if (cls === "ImageOpsComp") {
const mask = alphaMaskCanvas(ops.comp(ctx, W, node, inputs));
return boolAny(node, ["invert_mask"], false, frameIndex) ? invertMaskCanvas(mask) : mask;
}
if (cls === "ImageOpsColorAjust") {
return resolvedMask ?? alphaMaskCanvas(source);
}
return resolvedMask ?? alphaMaskCanvas(source);
}
};
export {
ops,
renderCompPreview,
renderDrawNodePreview
};