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 };