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.
This commit is contained in:
syntaxbullet
2026-07-11 16:41:22 +02:00
parent f4e13b80e7
commit ff762b8f17
78 changed files with 1632 additions and 301 deletions

View File

@@ -30,6 +30,7 @@ export type NormalizedMaskOptions = {
feather?: number;
blur?: number;
despeckle?: number;
limit?: Rect;
};
export async function createSolidMaskSource(width: number, height: number, fill: MaskFill): Promise<string> {
@@ -58,6 +59,36 @@ export async function applyMaskRasterOperation(source: string, width: number, he
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);
@@ -81,6 +112,7 @@ export async function createNormalizedMaskSource(source: string, width: number,
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));
@@ -97,6 +129,16 @@ export async function createNormalizedMaskSource(source: string, width: number,
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);
@@ -122,6 +164,33 @@ export function cropCanvas(sourceCanvas: HTMLCanvasElement, crop: Rect, outputWi
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);