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:
syntaxbullet
2026-07-11 16:41:22 +02:00
parent f4e13b80e7
commit ff762b8f17
78 changed files with 1632 additions and 301 deletions

View File

@@ -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>;
}