Files
image-studio/view/inpaint/InpaintRegionPanel.tsx
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

87 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from "react";
import { commandIds } from "@commands/ids";
import type { ImageDocument } from "@core/document";
import type { SelectionState } from "@editor/state";
import type { AppStore } from "@editor/store";
import type { GenerateSettings } from "@editor/tools";
import { runInpaintRegionOperation } from "@operations/masks/rasterActions";
import { createNormalizedMaskSource } from "@platform/browser/maskRaster";
type ProcessedMasks = { edit: string; noise: string; blend: string; coverage: number; bounds?: { x: number; y: number; w: number; h: number } };
export function InpaintRegionPanel({ document, selection, settings, dispatch }: { document: ImageDocument; selection: SelectionState; settings: GenerateSettings; dispatch: AppStore["dispatch"] }) {
const targetLayerId = selection.layerIds.length === 1 ? selection.layerIds[0] : undefined;
const region = document.inpaintRegions.find((candidate) => candidate.targetLayerId === targetLayerId && candidate.enabled);
const asset = region ? document.assets.find((candidate) => candidate.id === region.maskAssetId) : undefined;
const [processed, setProcessed] = useState<ProcessedMasks>();
const [busy, setBusy] = useState(false);
const processingKey = useMemo(() => asset ? [asset.source, settings.inpaint.maskExpand, settings.inpaint.maskFeather, settings.inpaint.maskBlur, settings.inpaint.maskDespeckle].join(":") : "", [asset, settings.inpaint]);
useEffect(() => {
if (!asset) {
setProcessed(undefined);
return;
}
let cancelled = false;
const width = Math.max(1, Math.round(asset.intrinsicSize.w));
const height = Math.max(1, Math.round(asset.intrinsicSize.h));
void Promise.all([
createNormalizedMaskSource(asset.source, width, height, { polarity: "revealed", despeckle: settings.inpaint.maskDespeckle }),
createNormalizedMaskSource(asset.source, width, height, { polarity: "revealed", expand: settings.inpaint.maskExpand, blur: settings.inpaint.maskBlur, despeckle: settings.inpaint.maskDespeckle }),
createNormalizedMaskSource(asset.source, width, height, { polarity: "revealed", feather: settings.inpaint.maskFeather, despeckle: settings.inpaint.maskDespeckle }),
]).then(([edit, noise, blend]) => {
if (cancelled) return;
const active = edit.values.reduce((sum, value) => sum + value / 255, 0);
setProcessed({ edit: edit.source, noise: noise.source, blend: blend.source, coverage: active / edit.values.length, bounds: edit.bounds });
}).catch(() => !cancelled && setProcessed(undefined));
return () => { cancelled = true; };
}, [processingKey, asset, settings.inpaint.maskDespeckle, settings.inpaint.maskExpand, settings.inpaint.maskBlur, settings.inpaint.maskFeather]);
if (!targetLayerId) return <p className="rounded-md bg-amber-300/[0.07] px-2.5 py-2 text-xs text-amber-100/70">Select one image or raster layer to create an AI edit region.</p>;
if (!region || !asset) return <p className="rounded-md bg-white/[0.04] px-2.5 py-2 text-xs text-white/55">No AI edit region yet. Use <strong className="text-white/80">Add region</strong> beside Generate, then paint where pixels should be replaced.</p>;
const operation = async (type: "invert" | "clear" | "fill") => {
setBusy(true);
try {
await runInpaintRegionOperation(region.id, asset, type === "invert" ? { type: "invert" } : { type: "fill", fill: type === "fill" ? "white" : "black" }, dispatch);
} finally {
setBusy(false);
}
};
return (
<div className="grid gap-2 rounded-lg border border-white/[0.07] bg-white/[0.025] p-2.5">
<div className="flex items-center justify-between gap-2">
<div><strong className="block text-xs text-white/80">AI edit region</strong><span className="text-[0.68rem] text-white/40">Painted pixels are replaced; unpainted pixels are protected.</span></div>
<button type="button" className="rounded-md bg-sky-300 px-2.5 py-1 text-xs font-semibold text-slate-950" onClick={() => dispatch(commandIds.toolEnterInpaintRegionEdit, { targetLayerId, regionId: region.id })}>Edit</button>
</div>
{processed ? (
<div className="grid grid-cols-3 gap-2 text-center text-[0.65rem] text-white/45">
<MaskPreview label="Edit" source={processed.edit} />
<MaskPreview label="Noise" source={processed.noise} />
<MaskPreview label="Blend" source={processed.blend} />
</div>
) : <span className="text-xs text-white/35">Preparing mask previews</span>}
<div className="flex flex-wrap items-center gap-1.5 text-[0.68rem] text-white/45">
{processed ? <span className="mr-auto">Replace {Math.round(processed.coverage * 100)}%{processed.bounds ? ` · ${Math.round(processed.bounds.w)}×${Math.round(processed.bounds.h)} px` : " · empty"}</span> : <span className="mr-auto" />}
<RegionButton disabled={busy} label="Invert" onClick={() => void operation("invert")} />
<RegionButton disabled={busy} label="Color select" onClick={() => { dispatch(commandIds.toolEnterInpaintRegionEdit, { targetLayerId, regionId: region.id }); dispatch(commandIds.toolSetActive, { tool: "magicWand" }); }} />
<RegionButton disabled={busy} label="AI object" onClick={() => { dispatch(commandIds.toolEnterInpaintRegionEdit, { targetLayerId, regionId: region.id }); dispatch(commandIds.toolSetActive, { tool: "semanticSelect" }); }} />
<RegionButton disabled={busy} label="Lasso" onClick={() => { dispatch(commandIds.toolEnterInpaintRegionEdit, { targetLayerId, regionId: region.id }); dispatch(commandIds.toolSetActive, { tool: "maskLasso" }); }} />
<RegionButton disabled={busy} label="Rectangle" onClick={() => { dispatch(commandIds.toolEnterInpaintRegionEdit, { targetLayerId, regionId: region.id }); dispatch(commandIds.toolSetActive, { tool: "maskRectangle" }); }} />
<RegionButton disabled={busy} label="Clear" onClick={() => void operation("clear")} />
<RegionButton disabled={busy} label="Select all" onClick={() => void operation("fill")} />
<RegionButton disabled={busy} label="Remove" danger onClick={() => dispatch(commandIds.documentRemoveInpaintRegion, { regionId: region.id })} />
</div>
</div>
);
}
function MaskPreview({ label, source }: { label: string; source: string }) {
return <div className="grid gap-1"><img src={source} alt={`${label} mask preview`} className="h-14 w-full rounded-md bg-black/30 object-cover ring-1 ring-white/10" /><span>{label}</span></div>;
}
function RegionButton({ label, disabled, danger, onClick }: { label: string; disabled?: boolean; danger?: boolean; onClick: () => void }) {
return <button type="button" disabled={disabled} className={`rounded-md px-2 py-1 font-semibold transition disabled:opacity-35 ${danger ? "bg-red-400/10 text-red-100/70 hover:bg-red-400/20" : "bg-white/[0.05] text-white/55 hover:bg-white/[0.09] hover:text-white"}`} onClick={onClick}>{label}</button>;
}