feat: implement inpainting functionality with mask handling

- Added `createMaskedPixelReplacementSource` function to handle pixel replacement using inpainting.
- Introduced `buildInpaintBundle` to prepare inpainting data including mask generation and validation.
- Created utility functions for mask operations such as `applyMaskedContentModeToRgba`, `expandRectWithinBounds`, and others for mask manipulation.
- Developed tests for inpainting preparation and mask raster utilities to ensure functionality and correctness.
- Implemented mask raster operations including inversion, feathering, blurring, and more.
This commit is contained in:
syntaxbullet
2026-07-05 09:35:16 +02:00
parent 6e5d58a638
commit f5c610dac5
35 changed files with 2285 additions and 242 deletions

View File

@@ -7,6 +7,7 @@ import { resolveTransformTargetBounds } from "@editor/transform-targets";
import type { AppStore } from "@editor/store";
import type { ChromaKeySettings } from "@editor/tools";
import type { SelectionState } from "@editor/state";
import { blurMaskValues, despeckleMaskValues, dilateMaskValues, erodeMaskValues } from "../mask/maskRaster";
import { BottomControlColorPicker } from "./ColorPicker";
import { BottomControlDivider } from "./Divider";
import { BottomControlSlider } from "./Slider";
@@ -179,8 +180,8 @@ async function applyChromaKeyMask(target: NonNullable<ReturnType<typeof resolveC
const source = await chromaKeyMaskSource(target.asset.source, target.asset.intrinsicSize.w, target.asset.intrinsicSize.h, settings);
dispatch(commandIds.toolSetBrushStrokePreview, undefined);
if (target.maskAsset) {
dispatch(commandIds.documentUpdateAssetSource, { assetId: target.maskAsset.id, source });
if (target.maskAsset && target.maskLayer && target.maskLayer.type !== "group") {
dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId: target.maskLayer.id, source, mimeType: "image/png", operation: { type: "chromaKey" } });
return;
}
@@ -293,85 +294,13 @@ function postProcessAlpha(alpha: Uint8ClampedArray, width: number, height: numbe
const choke = Math.round(Math.max(-20, Math.min(20, settings.choke)));
const feather = Math.round(Math.max(0, Math.min(20, settings.feather)));
if (despeckle > 0) next = despeckleAlpha(next, width, height, despeckle);
if (choke > 0) next = erodeAlpha(next, width, height, choke);
if (choke < 0) next = dilateAlpha(next, width, height, -choke);
if (feather > 0) next = blurAlpha(next, width, height, feather);
if (despeckle > 0) next = despeckleMaskValues(next, width, height, despeckle);
if (choke > 0) next = erodeMaskValues(next, width, height, choke);
if (choke < 0) next = dilateMaskValues(next, width, height, -choke);
if (feather > 0) next = blurMaskValues(next, width, height, feather);
return next;
}
function despeckleAlpha(alpha: Uint8ClampedArray, width: number, height: number, strength: number) {
const radius = Math.max(1, Math.ceil(strength / 6));
const threshold = Math.max(1, Math.round(strength / 2));
const next = new Uint8ClampedArray(alpha);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const index = y * width + x;
const visible = (alpha[index] ?? 0) > 127;
let same = 0;
for (let oy = -radius; oy <= radius; oy++) {
for (let ox = -radius; ox <= radius; ox++) {
if (ox === 0 && oy === 0) continue;
const sample = alpha[clamp(y + oy, 0, height - 1) * width + clamp(x + ox, 0, width - 1)] ?? 0;
if ((sample > 127) === visible) same += 1;
}
}
if (same <= threshold) next[index] = visible ? 0 : 255;
}
}
return next;
}
function erodeAlpha(alpha: Uint8ClampedArray, width: number, height: number, radius: number) {
const next = new Uint8ClampedArray(alpha.length);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let value = 255;
for (let oy = -radius; oy <= radius; oy++) {
for (let ox = -radius; ox <= radius; ox++) value = Math.min(value, alpha[clamp(y + oy, 0, height - 1) * width + clamp(x + ox, 0, width - 1)] ?? 0);
}
next[y * width + x] = value;
}
}
return next;
}
function dilateAlpha(alpha: Uint8ClampedArray, width: number, height: number, radius: number) {
const next = new Uint8ClampedArray(alpha.length);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let value = 0;
for (let oy = -radius; oy <= radius; oy++) {
for (let ox = -radius; ox <= radius; ox++) value = Math.max(value, alpha[clamp(y + oy, 0, height - 1) * width + clamp(x + ox, 0, width - 1)] ?? 0);
}
next[y * width + x] = value;
}
}
return next;
}
function blurAlpha(alpha: Uint8ClampedArray, width: number, height: number, radius: number) {
const next = new Uint8ClampedArray(alpha.length);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let total = 0;
let count = 0;
for (let oy = -radius; oy <= radius; oy++) {
for (let ox = -radius; ox <= radius; ox++) {
total += alpha[clamp(y + oy, 0, height - 1) * width + clamp(x + ox, 0, width - 1)] ?? 0;
count += 1;
}
}
next[y * width + x] = Math.round(total / count);
}
}
return next;
}
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}
function loadImage(source: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();