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:
@@ -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)}%`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user