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