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

@@ -1,6 +1,7 @@
import { useMemo, useRef, useState, type DragEvent, type MutableRefObject } from "react";
import { useEffect, useMemo, useRef, useState, type DragEvent, type MutableRefObject } from "react";
import { ArrowDown, ArrowUp, DownloadSimple, Eye, EyeSlash, FolderPlus, Lock, LockOpen, Plus, Stack, Trash } from "@phosphor-icons/react";
import { commandIds } from "@commands/ids";
import type { Asset } from "@core/asset";
import type { ImageDocument } from "@core/document";
import type { Layer } from "@core/layer";
import type { ArtboardId } from "@core/id";
@@ -9,6 +10,7 @@ import type { MaskEditState, SelectionState } from "@editor/state";
import type { AppStore } from "@editor/store";
import { resolveLayerDrop } from "@input/index";
import { downloadArtboardPng } from "./exportArtboardPng";
import { analyzeMaskSource, applyMaskRasterOperation, type MaskAnalysis, type MaskRasterOperation } from "./mask/maskRaster";
export type LayersSheetProps = {
document: ImageDocument;
@@ -219,6 +221,7 @@ function LayerRow({
const selected = selectedLayerIds.includes(layer.id);
const layerInfo = documentIndex.layerInfoById.get(layer.id);
const maskLayer = layer.clippingMask ? documentIndex.layerById.get(layer.clippingMask.maskLayerId) : undefined;
const maskAsset = maskLayer && maskLayer.type !== "group" ? documentIndex.assetById.get(maskLayer.assetId) : undefined;
const canAddMask = Boolean(layerInfo && layer.type !== "group" && !layer.clippingMask);
const editingMask = Boolean(maskEdit && layer.clippingMask && maskEdit.targetLayerId === layer.id && maskEdit.maskLayerId === layer.clippingMask.maskLayerId);
const rowPadding = 12 + depth * 16;
@@ -288,21 +291,49 @@ function LayerRow({
</button>
</div>
{layer.clippingMask ? (
<div className="mt-2 flex min-h-14 items-center gap-3 rounded-full py-2 pl-4 pr-2 text-sm text-sky-100/70 hover:bg-white/[0.04]">
<Stack size={24} weight="fill" />
<span className="min-w-0 flex-1 truncate">{maskLayer ? "Layer mask" : "Layer mask missing"}</span>
<div className="mt-2 flex min-h-14 flex-wrap items-center gap-2 rounded-[1.5rem] py-2 pl-4 pr-2 text-sm text-sky-100/70 hover:bg-white/[0.04]">
<Stack size={24} weight="fill" className="shrink-0" />
<span className="min-w-28 flex-1 truncate">{maskLayer ? "Layer mask" : "Layer mask missing"}</span>
{maskAsset ? <MaskStatus asset={maskAsset} /> : null}
{maskLayer ? (
<button
type="button"
className={`h-9 rounded-full px-4 text-sm font-medium transition ${editingMask ? "bg-sky-300 text-black" : "text-sky-100/75 hover:bg-white/10 hover:text-sky-50"}`}
onClick={() =>
editingMask
? dispatch(commandIds.toolExitMaskEdit, undefined)
: dispatch(commandIds.toolEnterMaskEdit, { targetLayerId: layer.id, maskLayerId: layer.clippingMask!.maskLayerId })
}
>
{editingMask ? "Done" : "Edit"}
</button>
<div className="flex flex-wrap items-center gap-1">
<button
type="button"
className={`h-9 rounded-full px-4 text-sm font-medium transition ${editingMask ? "bg-sky-300 text-black" : "text-sky-100/75 hover:bg-white/10 hover:text-sky-50"}`}
onClick={() =>
editingMask
? dispatch(commandIds.toolExitMaskEdit, undefined)
: dispatch(commandIds.toolEnterMaskEdit, { targetLayerId: layer.id, maskLayerId: layer.clippingMask!.maskLayerId })
}
>
{editingMask ? "Done" : "Edit"}
</button>
<button
type="button"
className={maskActionButtonClass()}
title="Paint reveal"
onClick={() => {
dispatch(commandIds.toolEnterMaskEdit, { targetLayerId: layer.id, maskLayerId: layer.clippingMask!.maskLayerId });
dispatch(commandIds.toolSetActive, { tool: "brush" });
}}
>
Reveal
</button>
<button
type="button"
className={maskActionButtonClass()}
title="Paint hide"
onClick={() => {
dispatch(commandIds.toolEnterMaskEdit, { targetLayerId: layer.id, maskLayerId: layer.clippingMask!.maskLayerId });
dispatch(commandIds.toolSetActive, { tool: "eraser" });
}}
>
Hide
</button>
{maskAsset && maskLayer.type !== "group" ? (
<MaskOperationButtons maskLayerId={maskLayer.id} maskAsset={maskAsset} dispatch={dispatch} />
) : null}
</div>
) : null}
<button
type="button"
@@ -336,6 +367,83 @@ function LayerRow({
);
}
function MaskStatus({ asset }: { asset: Asset }) {
const [analysis, setAnalysis] = useState<MaskAnalysis>();
useEffect(() => {
let cancelled = false;
void analyzeMaskSource(asset.source, asset.intrinsicSize.w, asset.intrinsicSize.h)
.then((nextAnalysis) => {
if (!cancelled) setAnalysis(nextAnalysis);
})
.catch(() => {
if (!cancelled) setAnalysis(undefined);
});
return () => {
cancelled = true;
};
}, [asset.source, asset.intrinsicSize.w, asset.intrinsicSize.h]);
if (!analysis) return <span className="rounded-full bg-white/5 px-3 py-1 text-xs text-sky-100/45">Reading</span>;
return (
<span className="inline-flex min-w-0 items-center gap-2 rounded-full bg-white/5 px-2 py-1 text-xs text-sky-100/65">
<img src={analysis.thumbnail} alt="" className="h-7 w-10 rounded-md bg-black/30 object-cover ring-1 ring-white/10" />
<span className="whitespace-nowrap">Reveal {formatPercent(analysis.coverage)}</span>
<span className="whitespace-nowrap text-sky-100/45">Inpaint {formatPercent(analysis.hiddenCoverage)}</span>
</span>
);
}
function MaskOperationButtons({ maskLayerId, maskAsset, dispatch }: { maskLayerId: string; maskAsset: Asset; dispatch: AppStore["dispatch"] }) {
return (
<>
<MaskOperationButton label="Invert" title="Invert mask" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "invert" }} dispatch={dispatch} />
<MaskOperationButton label="White" title="Fill mask white" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "fill", fill: "white" }} dispatch={dispatch} />
<MaskOperationButton label="Black" title="Fill mask black" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "fill", fill: "black" }} dispatch={dispatch} />
<MaskOperationButton label="Clear" title="Clear mask" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "fill", fill: "clear" }} dispatch={dispatch} />
<MaskOperationButton label="Feather" title="Feather mask edge" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "feather", radius: 3 }} dispatch={dispatch} />
<MaskOperationButton label="Expand" title="Expand mask" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "expand", radius: 3 }} dispatch={dispatch} />
<MaskOperationButton label="Contract" title="Contract mask" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "contract", radius: 3 }} dispatch={dispatch} />
<MaskOperationButton label="Blur" title="Blur mask edge" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "blur", radius: 2 }} dispatch={dispatch} />
<MaskOperationButton label="Clean" title="Despeckle mask" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "despeckle", strength: 8 }} dispatch={dispatch} />
</>
);
}
function MaskOperationButton({
label,
title,
maskLayerId,
maskAsset,
operation,
dispatch,
}: {
label: string;
title: string;
maskLayerId: string;
maskAsset: Asset;
operation: MaskRasterOperation;
dispatch: AppStore["dispatch"];
}) {
const [busy, setBusy] = useState(false);
return (
<button
type="button"
className={maskActionButtonClass()}
disabled={busy}
title={title}
onClick={() => {
setBusy(true);
void applyMaskRasterOperation(maskAsset.source, maskAsset.intrinsicSize.w, maskAsset.intrinsicSize.h, operation)
.then((source) => dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId, source, mimeType: "image/png", operation }))
.finally(() => setBusy(false));
}}
>
{busy ? "..." : label}
</button>
);
}
type EditingTitle =
| { type: "artboard"; id: ArtboardId; draft: string }
| { type: "layer"; id: string; draft: string };
@@ -528,3 +636,11 @@ function toolbarButtonClass() {
function labeledToolbarButtonClass() {
return "inline-flex h-12 flex-1 items-center rounded-full pr-4 text-sm font-medium text-white/75 transition hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-35 focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30";
}
function maskActionButtonClass() {
return "h-8 rounded-full bg-white/5 px-3 text-xs font-medium text-sky-100/65 transition hover:bg-white/10 hover:text-sky-50 disabled:pointer-events-none disabled:opacity-35";
}
function formatPercent(value: number) {
return `${Math.round(Math.max(0, Math.min(1, value)) * 100)}%`;
}