Files
image-studio/platform/browser/maskRaster.ts
syntaxbullet ff762b8f17 feat: add inpaint region functionality and related tools
- Enhanced cursor behavior for new tools: semantic select, mask lasso, and mask rectangle.
- Updated mask edit state to include mask asset ID and kind.
- Implemented inpaint region commands for adding, applying, and removing inpaint regions.
- Introduced new operations for lasso and semantic selection tools.
- Created UI components for candidate review and inpaint region management.
- Added tests for inpaint region commands to ensure functionality.
- Updated various components to support new inpaint features and improve user experience.
2026-07-11 16:41:22 +02:00

453 lines
20 KiB
TypeScript

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<string> {
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<string> {
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<string> {
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<string> {
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<MaskAnalysis> {
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<HTMLCanvasElement> {
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<string> {
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<MaskRasterOperation, { type: "fill" }>): 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<HTMLImageElement> {
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)));
}