import type { Rect } from "@core/geometry"; import { assertCanvasRasterSize, assertProcessingRasterSize } from "./rasterLimits"; export type MaskFill = "white" | "black" | "clear"; export type MaskRasterOperation = | { type: "invert" } | { type: "fill"; fill: MaskFill } | { type: "feather"; radius: number } | { type: "expand"; radius: number } | { type: "contract"; radius: number } | { type: "blur"; radius: number } | { type: "despeckle"; strength: number }; export type MaskAnalysis = { width: number; height: number; revealedPixels: number; hiddenPixels: number; coverage: number; hiddenCoverage: number; bounds?: Rect; hiddenBounds?: Rect; thumbnail: string; }; export type NormalizedMaskOptions = { polarity: "hidden" | "revealed"; expand?: number; feather?: number; blur?: number; despeckle?: number; limit?: Rect; }; export async function createSolidMaskSource(width: number, height: number, fill: MaskFill): Promise { assertProcessingRasterSize(width, height, "Mask"); const canvas = createCanvas(width, height); const context = require2dContext(canvas); context.clearRect(0, 0, canvas.width, canvas.height); if (fill === "white") { context.fillStyle = "#ffffff"; context.fillRect(0, 0, canvas.width, canvas.height); } else if (fill === "black") { context.fillStyle = "#000000"; context.fillRect(0, 0, canvas.width, canvas.height); } return canvas.toDataURL("image/png"); } export async function applyMaskRasterOperation(source: string, width: number, height: number, operation: MaskRasterOperation): Promise { assertProcessingRasterSize(width, height, "Mask"); if (operation.type === "fill") return createSolidMaskSource(width, height, operation.fill); const mask = await loadMaskValues(source, width, height); const next = applyMaskValueOperation(mask.values, mask.width, mask.height, operation); return maskValuesToDataUrl(next, mask.width, mask.height); } export async function applyPolygonMask(source: string, width: number, height: number, points: readonly { x: number; y: number }[], mode: "replace" | "add" | "subtract"): Promise { assertProcessingRasterSize(width, height, "Mask"); if (points.length < 3) return source; const canvas = await loadImageCanvas(source, width, height); const context = require2dContext(canvas); if (mode === "replace") context.clearRect(0, 0, canvas.width, canvas.height); context.save(); context.globalCompositeOperation = mode === "subtract" ? "destination-out" : "source-over"; context.fillStyle = "#ffffff"; context.beginPath(); context.moveTo(points[0]?.x ?? 0, points[0]?.y ?? 0); for (let index = 1; index < points.length; index += 1) context.lineTo(points[index]?.x ?? 0, points[index]?.y ?? 0); context.closePath(); context.fill(); context.restore(); return canvas.toDataURL("image/png"); } export async function mergeMaskSources(existingSource: string, selectedSource: string, width: number, height: number, mode: "replace" | "add" | "subtract"): Promise { assertProcessingRasterSize(width, height, "Mask"); const [existing, selected] = await Promise.all([loadMaskValues(existingSource, width, height), loadMaskValues(selectedSource, width, height)]); const values = new Uint8ClampedArray(width * height); for (let pixel = 0; pixel < values.length; pixel += 1) { const current = existing.values[pixel] ?? 0; const choice = selected.values[pixel] ?? 0; values[pixel] = mode === "add" ? Math.max(current, choice) : mode === "subtract" ? Math.min(current, 255 - choice) : choice; } return maskValuesToDataUrl(values, width, height); } export async function analyzeMaskSource(source: string, width: number, height: number): Promise { assertProcessingRasterSize(width, height, "Mask"); const mask = await loadMaskValues(source, width, height); const revealed = analyzeValues(mask.values, mask.width, mask.height, false); const hiddenValues = invertMaskValues(mask.values); const hidden = analyzeValues(hiddenValues, mask.width, mask.height, false); return { width: mask.width, height: mask.height, revealedPixels: revealed.pixels, hiddenPixels: hidden.pixels, coverage: revealed.coverage, hiddenCoverage: hidden.coverage, bounds: revealed.bounds, hiddenBounds: hidden.bounds, thumbnail: maskValuesToDataUrl(mask.values, mask.width, mask.height, 72, 48), }; } export async function createNormalizedMaskSource(source: string, width: number, height: number, options: NormalizedMaskOptions): Promise<{ source: string; values: Uint8ClampedArray; bounds?: Rect }> { assertProcessingRasterSize(width, height, "Mask"); const mask = await loadMaskValues(source, width, height); let values = options.polarity === "hidden" ? invertMaskValues(mask.values) : new Uint8ClampedArray(mask.values); if (options.limit) values = limitMaskValues(values, mask.width, mask.height, options.limit); const despeckle = Math.round(clampNumber(options.despeckle ?? 0, 0, 64)); const expand = Math.round(clampNumber(options.expand ?? 0, -256, 256)); const feather = Math.round(clampNumber(options.feather ?? 0, 0, 256)); const blur = Math.round(clampNumber(options.blur ?? 0, 0, 256)); if (despeckle > 0) values = despeckleMaskValues(values, mask.width, mask.height, despeckle); if (expand > 0) values = dilateMaskValues(values, mask.width, mask.height, expand); if (expand < 0) values = erodeMaskValues(values, mask.width, mask.height, -expand); if (feather > 0) values = blurMaskValues(values, mask.width, mask.height, feather); if (blur > 0) values = blurMaskValues(values, mask.width, mask.height, blur); const analysis = analyzeValues(values, mask.width, mask.height, false); return { source: maskValuesToDataUrl(values, mask.width, mask.height), values, bounds: analysis.bounds }; } export function limitMaskValues(values: Uint8ClampedArray, width: number, height: number, limit: Rect): Uint8ClampedArray { const next = new Uint8ClampedArray(values.length); const x1 = Math.max(0, Math.floor(limit.x)); const y1 = Math.max(0, Math.floor(limit.y)); const x2 = Math.min(width, Math.ceil(limit.x + limit.w)); const y2 = Math.min(height, Math.ceil(limit.y + limit.h)); for (let y = y1; y < y2; y += 1) for (let x = x1; x < x2; x += 1) next[y * width + x] = values[y * width + x] ?? 0; return next; } export async function loadImageCanvas(source: string, width?: number, height?: number): Promise { const image = await loadImage(source); assertCanvasRasterSize(width ?? image.naturalWidth, height ?? image.naturalHeight); const canvas = createCanvas(width ?? image.naturalWidth, height ?? image.naturalHeight); const context = require2dContext(canvas); context.clearRect(0, 0, canvas.width, canvas.height); context.drawImage(image, 0, 0, canvas.width, canvas.height); return canvas; } export async function imageSourceToPngDataUrl(source: string): Promise { if (source.startsWith("data:image/png;base64,")) return source; const canvas = await loadImageCanvas(source); return canvas.toDataURL("image/png"); } export function cropCanvas(sourceCanvas: HTMLCanvasElement, crop: Rect, outputWidth = crop.w, outputHeight = crop.h): string { assertProcessingRasterSize(outputWidth, outputHeight, "Crop"); const canvas = createCanvas(outputWidth, outputHeight); const context = require2dContext(canvas); context.clearRect(0, 0, outputWidth, outputHeight); context.drawImage(sourceCanvas, crop.x, crop.y, crop.w, crop.h, 0, 0, crop.w, crop.h); return canvas.toDataURL("image/png"); } export function sampleDocumentCanvasInLayerSpace( documentCanvas: HTMLCanvasElement, artboardBounds: Rect, layer: { transform: { position: { x: number; y: number }; scale: { x: number; y: number }; rotation: number }; sourceRect?: Rect }, intrinsicSize: { w: number; h: number }, crop: Rect, outputWidth = crop.w, outputHeight = crop.h, ): string { assertProcessingRasterSize(outputWidth, outputHeight, "Context crop"); const canvas = createCanvas(outputWidth, outputHeight); const context = require2dContext(canvas); const source = layer.sourceRect ?? { x: 0, y: 0, ...intrinsicSize }; const center = { x: layer.transform.position.x + (source.x + source.w / 2) * layer.transform.scale.x, y: layer.transform.position.y + (source.y + source.h / 2) * layer.transform.scale.y, }; context.translate(-crop.x, -crop.y); context.scale(1 / Math.max(0.0001, layer.transform.scale.x), 1 / Math.max(0.0001, layer.transform.scale.y)); context.translate(-layer.transform.position.x, -layer.transform.position.y); context.translate(center.x, center.y); context.rotate(-layer.transform.rotation); context.translate(-center.x, -center.y); context.drawImage(documentCanvas, artboardBounds.x, artboardBounds.y); return canvas.toDataURL("image/png"); } export function cropMaskValuesToDataUrl(values: Uint8ClampedArray, width: number, height: number, crop: Rect, outputWidth = crop.w, outputHeight = crop.h): string { assertProcessingRasterSize(outputWidth, outputHeight, "Mask crop"); const canvas = createCanvas(outputWidth, outputHeight); const context = require2dContext(canvas); const imageData = context.createImageData(outputWidth, outputHeight); imageData.data.set(cropMaskValuesToRgba(values, width, height, crop, outputWidth, outputHeight)); context.putImageData(imageData, 0, 0); return canvas.toDataURL("image/png"); } export function cropMaskValuesToRgba(values: Uint8ClampedArray, width: number, height: number, crop: Rect, outputWidth = crop.w, outputHeight = crop.h): Uint8ClampedArray { const safeOutputWidth = Math.max(1, Math.round(outputWidth)); const safeOutputHeight = Math.max(1, Math.round(outputHeight)); const data = new Uint8ClampedArray(safeOutputWidth * safeOutputHeight * 4); for (let pixel = 0; pixel < safeOutputWidth * safeOutputHeight; pixel += 1) data[pixel * 4 + 3] = 255; for (let y = 0; y < Math.min(crop.h, safeOutputHeight); y += 1) { for (let x = 0; x < Math.min(crop.w, safeOutputWidth); x += 1) { const sourceX = crop.x + x; const sourceY = crop.y + y; if (sourceX < 0 || sourceY < 0 || sourceX >= width || sourceY >= height) continue; const value = values[sourceY * width + sourceX] ?? 0; const index = (y * safeOutputWidth + x) * 4; data[index] = value; data[index + 1] = value; data[index + 2] = value; } } return data; } export function expandRectWithinBounds(rect: Rect, padding: number, bounds: { w: number; h: number }, multiple = 1, minSize = 1): Rect { const padded = Math.max(0, Math.round(padding)); let x1 = Math.max(0, Math.floor(rect.x) - padded); let y1 = Math.max(0, Math.floor(rect.y) - padded); let x2 = Math.min(bounds.w, Math.ceil(rect.x + rect.w) + padded); let y2 = Math.min(bounds.h, Math.ceil(rect.y + rect.h) + padded); const safeMinSize = Math.max(1, Math.round(minSize)); const targetWidth = Math.min(bounds.w, roundUp(Math.max(safeMinSize, x2 - x1), multiple)); const targetHeight = Math.min(bounds.h, roundUp(Math.max(safeMinSize, y2 - y1), multiple)); const extraWidth = targetWidth - (x2 - x1); const extraHeight = targetHeight - (y2 - y1); x1 = Math.max(0, x1 - Math.floor(extraWidth / 2)); y1 = Math.max(0, y1 - Math.floor(extraHeight / 2)); x2 = Math.min(bounds.w, x1 + targetWidth); y2 = Math.min(bounds.h, y1 + targetHeight); x1 = Math.max(0, x2 - targetWidth); y1 = Math.max(0, y2 - targetHeight); return { x: x1, y: y1, w: Math.max(1, x2 - x1), h: Math.max(1, y2 - y1) }; } export function maskValueFromRgba(data: Uint8ClampedArray, index: number): number { const red = data[index] ?? 0; const green = data[index + 1] ?? 0; const blue = data[index + 2] ?? 0; const alpha = data[index + 3] ?? 0; const luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue; return Math.round((alpha * luminance) / 255); } export function applyMaskValueOperation(values: Uint8ClampedArray, width: number, height: number, operation: Exclude): Uint8ClampedArray { switch (operation.type) { case "invert": return invertMaskValues(values); case "feather": case "blur": return blurMaskValues(values, width, height, Math.round(clampNumber(operation.radius, 0, 256))); case "expand": return dilateMaskValues(values, width, height, Math.round(clampNumber(operation.radius, 0, 256))); case "contract": return erodeMaskValues(values, width, height, Math.round(clampNumber(operation.radius, 0, 256))); case "despeckle": return despeckleMaskValues(values, width, height, Math.round(clampNumber(operation.strength, 0, 64))); } } export function invertMaskValues(values: Uint8ClampedArray): Uint8ClampedArray { const next = new Uint8ClampedArray(values.length); for (let index = 0; index < values.length; index += 1) next[index] = 255 - (values[index] ?? 0); return next; } export function erodeMaskValues(values: Uint8ClampedArray, width: number, height: number, radius: number): Uint8ClampedArray { const safeRadius = Math.round(clampNumber(radius, 0, 256)); if (safeRadius <= 0) return new Uint8ClampedArray(values); return separableExtrema(values, width, height, safeRadius, Math.min, 255); } export function dilateMaskValues(values: Uint8ClampedArray, width: number, height: number, radius: number): Uint8ClampedArray { const safeRadius = Math.round(clampNumber(radius, 0, 256)); if (safeRadius <= 0) return new Uint8ClampedArray(values); return separableExtrema(values, width, height, safeRadius, Math.max, 0); } export function blurMaskValues(values: Uint8ClampedArray, width: number, height: number, radius: number): Uint8ClampedArray { const safeRadius = Math.round(clampNumber(radius, 0, 256)); if (safeRadius <= 0) return new Uint8ClampedArray(values); const horizontal = new Float64Array(values.length); const next = new Uint8ClampedArray(values.length); const span = safeRadius * 2 + 1; for (let y = 0; y < height; y += 1) { let total = 0; for (let ox = -safeRadius; ox <= safeRadius; ox += 1) total += values[y * width + clampInt(ox, 0, width - 1)] ?? 0; for (let x = 0; x < width; x += 1) { horizontal[y * width + x] = total / span; total += (values[y * width + clampInt(x + safeRadius + 1, 0, width - 1)] ?? 0) - (values[y * width + clampInt(x - safeRadius, 0, width - 1)] ?? 0); } } for (let x = 0; x < width; x += 1) { let total = 0; for (let oy = -safeRadius; oy <= safeRadius; oy += 1) total += horizontal[clampInt(oy, 0, height - 1) * width + x] ?? 0; for (let y = 0; y < height; y += 1) { next[y * width + x] = Math.round(total / span); total += (horizontal[clampInt(y + safeRadius + 1, 0, height - 1) * width + x] ?? 0) - (horizontal[clampInt(y - safeRadius, 0, height - 1) * width + x] ?? 0); } } return next; } function separableExtrema(values: Uint8ClampedArray, width: number, height: number, radius: number, combine: (a: number, b: number) => number, initial: number) { const horizontal = new Uint8ClampedArray(values.length); const next = new Uint8ClampedArray(values.length); for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) { let value = initial; for (let offset = -radius; offset <= radius; offset += 1) value = combine(value, values[y * width + clampInt(x + offset, 0, width - 1)] ?? 0); horizontal[y * width + x] = value; } for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) { let value = initial; for (let offset = -radius; offset <= radius; offset += 1) value = combine(value, horizontal[clampInt(y + offset, 0, height - 1) * width + x] ?? 0); next[y * width + x] = value; } return next; } export function despeckleMaskValues(values: Uint8ClampedArray, width: number, height: number, strength: number): Uint8ClampedArray { const safeStrength = Math.round(clampNumber(strength, 0, 64)); if (safeStrength <= 0) return new Uint8ClampedArray(values); const radius = Math.max(1, Math.ceil(safeStrength / 6)); const threshold = Math.max(1, Math.round(safeStrength / 2)); const next = new Uint8ClampedArray(values); for (let y = 0; y < height; y += 1) { for (let x = 0; x < width; x += 1) { const index = y * width + x; const visible = (values[index] ?? 0) > 127; let same = 0; for (let oy = -radius; oy <= radius; oy += 1) { for (let ox = -radius; ox <= radius; ox += 1) { if (ox === 0 && oy === 0) continue; const sample = values[clampInt(y + oy, 0, height - 1) * width + clampInt(x + ox, 0, width - 1)] ?? 0; if ((sample > 127) === visible) same += 1; } } if (same <= threshold) next[index] = visible ? 0 : 255; } } return next; } async function loadMaskValues(source: string, width: number, height: number): Promise<{ width: number; height: number; values: Uint8ClampedArray }> { const canvas = await loadImageCanvas(source, width, height); const context = require2dContext(canvas); const data = context.getImageData(0, 0, canvas.width, canvas.height); const values = new Uint8ClampedArray(canvas.width * canvas.height); for (let pixel = 0; pixel < values.length; pixel += 1) values[pixel] = maskValueFromRgba(data.data, pixel * 4); return { width: canvas.width, height: canvas.height, values }; } function maskValuesToDataUrl(values: Uint8ClampedArray, width: number, height: number, outputWidth = width, outputHeight = height): string { const canvas = createCanvas(outputWidth, outputHeight); const context = require2dContext(canvas); const imageData = context.createImageData(outputWidth, outputHeight); for (let y = 0; y < outputHeight; y += 1) { for (let x = 0; x < outputWidth; x += 1) { const sourceX = Math.floor((x / outputWidth) * width); const sourceY = Math.floor((y / outputHeight) * height); const value = values[clampInt(sourceY, 0, height - 1) * width + clampInt(sourceX, 0, width - 1)] ?? 0; const index = (y * outputWidth + x) * 4; imageData.data[index] = 255; imageData.data[index + 1] = 255; imageData.data[index + 2] = 255; imageData.data[index + 3] = value; } } context.putImageData(imageData, 0, 0); return canvas.toDataURL("image/png"); } function analyzeValues(values: Uint8ClampedArray, width: number, height: number, includeSoftPixels: boolean): { pixels: number; coverage: number; bounds?: Rect } { let pixels = 0; let minX = Number.POSITIVE_INFINITY; let minY = Number.POSITIVE_INFINITY; let maxX = Number.NEGATIVE_INFINITY; let maxY = Number.NEGATIVE_INFINITY; for (let y = 0; y < height; y += 1) { for (let x = 0; x < width; x += 1) { const value = values[y * width + x] ?? 0; const active = includeSoftPixels ? value > 0 : value > 127; if (!active) continue; pixels += 1; minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x + 1); maxY = Math.max(maxY, y + 1); } } return { pixels, coverage: pixels / Math.max(1, width * height), bounds: pixels > 0 ? { x: minX, y: minY, w: maxX - minX, h: maxY - minY } : undefined, }; } function createCanvas(width: number, height: number): HTMLCanvasElement { const canvas = document.createElement("canvas"); canvas.width = Math.max(1, Math.round(width)); canvas.height = Math.max(1, Math.round(height)); return canvas; } function require2dContext(canvas: HTMLCanvasElement): CanvasRenderingContext2D { const context = canvas.getContext("2d"); if (!context) throw new Error("Unable to create mask canvas"); return context; } function loadImage(source: string): Promise { return new Promise((resolve, reject) => { const image = new Image(); image.onload = () => resolve(image); image.onerror = () => reject(new Error("Failed to load image")); image.src = source; }); } function roundUp(value: number, multiple: number): number { const safeMultiple = Math.max(1, Math.round(multiple)); return Math.ceil(value / safeMultiple) * safeMultiple; } function clampNumber(value: number, min: number, max: number): number { if (!Number.isFinite(value)) return min; return Math.max(min, Math.min(max, value)); } function clampInt(value: number, min: number, max: number): number { return Math.round(Math.max(min, Math.min(max, value))); }