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:
@@ -194,12 +194,16 @@ export function App({ app }: AppProps) {
|
||||
activeTool={tools.activeTool}
|
||||
interactionMode={tools.interactionMode}
|
||||
panel={workspace.panel}
|
||||
inpaintMaskEditing={maskEdit?.kind === "inpaintRegion"}
|
||||
dispatch={app.store.dispatch}
|
||||
/>
|
||||
</div>
|
||||
<GenerateSheet
|
||||
settings={tools.generate}
|
||||
document={document}
|
||||
selection={selection}
|
||||
resources={generation.resources}
|
||||
generation={generation}
|
||||
open={generateOpen}
|
||||
dispatch={app.store.dispatch}
|
||||
/>
|
||||
@@ -216,7 +220,7 @@ export function App({ app }: AppProps) {
|
||||
document={document}
|
||||
selection={selection}
|
||||
viewport={viewport}
|
||||
visible={generateOpen || chromaKeyOpen || tools.activeTool === "brush" || tools.activeTool === "eraser" || tools.activeTool === "magicWand" || Boolean(transformBounds) || viewportActivityIsland.visible}
|
||||
visible={generateOpen || chromaKeyOpen || tools.activeTool === "brush" || tools.activeTool === "eraser" || tools.activeTool === "magicWand" || tools.activeTool === "semanticSelect" || tools.activeTool === "maskLasso" || tools.activeTool === "maskRectangle" || Boolean(transformBounds) || viewportActivityIsland.visible}
|
||||
action={viewportActivityIsland.action}
|
||||
activeTool={tools.activeTool}
|
||||
operation={generateOpen ? "generate" : chromaKeyOpen ? "chromaKey" : undefined}
|
||||
@@ -226,6 +230,7 @@ export function App({ app }: AppProps) {
|
||||
chromaKeySettings={tools.chromaKey}
|
||||
magicWandSettings={tools.magicWand}
|
||||
editingMask={Boolean(maskEdit)}
|
||||
maskKind={maskEdit?.kind}
|
||||
maskViewMode={maskEdit?.viewMode ?? "composite"}
|
||||
brushHint={brushHint}
|
||||
transformBounds={viewportActivityIsland.visible ? undefined : transformBounds}
|
||||
|
||||
@@ -31,6 +31,7 @@ export type BottomControlsIslandProps = {
|
||||
chromaKeySettings: ChromaKeySettings;
|
||||
magicWandSettings: MagicWandSettings;
|
||||
editingMask?: boolean;
|
||||
maskKind?: "layerMask" | "inpaintRegion";
|
||||
maskViewMode?: MaskViewMode;
|
||||
transformBounds?: Rect;
|
||||
transformTarget?: TransformTarget;
|
||||
@@ -40,7 +41,7 @@ export type BottomControlsIslandProps = {
|
||||
documentActions: DocumentActions;
|
||||
};
|
||||
|
||||
export function BottomControlsIsland({ document, selection, viewport, visible, action, activeTool, operation, brushSettings, generateSettings, generation, chromaKeySettings, magicWandSettings, editingMask = false, maskViewMode = "composite", transformBounds, transformTarget, brushHint, dispatch, generationWorkflow, documentActions }: BottomControlsIslandProps) {
|
||||
export function BottomControlsIsland({ document, selection, viewport, visible, action, activeTool, operation, brushSettings, generateSettings, generation, chromaKeySettings, magicWandSettings, editingMask = false, maskKind, maskViewMode = "composite", transformBounds, transformTarget, brushHint, dispatch, generationWorkflow, documentActions }: BottomControlsIslandProps) {
|
||||
const documentIndex = useMemo(() => createDocumentReadIndex(document), [document]);
|
||||
const selectedLayerInfo = selection.layerIds.length === 1 && selection.layerIds[0] ? documentIndex.layerInfoById.get(selection.layerIds[0]) : undefined;
|
||||
const zoomPercent = Math.round(viewport.zoom * 100);
|
||||
@@ -61,9 +62,13 @@ export function BottomControlsIsland({ document, selection, viewport, visible, a
|
||||
) : (activeTool === "brush" || activeTool === "eraser") && brushHint ? (
|
||||
<BrushHint tool={activeTool} hint={brushHint} />
|
||||
) : activeTool === "brush" || activeTool === "eraser" ? (
|
||||
<BrushControls tool={activeTool} settings={brushSettings} editingMask={editingMask} maskViewMode={maskViewMode} dispatch={dispatch} />
|
||||
<BrushControls tool={activeTool} settings={brushSettings} editingMask={editingMask} maskKind={maskKind} maskViewMode={maskViewMode} dispatch={dispatch} />
|
||||
) : activeTool === "magicWand" ? (
|
||||
<MagicWandControls settings={magicWandSettings} dispatch={dispatch} />
|
||||
) : activeTool === "semanticSelect" ? (
|
||||
<div className="flex items-center gap-3 px-4 text-sm text-white/75"><strong className="text-white">AI object select</strong><span>Click an object · Shift-click adds · Option-click protects</span></div>
|
||||
) : activeTool === "maskLasso" || activeTool === "maskRectangle" ? (
|
||||
<div className="flex items-center gap-3 px-4 text-sm text-white/75"><strong className="text-white">AI region {activeTool === "maskRectangle" ? "rectangle" : "lasso"}</strong><span>Drag to replace · Shift-drag adds · Option-drag protects</span></div>
|
||||
) : transformBounds && transformTarget ? (
|
||||
<TransformControls bounds={transformBounds} target={transformTarget} documentIndex={documentIndex} layerInfo={selectedLayerInfo} dispatch={dispatch} />
|
||||
) : action === "pan" ? (
|
||||
|
||||
@@ -105,5 +105,5 @@ function interactionModesEqual(a: InteractionMode, b: InteractionMode): boolean
|
||||
function maskEditStatesEqual(a: MaskEditState | undefined, b: MaskEditState | undefined): boolean {
|
||||
if (a === b) return true;
|
||||
if (!a || !b) return false;
|
||||
return a.targetLayerId === b.targetLayerId && a.maskLayerId === b.maskLayerId && a.viewMode === b.viewMode;
|
||||
return a.kind === b.kind && a.targetLayerId === b.targetLayerId && a.maskAssetId === b.maskAssetId && a.maskLayerId === b.maskLayerId && a.inpaintRegionId === b.inpaintRegionId && a.viewMode === b.viewMode;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import type { GenerateSettings } from "@editor/tools";
|
||||
import type { AppStore } from "@editor/store";
|
||||
import type { GenerationResourcesState } from "@editor/state";
|
||||
import type { GenerationResourcesState, GenerationState } from "@editor/state";
|
||||
import { GenerateControls } from "./bottom-controls/GenerateControls";
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { SelectionState } from "@editor/state";
|
||||
import { CandidateReviewPanel } from "./inpaint/CandidateReviewPanel";
|
||||
|
||||
export type GenerateSheetProps = {
|
||||
settings: GenerateSettings;
|
||||
document: ImageDocument;
|
||||
selection: SelectionState;
|
||||
resources: GenerationResourcesState;
|
||||
generation: GenerationState;
|
||||
open: boolean;
|
||||
dispatch: AppStore["dispatch"];
|
||||
};
|
||||
|
||||
export function GenerateSheet({ settings, resources, open, dispatch }: GenerateSheetProps) {
|
||||
export function GenerateSheet({ settings, document, selection, resources, generation, open, dispatch }: GenerateSheetProps) {
|
||||
return (
|
||||
<aside
|
||||
id="generate-sheet"
|
||||
@@ -22,7 +28,8 @@ export function GenerateSheet({ settings, resources, open, dispatch }: GenerateS
|
||||
>
|
||||
{open ? (
|
||||
<div className="subtle-scrollbar min-h-0 flex-1 overflow-auto py-4">
|
||||
<GenerateControls settings={settings} resources={resources} dispatch={dispatch} />
|
||||
<CandidateReviewPanel generation={generation} dispatch={dispatch} />
|
||||
<GenerateControls settings={settings} document={document} selection={selection} resources={resources} dispatch={dispatch} />
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
|
||||
@@ -17,7 +17,7 @@ export function GenerationJobStatus({ generation, compact = false }: { generatio
|
||||
|
||||
if (!job) return null;
|
||||
const elapsed = Math.max(0, Math.floor(((job.finishedAt ?? Date.now()) - job.startedAt) / 1000));
|
||||
const label = job.status === "running" ? `${job.label} ${formatElapsed(elapsed)}` : job.status === "failed" ? job.error ?? `${job.label} failed` : job.status === "cancelled" ? `${job.label} cancelled` : `${job.label} complete`;
|
||||
const label = job.status === "running" ? `${job.detail ?? job.label}${job.progress !== undefined ? ` · ${Math.round(job.progress * 100)}%` : ""} ${formatElapsed(elapsed)}` : job.status === "failed" ? job.error ?? `${job.label} failed` : job.status === "cancelled" ? `${job.label} cancelled` : `${job.label} complete`;
|
||||
const tone = job.status === "failed" ? "bg-red-500/15 text-red-100" : job.status === "running" ? "bg-white/10 text-white/70" : job.status === "cancelled" ? "bg-amber-500/15 text-amber-100" : "bg-emerald-500/15 text-emerald-100";
|
||||
|
||||
return <span className={`${compact ? "max-w-48" : "max-w-72"} truncate rounded-md px-2 py-1 text-[0.68rem] font-medium ${tone}`} title={label} aria-live="polite">{label}</span>;
|
||||
|
||||
@@ -346,8 +346,11 @@ function LayerRow({
|
||||
const layerMask = getLayerMask(layer);
|
||||
const maskLayer = layerMask ? documentIndex.layerById.get(layerMask.maskLayerId) : undefined;
|
||||
const maskAsset = maskLayer && (maskLayer.type === "image" || maskLayer.type === "raster") ? documentIndex.assetById.get(maskLayer.assetId) : undefined;
|
||||
const inpaintRegion = document.inpaintRegions.find((region) => region.targetLayerId === layer.id && region.enabled);
|
||||
const inpaintMaskAsset = inpaintRegion ? documentIndex.assetById.get(inpaintRegion.maskAssetId) : undefined;
|
||||
const canAddMask = Boolean(layerInfo && (layer.type === "image" || layer.type === "raster") && !layerMask);
|
||||
const editingMask = Boolean(maskEdit && layerMask && maskEdit.targetLayerId === layer.id && maskEdit.maskLayerId === layerMask.maskLayerId);
|
||||
const editingInpaintRegion = Boolean(maskEdit?.kind === "inpaintRegion" && inpaintRegion && maskEdit.inpaintRegionId === inpaintRegion.id);
|
||||
const thumbnail = thumbnailByLayerId.get(layer.id) ?? { kind: "empty" };
|
||||
const maskThumbnail = maskLayer ? thumbnailByLayerId.get(maskLayer.id) : undefined;
|
||||
const rowPadding = 12 + depth * 16;
|
||||
@@ -479,6 +482,16 @@ function LayerRow({
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{inpaintRegion ? (
|
||||
<div role="group" aria-label={`AI edit region attached to ${layer.name}`} className={`relative mt-1 ml-8 flex min-h-11 flex-wrap items-center gap-1.5 border-l-2 py-1.5 pl-3 pr-1.5 text-xs transition ${editingInpaintRegion ? "border-red-300 text-red-50" : "border-red-300/25 text-red-100/65"}`}>
|
||||
<span aria-hidden="true" className="absolute -left-2 top-1/2 h-px w-2 bg-red-300/40" />
|
||||
{inpaintMaskAsset ? <img src={inpaintMaskAsset.source} alt={`${layer.name} AI edit mask preview`} className="h-8 w-11 rounded-md bg-black/30 object-cover ring-1 ring-red-300/30" /> : <Stack size={24} weight="fill" />}
|
||||
<span className="min-w-28 flex-1 truncate font-medium">{editingInpaintRegion ? "Editing AI region" : inpaintRegion.name}</span>
|
||||
{inpaintMaskAsset ? <MaskStatus asset={inpaintMaskAsset} purpose="inpaint" /> : null}
|
||||
<button type="button" className={`h-8 rounded-md px-3 text-xs font-medium ${editingInpaintRegion ? "bg-red-200 text-red-950" : "hover:bg-white/[0.07]"}`} onClick={() => editingInpaintRegion ? dispatch(commandIds.toolExitMaskEdit, undefined) : dispatch(commandIds.toolEnterInpaintRegionEdit, { targetLayerId: layer.id, regionId: inpaintRegion.id })}>{editingInpaintRegion ? "Done" : "Edit"}</button>
|
||||
<button type="button" className="h-8 rounded-md px-3 text-xs font-medium text-red-100/50 transition hover:bg-red-400/15 hover:text-red-100" onClick={() => dispatch(commandIds.documentRemoveInpaintRegion, { regionId: inpaintRegion.id })}>Remove</button>
|
||||
</div>
|
||||
) : null}
|
||||
{layer.type === "group"
|
||||
? layer.children.map((child) => (
|
||||
<LayerRow
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Cursor, Eraser, Hand, PaintBrush, DropHalf, MagicWand, Sparkle } from "@phosphor-icons/react";
|
||||
import { Cursor, Eraser, Hand, PaintBrush, DropHalf, MagicWand, Sparkle, Polygon, Rectangle, Selection } from "@phosphor-icons/react";
|
||||
import { commandIds } from "@commands/ids";
|
||||
import type { AppStore } from "@editor/store";
|
||||
import type { InteractionMode, OperationId, ToolId } from "@editor/tools";
|
||||
@@ -10,16 +10,17 @@ export type ToolOverlayProps = {
|
||||
activeTool: ToolId;
|
||||
interactionMode: InteractionMode;
|
||||
panel: WorkspacePanel;
|
||||
inpaintMaskEditing: boolean;
|
||||
dispatch: AppStore["dispatch"];
|
||||
};
|
||||
|
||||
export function ToolOverlay({ activeTool, interactionMode, panel, dispatch }: ToolOverlayProps) {
|
||||
export function ToolOverlay({ activeTool, interactionMode, panel, inpaintMaskEditing, dispatch }: ToolOverlayProps) {
|
||||
return (
|
||||
<nav
|
||||
aria-label="Tools"
|
||||
className="app-surface pointer-events-auto flex w-11 flex-col items-center gap-1 rounded-xl p-1 text-white"
|
||||
>
|
||||
{availableToolIds.map((tool) => {
|
||||
{availableToolIds.filter((tool) => inpaintMaskEditing || (tool !== "semanticSelect" && tool !== "maskLasso" && tool !== "maskRectangle")).map((tool) => {
|
||||
const active = !isOperationWorkspacePanel(panel) && isToolHighlighted(tool, activeTool, interactionMode);
|
||||
const Icon = iconForTool(tool);
|
||||
|
||||
@@ -67,6 +68,12 @@ function iconForTool(tool: ToolId) {
|
||||
return Eraser;
|
||||
case "magicWand":
|
||||
return MagicWand;
|
||||
case "semanticSelect":
|
||||
return Selection;
|
||||
case "maskLasso":
|
||||
return Polygon;
|
||||
case "maskRectangle":
|
||||
return Rectangle;
|
||||
case "pan":
|
||||
return Hand;
|
||||
case "select":
|
||||
|
||||
@@ -13,6 +13,7 @@ export type BrushControlsProps = {
|
||||
tool: Extract<ToolId, "brush" | "eraser">;
|
||||
settings: BrushSettings;
|
||||
editingMask?: boolean;
|
||||
maskKind?: "layerMask" | "inpaintRegion";
|
||||
maskViewMode?: MaskViewMode;
|
||||
dispatch: AppStore["dispatch"];
|
||||
};
|
||||
@@ -24,7 +25,8 @@ const maskViewModeOptions = [
|
||||
{ value: "overlay", label: "Overlay" },
|
||||
] satisfies readonly BottomControlSelectOption<MaskViewMode>[];
|
||||
|
||||
export function BrushControls({ tool, settings, editingMask = false, maskViewMode = "composite", dispatch }: BrushControlsProps) {
|
||||
export function BrushControls({ tool, settings, editingMask = false, maskKind = "layerMask", maskViewMode = "composite", dispatch }: BrushControlsProps) {
|
||||
const inpaintRegion = editingMask && maskKind === "inpaintRegion";
|
||||
return (
|
||||
<div className={bottomControlMenuClass()}>
|
||||
<span className={bottomControlIconSlotClass()} title={editingMask ? `Mask ${tool === "eraser" ? "hide" : "reveal"}` : tool === "eraser" ? "Eraser" : "Brush"}>
|
||||
@@ -33,8 +35,8 @@ export function BrushControls({ tool, settings, editingMask = false, maskViewMod
|
||||
<BottomControlDivider />
|
||||
{editingMask ? (
|
||||
<>
|
||||
<button type="button" className={maskModeButtonClass(tool === "brush")} aria-pressed={tool === "brush"} onClick={() => dispatch(commandIds.toolSetActive, { tool: "brush" })}>Reveal</button>
|
||||
<button type="button" className={maskModeButtonClass(tool === "eraser")} aria-pressed={tool === "eraser"} onClick={() => dispatch(commandIds.toolSetActive, { tool: "eraser" })}>Hide</button>
|
||||
<button type="button" className={maskModeButtonClass(tool === "brush")} aria-pressed={tool === "brush"} onClick={() => dispatch(commandIds.toolSetActive, { tool: "brush" })}>{inpaintRegion ? "Replace" : "Reveal"}</button>
|
||||
<button type="button" className={maskModeButtonClass(tool === "eraser")} aria-pressed={tool === "eraser"} onClick={() => dispatch(commandIds.toolSetActive, { tool: "eraser" })}>{inpaintRegion ? "Protect" : "Hide"}</button>
|
||||
<BottomControlDivider />
|
||||
</>
|
||||
) : null}
|
||||
@@ -61,6 +63,13 @@ export function BrushControls({ tool, settings, editingMask = false, maskViewMod
|
||||
<span className="w-9 text-right text-xs font-medium text-white/85">{Math.round(settings.size)}</span>
|
||||
</label>
|
||||
<BottomControlDivider />
|
||||
<label className={bottomControlFieldClass()}><span className={bottomControlLabelClass()}>Opacity</span><BottomControlSlider min={0} max={100} value={settings.opacity} className="min-w-10 w-[clamp(2.5rem,7vw,6rem)] shrink" aria-label="Brush opacity" onValueChange={(opacity) => dispatch(commandIds.toolSetBrushSettings, { opacity })} /><span className="w-9 text-right text-xs font-medium text-white/85">{Math.round(settings.opacity)}</span></label>
|
||||
<BottomControlDivider />
|
||||
<label className={bottomControlFieldClass()}><span className={bottomControlLabelClass()}>Flow</span><BottomControlSlider min={1} max={100} value={settings.flow} className="min-w-10 w-[clamp(2.5rem,7vw,6rem)] shrink" aria-label="Brush flow" onValueChange={(flow) => dispatch(commandIds.toolSetBrushSettings, { flow })} /><span className="w-9 text-right text-xs font-medium text-white/85">{Math.round(settings.flow)}</span></label>
|
||||
<BottomControlDivider />
|
||||
<label className={bottomControlFieldClass()}><span className={bottomControlLabelClass()}>Smooth</span><BottomControlSlider min={0} max={100} value={settings.smoothing} className="min-w-10 w-[clamp(2.5rem,7vw,6rem)] shrink" aria-label="Brush smoothing" onValueChange={(smoothing) => dispatch(commandIds.toolSetBrushSettings, { smoothing })} /><span className="w-9 text-right text-xs font-medium text-white/85">{Math.round(settings.smoothing)}</span></label>
|
||||
<button type="button" className={maskModeButtonClass(settings.pressureSize)} aria-pressed={settings.pressureSize} title="Use pen pressure for brush size" onClick={() => dispatch(commandIds.toolSetBrushSettings, { pressureSize: !settings.pressureSize })}>Pressure</button>
|
||||
<BottomControlDivider />
|
||||
<label className={bottomControlFieldClass()}>
|
||||
<span className={bottomControlLabelClass()}>Hard</span>
|
||||
<BottomControlSlider
|
||||
|
||||
@@ -121,6 +121,7 @@ function CandidateControls({
|
||||
<span className="px-2 text-xs font-medium text-white/55" title={`${candidate.settings.model} · ${candidate.mode}`}><strong className="block font-semibold text-white/75">Provisional result</strong>Seed {candidate.seed}</span>
|
||||
<CandidateCompareControls compareMode={compareMode} disabled={disabled} dispatch={dispatch} />
|
||||
<CandidateButton disabled={disabled} label="Regenerate" title="Regenerate same mask and crop" onClick={() => rerun("Regenerate", candidate.settings)} />
|
||||
<CandidateButton disabled={disabled || !candidate.inpaint} label="Current mask" title="Rebuild the crop and generate from the current AI edit region" onClick={() => void workflow.rebuildFromCurrentRegion(candidate.id)} />
|
||||
<CandidateButton
|
||||
disabled={disabled}
|
||||
label="Lower"
|
||||
@@ -159,7 +160,7 @@ function CandidateControls({
|
||||
onClick={() => {
|
||||
if (!candidate.inpaint) return;
|
||||
dispatch(commandIds.selectionSet, { artboardId: candidate.placement.artboardId, layerIds: [candidate.inpaint.targetLayerId] });
|
||||
dispatch(commandIds.toolEnterMaskEdit, { targetLayerId: candidate.inpaint.targetLayerId, maskLayerId: candidate.inpaint.maskLayerId });
|
||||
dispatch(commandIds.toolEnterInpaintRegionEdit, { targetLayerId: candidate.inpaint.targetLayerId, regionId: candidate.inpaint.regionId });
|
||||
}}
|
||||
/>
|
||||
<CandidateButton disabled={disabled} label="Dismiss" title="Remove this candidate" onClick={() => dispatch(commandIds.generationRemoveCandidate, { candidateId: candidate.id })} />
|
||||
|
||||
@@ -7,6 +7,9 @@ import type { GenerateArchitecture, GenerateIntent, GenerateMode, GenerateSettin
|
||||
import { resolveGenerationModeOptions, resolveGenerationModelOptions, resolveGenerationStringOptions, resolveGenerationSupportOptions } from "@operations/generation/options";
|
||||
import { BottomControlSelectMenu, type BottomControlSelectOption } from "./SelectMenu";
|
||||
import { BottomControlSlider } from "./Slider";
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { SelectionState } from "@editor/state";
|
||||
import { InpaintRegionPanel } from "../inpaint/InpaintRegionPanel";
|
||||
|
||||
const architectures = [
|
||||
{ value: "sdxl", label: "SDXL" },
|
||||
@@ -30,11 +33,6 @@ const sizePresets = [
|
||||
{ label: "9:16", w: 768, h: 1344 },
|
||||
] as const;
|
||||
|
||||
const inpaintPolarityOptions = [
|
||||
{ value: "hidden", label: "Hidden / erased" },
|
||||
{ value: "revealed", label: "Revealed / painted" },
|
||||
] satisfies readonly BottomControlSelectOption<GenerateSettings["inpaint"]["maskPolarity"]>[];
|
||||
|
||||
const inpaintMaskedContentOptions = [
|
||||
{ value: "neutral", label: "Neutral fill" },
|
||||
{ value: "original", label: "Original gray" },
|
||||
@@ -42,13 +40,31 @@ const inpaintMaskedContentOptions = [
|
||||
{ value: "edges", label: "Edge map" },
|
||||
] satisfies readonly BottomControlSelectOption<GenerateSettings["inpaint"]["maskedContent"]>[];
|
||||
|
||||
const inpaintProfileOptions = [
|
||||
{ value: "remove", label: "Remove object" },
|
||||
{ value: "replace", label: "Replace object" },
|
||||
{ value: "repair", label: "Repair detail" },
|
||||
{ value: "material", label: "Change material" },
|
||||
{ value: "reshape", label: "Change shape" },
|
||||
{ value: "custom", label: "Custom" },
|
||||
] satisfies readonly BottomControlSelectOption<GenerateSettings["inpaint"]["profile"]>[];
|
||||
|
||||
const structureControlOptions = [
|
||||
{ value: "none", label: "None" },
|
||||
{ value: "canny", label: "Canny edges" },
|
||||
{ value: "depth", label: "Depth" },
|
||||
{ value: "pose", label: "Pose" },
|
||||
] satisfies readonly BottomControlSelectOption<GenerateSettings["inpaint"]["structureControl"]>[];
|
||||
|
||||
export type GenerateControlsProps = {
|
||||
settings: GenerateSettings;
|
||||
document: ImageDocument;
|
||||
selection: SelectionState;
|
||||
resources: GenerationResourcesState;
|
||||
dispatch: AppStore["dispatch"];
|
||||
};
|
||||
|
||||
export function GenerateControls({ settings, resources, dispatch }: GenerateControlsProps) {
|
||||
export function GenerateControls({ settings, document, selection, resources, dispatch }: GenerateControlsProps) {
|
||||
const comfyOptions = resources.options;
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [outpaintOpen, setOutpaintOpen] = useState(false);
|
||||
@@ -60,6 +76,7 @@ export function GenerateControls({ settings, resources, dispatch }: GenerateCont
|
||||
const samplerOptions = resolveGenerationStringOptions(comfyOptions?.samplers, settings.sampler);
|
||||
const schedulerOptions = resolveGenerationStringOptions(comfyOptions?.schedulers, settings.scheduler);
|
||||
const modeOptions = resolveGenerationModeOptions(settings, comfyOptions, modes);
|
||||
const availableStructureControls = structureControlOptions.filter((option) => option.value === "none" || option.value === settings.inpaint.structureControl || comfyOptions?.structureControls?.includes(option.value));
|
||||
|
||||
useEffect(() => {
|
||||
if (!sizeOpen) return;
|
||||
@@ -84,18 +101,14 @@ export function GenerateControls({ settings, resources, dispatch }: GenerateCont
|
||||
<button
|
||||
key={intent.value}
|
||||
type="button"
|
||||
aria-pressed={settings.mode === intent.mode}
|
||||
className={`border-l-2 px-2.5 py-1.5 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/50 ${settings.mode === intent.mode ? "border-sky-300 bg-sky-300/10 text-sky-100" : "border-transparent text-white/60 hover:border-white/20 hover:text-white"}`}
|
||||
aria-pressed={isIntentActive(intent.value, intent.mode, settings)}
|
||||
className={`border-l-2 px-2.5 py-1.5 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/50 ${isIntentActive(intent.value, intent.mode, settings) ? "border-sky-300 bg-sky-300/10 text-sky-100" : "border-transparent text-white/60 hover:border-white/20 hover:text-white"}`}
|
||||
onClick={() => dispatch(commandIds.toolChooseGenerateIntent, { intent: intent.value })}
|
||||
>
|
||||
<span className="block text-sm font-semibold">{intent.label}</span>
|
||||
<span className={`mt-1 block text-xs leading-4 ${settings.mode === intent.mode ? "text-sky-100/70" : "text-white/40"}`}>{intent.description}</span>
|
||||
<span className={`mt-1 block text-xs leading-4 ${isIntentActive(intent.value, intent.mode, settings) ? "text-sky-100/70" : "text-white/40"}`}>{intent.description}</span>
|
||||
</button>
|
||||
))}
|
||||
<button type="button" disabled className="border-l-2 border-transparent px-2.5 py-1.5 text-left text-white/25" title="Dedicated object removal is not supported by the current backend. Use Replace and describe the desired background.">
|
||||
<span className="block text-sm font-semibold">Remove</span>
|
||||
<span className="mt-1 block text-xs leading-4">Use Replace for now; describe what should fill the area.</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -134,6 +147,9 @@ export function GenerateControls({ settings, resources, dispatch }: GenerateCont
|
||||
<span className="w-10 text-right text-sm text-white">{Math.round(settings.strength)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<PanelNumber label="Results" aria-label="Generation result count" value={settings.batchSize} onValueChange={(batchSize) => dispatch(commandIds.toolSetGenerateSettings, { batchSize })} />
|
||||
<button type="button" className={compactRowButtonClass()} aria-pressed={settings.refinePass} onClick={() => dispatch(commandIds.toolSetGenerateSettings, { refinePass: !settings.refinePass })}><span className={panelLabelClass()}>Detail pass</span><span className="min-w-0 flex-1 text-right text-white">{settings.refinePass ? `${Math.round(settings.refineStrength)}%` : "Off"}</span></button>
|
||||
{settings.refinePass ? <PanelNumber label="Detail strength" aria-label="Generation detail pass strength" value={settings.refineStrength} onValueChange={(refineStrength) => dispatch(commandIds.toolSetGenerateSettings, { refineStrength })} /> : null}
|
||||
</section>
|
||||
|
||||
<section className={panelSectionClass()}>
|
||||
@@ -184,6 +200,7 @@ export function GenerateControls({ settings, resources, dispatch }: GenerateCont
|
||||
</section> : null}
|
||||
|
||||
{settings.mode === "inpaint" ? <section className={panelSectionClass()}>
|
||||
<InpaintRegionPanel document={document} selection={selection} settings={settings} dispatch={dispatch} />
|
||||
<button type="button" className={sectionToggleClass()} aria-expanded={inpaintOpen} aria-controls="generate-inpaint-controls" onClick={() => setInpaintOpen((open) => !open)}>
|
||||
<span>
|
||||
<span className="block text-sm font-semibold text-white/85">Inpaint</span>
|
||||
@@ -192,13 +209,7 @@ export function GenerateControls({ settings, resources, dispatch }: GenerateCont
|
||||
{inpaintOpen ? <CaretUp size={18} weight="bold" /> : <CaretDown size={18} weight="bold" />}
|
||||
</button>
|
||||
<div id="generate-inpaint-controls" className={`grid gap-2 overflow-hidden transition-all duration-200 ${inpaintOpen ? "max-h-[38rem] pt-2 opacity-100" : "max-h-0 opacity-0"}`}>
|
||||
<PanelSelect
|
||||
label="Polarity"
|
||||
value={settings.inpaint.maskPolarity}
|
||||
options={inpaintPolarityOptions}
|
||||
ariaLabel="Inpaint mask polarity"
|
||||
onValueChange={(maskPolarity) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { ...settings.inpaint, maskPolarity } })}
|
||||
/>
|
||||
<PanelSelect label="Intent" value={settings.inpaint.profile} options={inpaintProfileOptions} ariaLabel="Inpaint intent" onValueChange={(profile) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { profile } })} />
|
||||
<PanelSelect
|
||||
label="Content"
|
||||
value={settings.inpaint.maskedContent}
|
||||
@@ -206,10 +217,20 @@ export function GenerateControls({ settings, resources, dispatch }: GenerateCont
|
||||
ariaLabel="Inpaint masked content"
|
||||
onValueChange={(maskedContent) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { ...settings.inpaint, maskedContent } })}
|
||||
/>
|
||||
<PanelSelect label="Structure" value={settings.inpaint.structureControl} options={availableStructureControls} ariaLabel="Inpaint structure control" onValueChange={(structureControl) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { structureControl, profile: "custom" } })} />
|
||||
{settings.inpaint.structureControl !== "none" ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<PanelNumber label="Control" aria-label="Structure control strength percent" value={Math.round(settings.inpaint.controlStrength * 100)} onValueChange={(controlStrength) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { controlStrength: controlStrength / 100, profile: "custom" } })} />
|
||||
<PanelSelect label="Control model" value={settings.inpaint.controlModel} options={resolveGenerationStringOptions(comfyOptions?.controlModels, settings.inpaint.controlModel)} ariaLabel="Inpaint control model" onValueChange={(controlModel) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { controlModel } })} />
|
||||
</div>
|
||||
) : null}
|
||||
<button type="button" className={compactRowButtonClass()} aria-pressed={settings.inpaint.maskedAreaOnly} onClick={() => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { ...settings.inpaint, maskedAreaOnly: !settings.inpaint.maskedAreaOnly } })}>
|
||||
<span className={panelLabelClass()}>Frame</span>
|
||||
<span className="min-w-0 flex-1 text-right text-white">{settings.inpaint.maskedAreaOnly ? "Mask crop" : "Full layer"}</span>
|
||||
</button>
|
||||
<button type="button" className={compactRowButtonClass()} aria-pressed={settings.inpaint.colorMatch} onClick={() => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { colorMatch: !settings.inpaint.colorMatch, profile: "custom" } })}>
|
||||
<span className={panelLabelClass()}>Seam color</span><span className="min-w-0 flex-1 text-right text-white">{settings.inpaint.colorMatch ? "Match boundary" : "Preserve result"}</span>
|
||||
</button>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<PanelNumber label="Pad" aria-label="Inpaint crop padding" value={settings.inpaint.cropPadding} onValueChange={(cropPadding) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { ...settings.inpaint, cropPadding } })} />
|
||||
<PanelNumber label="Grow" aria-label="Inpaint backend grow mask" value={settings.inpaint.growMaskBy} onValueChange={(growMaskBy) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { ...settings.inpaint, growMaskBy } })} />
|
||||
@@ -227,10 +248,18 @@ export function GenerateControls({ settings, resources, dispatch }: GenerateCont
|
||||
const intentOptions: ReadonlyArray<{ value: GenerateIntent; mode: GenerateMode; label: string; description: string }> = [
|
||||
{ value: "create", mode: "text-to-image", label: "Create", description: "Make a new image from your prompt." },
|
||||
{ value: "replace", mode: "inpaint", label: "Replace", description: "Regenerate the masked part of one layer." },
|
||||
{ value: "remove", mode: "inpaint", label: "Remove", description: "Erase an object and reconstruct its background." },
|
||||
{ value: "extend", mode: "outpaint", label: "Extend", description: "Grow one selected image beyond its edges." },
|
||||
{ value: "variations", mode: "image-to-image", label: "Variations", description: "Explore alternatives based on one image." },
|
||||
];
|
||||
|
||||
function isIntentActive(intent: GenerateIntent, mode: GenerateMode, settings: GenerateSettings) {
|
||||
if (settings.mode !== mode) return false;
|
||||
if (intent === "remove") return settings.inpaint.profile === "remove";
|
||||
if (intent === "replace") return settings.inpaint.profile !== "remove";
|
||||
return true;
|
||||
}
|
||||
|
||||
function SectionTitle({ title }: { title: string }) {
|
||||
return <div className="px-1 text-xs font-semibold uppercase tracking-[0.18em] text-white/35">{title}</div>;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,6 @@ export function canvasCursorClass(interactionMode: InteractionMode, isPanning: b
|
||||
if (!canBrush) return "cursor-not-allowed";
|
||||
return hasBrushPreview ? "cursor-none" : "cursor-crosshair";
|
||||
}
|
||||
if (interactionMode.type === "tool" && interactionMode.tool === "magicWand") return "cursor-crosshair";
|
||||
if (interactionMode.type === "tool" && (interactionMode.tool === "magicWand" || interactionMode.tool === "semanticSelect" || interactionMode.tool === "maskLasso" || interactionMode.tool === "maskRectangle")) return "cursor-crosshair";
|
||||
return "cursor-default";
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ const visualEditorChanges: Array<[string, (state: AppState) => AppState]> = [
|
||||
...state,
|
||||
editor: {
|
||||
...state.editor,
|
||||
maskEdit: { targetLayerId: "target", maskLayerId: "mask", viewMode: "overlay" },
|
||||
maskEdit: { kind: "layerMask", targetLayerId: "target", maskLayerId: "mask", maskAssetId: "mask-asset", viewMode: "overlay" },
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -23,6 +23,7 @@ function visualEditorStatesEqual(a: EditorState, b: EditorState): boolean {
|
||||
maskEditStatesEqual(a.maskEdit, b.maskEdit) &&
|
||||
brushPreviewStatesEqual(a.brushPreview, b.brushPreview) &&
|
||||
brushStrokePreviewStatesEqual(a.brushStrokePreview, b.brushStrokePreview) &&
|
||||
a.maskShapeSession === b.maskShapeSession &&
|
||||
generationStatesEqual(a.generation, b.generation) &&
|
||||
visualToolStatesEqual(a.tools, b.tools)
|
||||
);
|
||||
@@ -50,7 +51,7 @@ function transformTargetsEqual(a: TransformTarget, b: TransformTarget): boolean
|
||||
function maskEditStatesEqual(a: MaskEditState | undefined, b: MaskEditState | undefined): boolean {
|
||||
if (a === b) return true;
|
||||
if (!a || !b) return false;
|
||||
return a.targetLayerId === b.targetLayerId && a.maskLayerId === b.maskLayerId && a.viewMode === b.viewMode;
|
||||
return a.kind === b.kind && a.targetLayerId === b.targetLayerId && a.maskAssetId === b.maskAssetId && a.maskLayerId === b.maskLayerId && a.inpaintRegionId === b.inpaintRegionId && a.viewMode === b.viewMode;
|
||||
}
|
||||
|
||||
function brushPreviewStatesEqual(a: BrushPreviewState | undefined, b: BrushPreviewState | undefined): boolean {
|
||||
@@ -80,7 +81,7 @@ function interactionModesEqual(a: InteractionMode, b: InteractionMode): boolean
|
||||
}
|
||||
|
||||
function brushSettingsEqual(a: BrushSettings, b: BrushSettings): boolean {
|
||||
return a.color === b.color && a.size === b.size && a.hardness === b.hardness;
|
||||
return a.color === b.color && a.size === b.size && a.hardness === b.hardness && a.opacity === b.opacity && a.flow === b.flow && a.smoothing === b.smoothing && a.pressureSize === b.pressureSize;
|
||||
}
|
||||
|
||||
function vec2Equal(a: Vec2D, b: Vec2D): boolean {
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
} from "@input/index";
|
||||
import { beginBrushSession, canPreviewBrush, cancelBrushSession, commitBrushSession, updateBrushSession, type BrushSession } from "@operations/paint/brush";
|
||||
import { applyMagicWandAt } from "@operations/masks/magic-wand";
|
||||
import { commitInpaintLasso } from "@operations/masks/lasso";
|
||||
import { applySemanticSelectionAt } from "@operations/masks/semantic-select";
|
||||
|
||||
export type CanvasInputOptions = {
|
||||
globalKeybindConsumer: GlobalKeybindConsumer;
|
||||
@@ -122,6 +124,12 @@ export function useCanvasInput(
|
||||
|
||||
const state = store.getState();
|
||||
const documentPoint = viewportPointToDocumentPoint(inputEvent.position, state.editor.viewport);
|
||||
if (!isOperationWorkspacePanel(state.editor.workspace.panel) && (state.editor.tools.activeTool === "maskLasso" || state.editor.tools.activeTool === "maskRectangle") && state.editor.maskEdit?.kind === "inpaintRegion" && (inputEvent.buttons & 1) === 1) {
|
||||
store.dispatch(commandIds.toolBeginMaskShape, { point: documentPoint, mode: inputEvent.shiftKey ? "add" : inputEvent.altKey ? "subtract" : "replace" });
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
const brush = !isOperationWorkspacePanel(state.editor.workspace.panel) && (inputEvent.buttons & 1) === 1 && !isPanInteractionMode(state.editor.tools.interactionMode)
|
||||
? beginBrushSession(state.document, state.editor, documentPoint)
|
||||
: undefined;
|
||||
@@ -139,6 +147,11 @@ export function useCanvasInput(
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (!isOperationWorkspacePanel(state.editor.workspace.panel) && state.editor.tools.activeTool === "semanticSelect") {
|
||||
void applySemanticSelectionAt(store, documentPoint, inputEvent.shiftKey ? "add" : inputEvent.altKey ? "subtract" : "replace");
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentState = store.getState();
|
||||
const selectionToolActive = currentState.editor.tools.activeTool === "select";
|
||||
@@ -153,6 +166,12 @@ export function useCanvasInput(
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const inputEvent = pointerInputEventFromPointerEvent(event);
|
||||
if (store.getState().editor.maskShapeSession) {
|
||||
const point = viewportPointToDocumentPoint(inputEvent.position, store.getState().editor.viewport);
|
||||
store.dispatch(commandIds.toolAppendMaskShape, { point });
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (brushSession.current) {
|
||||
if ((inputEvent.buttons & 1) !== 1 || isPanInteractionMode(store.getState().editor.tools.interactionMode)) {
|
||||
commitActiveBrushSession(inputEvent.position);
|
||||
@@ -163,7 +182,7 @@ export function useCanvasInput(
|
||||
const point = viewportPointToDocumentPoint(inputEvent.position, store.getState().editor.viewport);
|
||||
store.dispatch(commandIds.toolSetBrushPreview, { position: point });
|
||||
const settings = store.getState().editor.tools.brush;
|
||||
brushSession.current = updateBrushSession({ store, session: brushSession.current, point, color: settings.color, size: settings.size, hardness: settings.hardness });
|
||||
brushSession.current = updateBrushSession({ store, session: brushSession.current, point, color: settings.color, size: settings.size, hardness: settings.hardness, opacity: settings.opacity, flow: settings.flow, smoothing: settings.smoothing, pressure: inputEvent.pressure ?? 1, pressureSize: settings.pressureSize });
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
@@ -187,6 +206,13 @@ export function useCanvasInput(
|
||||
|
||||
const handlePointerUp = (event: PointerEvent) => {
|
||||
const inputEvent = pointerInputEventFromPointerEvent(event);
|
||||
if (store.getState().editor.maskShapeSession) {
|
||||
const point = viewportPointToDocumentPoint(inputEvent.position, store.getState().editor.viewport);
|
||||
store.dispatch(commandIds.toolAppendMaskShape, { point });
|
||||
void commitInpaintLasso(store);
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (brushSession.current) {
|
||||
commitActiveBrushSession(inputEvent.position);
|
||||
event.preventDefault();
|
||||
|
||||
25
view/inpaint/CandidateReviewPanel.tsx
Normal file
25
view/inpaint/CandidateReviewPanel.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Star } from "@phosphor-icons/react";
|
||||
import { commandIds } from "@commands/ids";
|
||||
import type { GenerationState } from "@editor/state";
|
||||
import type { AppStore } from "@editor/store";
|
||||
|
||||
export function CandidateReviewPanel({ generation, dispatch }: { generation: GenerationState; dispatch: AppStore["dispatch"] }) {
|
||||
if (generation.candidates.length === 0) return null;
|
||||
const selectedId = generation.selectedCandidateId ?? generation.candidates[0]?.id;
|
||||
return (
|
||||
<section className="grid gap-2 border-b border-white/[0.07] pb-3">
|
||||
<div className="flex items-center justify-between px-1"><strong className="text-sm text-white/85">Results</strong><span className="text-xs text-white/35">{generation.candidates.length} candidates</span></div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{generation.candidates.map((candidate) => (
|
||||
<div key={candidate.id} className={`group relative overflow-hidden rounded-lg border ${candidate.id === selectedId ? "border-sky-300" : "border-white/10"}`}>
|
||||
<button type="button" className="block w-full" onClick={() => dispatch(commandIds.generationSelectCandidate, { candidateId: candidate.id })}>
|
||||
<img src={candidate.source} alt={`Candidate seed ${candidate.seed}`} className="aspect-square w-full bg-black/25 object-cover" />
|
||||
<span className="flex items-center justify-between px-2 py-1 text-[0.65rem] text-white/45"><span>Seed {candidate.seed}</span><span>{candidate.settings.inpaint.profile}</span></span>
|
||||
</button>
|
||||
<button type="button" aria-label={candidate.favorite ? "Remove favorite" : "Favorite candidate"} aria-pressed={candidate.favorite} className={`absolute right-1.5 top-1.5 rounded-md bg-black/65 p-1.5 ${candidate.favorite ? "text-amber-300" : "text-white/60 opacity-0 group-hover:opacity-100"}`} onClick={() => dispatch(commandIds.generationToggleCandidateFavorite, { candidateId: candidate.id })}><Star size={16} weight={candidate.favorite ? "fill" : "regular"} /></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
86
view/inpaint/InpaintRegionPanel.tsx
Normal file
86
view/inpaint/InpaintRegionPanel.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
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>;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { Asset } from "@core/asset";
|
||||
import type { AppStore } from "@editor/store";
|
||||
import { analyzeMask, runMaskOperation, type MaskAnalysis, type MaskRasterOperation } from "@operations/masks/rasterActions";
|
||||
|
||||
export function MaskStatus({ asset }: { asset: Asset }) {
|
||||
export function MaskStatus({ asset, purpose = "visibility" }: { asset: Asset; purpose?: "visibility" | "inpaint" }) {
|
||||
const [analysis, setAnalysis] = useState<MaskAnalysis>();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -24,8 +24,7 @@ export function MaskStatus({ asset }: { asset: Asset }) {
|
||||
return (
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5 rounded-md bg-white/[0.04] px-1.5 py-1 text-[0.68rem] text-sky-100/60">
|
||||
<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>
|
||||
{purpose === "inpaint" ? <span className="whitespace-nowrap">Replace {formatPercent(analysis.coverage)}</span> : <><span className="whitespace-nowrap">Reveal {formatPercent(analysis.coverage)}</span><span className="whitespace-nowrap text-sky-100/45">Hidden {formatPercent(analysis.hiddenCoverage)}</span></>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ describe("layer thumbnail read model", () => {
|
||||
test("indexes nested group previews once for row lookup", () => {
|
||||
const child = { ...base("child-group", "Child"), type: "group" as const, children: [raster("nested", "asset-b")] };
|
||||
const group = { ...base("root", "Root"), type: "group" as const, children: [child] };
|
||||
const document = { id: "doc", version: 1, name: "Doc", assets: [...assets.values()], artboards: [{ id: "artboard", name: "Artboard", bounds: { x: 0, y: 0, w: 100, h: 100 }, backgroundColor: "#000000", visible: true, locked: false, layers: [group] }] };
|
||||
const document = { id: "doc", version: 1, name: "Doc", assets: [...assets.values()], inpaintRegions: [], artboards: [{ id: "artboard", name: "Artboard", bounds: { x: 0, y: 0, w: 100, h: 100 }, backgroundColor: "#000000", visible: true, locked: false, layers: [group] }] };
|
||||
const index = createLayerThumbnailIndex(document, assets);
|
||||
expect(index.get("nested")?.kind).toBe("raster");
|
||||
expect(index.get("child-group")).toMatchObject({ kind: "group", previews: [{ source: "blob:b" }] });
|
||||
|
||||
@@ -345,6 +345,12 @@ function toolIcon(tool: ToolId) {
|
||||
return <Eraser size={20} />;
|
||||
case "magicWand":
|
||||
return <MagicWand size={20} />;
|
||||
case "semanticSelect":
|
||||
return <MagicWand size={20} />;
|
||||
case "maskLasso":
|
||||
return <MagicWand size={20} />;
|
||||
case "maskRectangle":
|
||||
return <MagicWand size={20} />;
|
||||
case "pan":
|
||||
return <Hand size={20} />;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,12 @@ export function labelForTool(tool: ToolId): string {
|
||||
return "Brush";
|
||||
case "magicWand":
|
||||
return "Magic wand";
|
||||
case "semanticSelect":
|
||||
return "AI object select";
|
||||
case "maskLasso":
|
||||
return "AI region lasso";
|
||||
case "maskRectangle":
|
||||
return "AI region rectangle";
|
||||
case "eraser":
|
||||
return "Eraser";
|
||||
case "pan":
|
||||
|
||||
Reference in New Issue
Block a user