feat: unify AI edit workflow

This commit is contained in:
syntaxbullet
2026-07-11 11:58:16 +02:00
parent 49b029d60a
commit bef2fb3051
12 changed files with 173 additions and 41 deletions

View File

@@ -20,6 +20,7 @@ export function GenerateActionControls({ settings, generation, dispatch, workflo
const precondition = workflow.precondition();
const canGenerate = precondition.ready && !busy;
const preconditionMessage = precondition.ready ? undefined : precondition.message;
const repair = precondition.ready ? undefined : precondition.repair;
return (
<div className="flex max-w-[calc(100vw-2rem)] flex-wrap items-center justify-center gap-2 px-2">
@@ -34,7 +35,16 @@ export function GenerateActionControls({ settings, generation, dispatch, workflo
>
{busy && job?.kind === "generate" ? "Generating..." : "Generate"}
</button>
{preconditionMessage ? <span className="max-w-72 text-xs text-white/55" role="status">{preconditionMessage}</span> : null}
{preconditionMessage ? (
<span className="flex max-w-80 items-center gap-2 rounded-full bg-amber-300/10 px-3 py-2 text-xs text-amber-100/75" role="status">
<span>{preconditionMessage}</span>
{repair === "add-mask" ? (
<button type="button" className="shrink-0 rounded-full bg-white px-3 py-1 font-semibold text-black" onClick={() => void workflow.prepareInpaintMask()}>Add mask</button>
) : repair === "set-outpaint-padding" ? (
<button type="button" className="shrink-0 rounded-full bg-white px-3 py-1 font-semibold text-black" onClick={() => dispatch(commandIds.toolSetGenerateSettings, { outpaint: { ...settings.outpaint, left: 128, right: 128 } })}>Add padding</button>
) : null}
</span>
) : null}
<GenerationJobStatus generation={generation} />
{busy ? <button type="button" className="h-9 rounded-full bg-white/5 px-3 text-xs font-semibold text-white/70 hover:bg-white/10 hover:text-white" onClick={workflow.cancel}>Cancel</button> : null}
{candidate ? (
@@ -56,25 +66,26 @@ export function GenerateActionControls({ settings, generation, dispatch, workflo
function CandidatePicker({ generation, dispatch }: { generation: GenerationState; dispatch: AppStore["dispatch"] }) {
return (
<div className="subtle-scrollbar flex max-w-[min(28rem,calc(100vw-2rem))] items-center gap-1 overflow-x-auto rounded-full bg-white/[0.04] px-1.5 py-1 ring-1 ring-white/[0.05]" role="group" aria-label="Generation candidates">
<div className="subtle-scrollbar flex max-w-[min(34rem,calc(100vw-2rem))] items-stretch gap-2 overflow-x-auto rounded-[1.25rem] bg-black/20 p-2 ring-1 ring-white/[0.08]" role="group" aria-label="Provisional generation candidates">
{generation.candidates.map((candidate) => {
const selected = candidate.id === (generation.selectedCandidateId ?? generation.candidates[0]?.id);
return (
<button
key={candidate.id}
type="button"
className={`h-9 w-9 overflow-hidden rounded-full ring-2 transition ${selected ? "ring-white" : "ring-white/10 hover:ring-white/40"}`}
title={`Candidate seed ${candidate.seed}`}
className={`relative h-16 w-16 shrink-0 overflow-hidden rounded-xl ring-2 transition ${selected ? "ring-white" : "ring-white/10 hover:ring-white/40"}`}
title={`Provisional candidate · seed ${candidate.seed}`}
onClick={() => dispatch(commandIds.generationSelectCandidate, { candidateId: candidate.id })}
>
<img src={candidate.source} alt="" className="h-full w-full object-cover" />
{selected ? <span className="absolute inset-x-1 bottom-1 rounded-full bg-black/70 px-1 py-0.5 text-[0.6rem] font-semibold text-white">Reviewing</span> : null}
</button>
);
})}
<button
type="button"
disabled={generation.candidates.length === 0}
className="h-9 shrink-0 rounded-full px-3 text-xs font-semibold text-white/55 transition hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-35"
className="min-h-16 shrink-0 rounded-xl px-3 text-xs font-semibold text-white/55 transition hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-35"
title="Dismiss every candidate and reset comparison"
onClick={() => dispatch(commandIds.generationClearCandidates, undefined)}
>
@@ -105,11 +116,9 @@ function CandidateControls({
const disabled = busy;
return (
<div className="flex flex-wrap items-center justify-center gap-1 rounded-full bg-white/[0.04] px-2 py-1 ring-1 ring-white/[0.05]">
<div className="flex flex-wrap items-center justify-center gap-1 rounded-[1.25rem] bg-white/[0.04] px-2 py-2 ring-1 ring-white/[0.05]">
<CandidatePreview candidate={candidate} />
<span className="px-2 text-xs font-medium text-white/55" title={`${candidate.settings.model} · ${candidate.mode}`}>
Seed {candidate.seed}
</span>
<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

View File

@@ -3,7 +3,7 @@ import { CaretDown, CaretUp } from "@phosphor-icons/react";
import { commandIds } from "@commands/ids";
import type { AppStore } from "@editor/store";
import type { GenerationResourcesState } from "@editor/state";
import type { GenerateArchitecture, GenerateMode, GenerateSettings } from "@editor/tools";
import type { GenerateArchitecture, GenerateIntent, GenerateMode, GenerateSettings } from "@editor/tools";
import { resolveGenerationModeOptions, resolveGenerationModelOptions, resolveGenerationStringOptions, resolveGenerationSupportOptions } from "@operations/generation/options";
import { BottomControlSelectMenu, type BottomControlSelectOption } from "./SelectMenu";
import { BottomControlSlider } from "./Slider";
@@ -74,6 +74,31 @@ export function GenerateControls({ settings, resources, dispatch }: GenerateCont
<div className="grid gap-5 pb-2 tabular-nums">
{resources.error ? <p className="rounded-full bg-red-500/10 px-3 py-2 text-xs text-red-200">{resources.error}</p> : null}
<section className={panelSectionClass()}>
<div className="px-1">
<h2 className="text-base font-semibold text-white/90">Edit with AI</h2>
<p className="mt-1 text-xs leading-5 text-white/45">Choose an outcome. Image Studio configures the matching generation workflow.</p>
</div>
<div className="grid grid-cols-2 gap-2">
{intentOptions.map((intent) => (
<button
key={intent.value}
type="button"
aria-pressed={settings.mode === intent.mode}
className={`rounded-[1.25rem] px-3 py-3 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30 ${settings.mode === intent.mode ? "bg-white text-black" : "bg-white/[0.05] text-white/75 hover:bg-white/10"}`}
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-black/55" : "text-white/40"}`}>{intent.description}</span>
</button>
))}
<button type="button" disabled className="rounded-[1.25rem] bg-white/[0.025] px-3 py-3 text-left text-white/30" 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>
<section className={panelSectionClass()}>
<label className="grid gap-2">
<span className={panelLabelClass()}>Prompt</span>
@@ -101,43 +126,45 @@ export function GenerateControls({ settings, resources, dispatch }: GenerateCont
<section className={panelSectionClass()}>
<SectionTitle title="Essentials" />
<PanelSelect label="Backend" value={settings.architecture} options={architectures} ariaLabel="Generate backend" onValueChange={(architecture) => dispatch(commandIds.toolSetGenerateSettings, { architecture })} />
<PanelSelect label="Model" value={settings.model} options={modelOptions} ariaLabel="Generate model" onValueChange={(model) => dispatch(commandIds.toolSetGenerateSettings, { model })} />
{settings.architecture !== "sdxl" ? (
<>
<PanelSelect label="Text enc." value={settings.textEncoder} options={supportOptions.textEncoders} ariaLabel="Generate text encoder" onValueChange={(textEncoder) => dispatch(commandIds.toolSetGenerateSettings, { textEncoder })} />
<PanelSelect label="VAE" value={settings.vae} options={supportOptions.vaes} ariaLabel="Generate VAE" onValueChange={(vae) => dispatch(commandIds.toolSetGenerateSettings, { vae })} />
</>
) : null}
<SizeControl refRoot={sizeRef} open={sizeOpen} setOpen={setSizeOpen} settings={settings} dispatch={dispatch} />
<PanelNumber label="Seed" aria-label="Generate seed" min={-1} max={Number.MAX_SAFE_INTEGER} value={settings.seed} onValueChange={(seed) => dispatch(commandIds.toolSetGenerateSettings, { seed })} />
{settings.mode === "image-to-image" || settings.mode === "inpaint" ? (
<div className={panelRowClass()}>
<span className={panelLabelClass()}>Influence</span>
<BottomControlSlider min={0} max={100} value={settings.strength} className="w-32" aria-label="Generate strength" onValueChange={(strength) => dispatch(commandIds.toolSetGenerateSettings, { strength })} />
<span className="w-10 text-right text-sm text-white">{Math.round(settings.strength)}</span>
</div>
) : null}
</section>
<section className={panelSectionClass()}>
<button type="button" className={sectionToggleClass()} aria-expanded={advancedOpen} aria-controls="generate-advanced-controls" onClick={() => setAdvancedOpen((open) => !open)}>
<span>
<span className="block text-sm font-semibold text-white/85">Advanced</span>
<span className="block text-xs text-white/40">Mode, sampler, scheduler, steps, CFG, strength</span>
<span className="block text-xs text-white/40">Model, backend, seed, sampling, and workflow internals</span>
</span>
{advancedOpen ? <CaretUp size={18} weight="bold" /> : <CaretDown size={18} weight="bold" />}
</button>
<div id="generate-advanced-controls" className={`grid gap-2 overflow-hidden transition-all duration-200 ${advancedOpen ? "max-h-[32rem] pt-2 opacity-100" : "max-h-0 opacity-0"}`}>
<PanelSelect label="Mode" value={settings.mode} options={modeOptions} ariaLabel="Generate mode" onValueChange={(mode) => dispatch(commandIds.toolSetGenerateSettings, { mode })} />
<PanelSelect label="Backend" value={settings.architecture} options={architectures} ariaLabel="Generate backend" onValueChange={(architecture) => dispatch(commandIds.toolSetGenerateSettings, { architecture })} />
<PanelSelect label="Model" value={settings.model} options={modelOptions} ariaLabel="Generate model" onValueChange={(model) => dispatch(commandIds.toolSetGenerateSettings, { model })} />
{settings.architecture !== "sdxl" ? (
<>
<PanelSelect label="Text enc." value={settings.textEncoder} options={supportOptions.textEncoders} ariaLabel="Generate text encoder" onValueChange={(textEncoder) => dispatch(commandIds.toolSetGenerateSettings, { textEncoder })} />
<PanelSelect label="VAE" value={settings.vae} options={supportOptions.vaes} ariaLabel="Generate VAE" onValueChange={(vae) => dispatch(commandIds.toolSetGenerateSettings, { vae })} />
</>
) : null}
<PanelNumber label="Seed" aria-label="Generate seed" min={-1} max={Number.MAX_SAFE_INTEGER} value={settings.seed} onValueChange={(seed) => dispatch(commandIds.toolSetGenerateSettings, { seed })} />
<PanelSelect label="Sampler" value={settings.sampler} options={samplerOptions} ariaLabel="Generate sampler" onValueChange={(sampler) => dispatch(commandIds.toolSetGenerateSettings, { sampler })} />
<PanelSelect label="Scheduler" value={settings.scheduler} options={schedulerOptions} ariaLabel="Generate scheduler" onValueChange={(scheduler) => dispatch(commandIds.toolSetGenerateSettings, { scheduler })} />
<div className="grid grid-cols-2 gap-2">
<PanelNumber label="Steps" aria-label="Generate steps" value={settings.steps} onValueChange={(steps) => dispatch(commandIds.toolSetGenerateSettings, { steps })} />
<PanelNumber label="CFG" aria-label="Generate CFG" value={settings.cfg} onValueChange={(cfg) => dispatch(commandIds.toolSetGenerateSettings, { cfg })} />
</div>
<div className={panelRowClass()}>
<span className={panelLabelClass()}>Strength</span>
<BottomControlSlider min={0} max={100} value={settings.strength} className="w-32" aria-label="Generate strength" onValueChange={(strength) => dispatch(commandIds.toolSetGenerateSettings, { strength })} />
<span className="w-10 text-right text-sm text-white">{Math.round(settings.strength)}</span>
</div>
</div>
</section>
<section className={panelSectionClass()}>
{settings.mode === "outpaint" ? <section className={panelSectionClass()}>
<button type="button" className={sectionToggleClass()} aria-expanded={outpaintOpen} aria-controls="generate-outpaint-controls" onClick={() => setOutpaintOpen((open) => !open)}>
<span>
<span className="block text-sm font-semibold text-white/85">Outpaint</span>
@@ -154,9 +181,9 @@ export function GenerateControls({ settings, resources, dispatch }: GenerateCont
</div>
<PanelNumber label="Feather" aria-label="Outpaint feathering" value={settings.outpaint.feathering} onValueChange={(feathering) => dispatch(commandIds.toolSetGenerateSettings, { outpaint: { ...settings.outpaint, feathering } })} />
</div>
</section>
</section> : null}
<section className={panelSectionClass()}>
{settings.mode === "inpaint" ? <section className={panelSectionClass()}>
<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,11 +219,18 @@ export function GenerateControls({ settings, resources, dispatch }: GenerateCont
<PanelNumber label="Clean" aria-label="Inpaint mask despeckle" value={settings.inpaint.maskDespeckle} max={64} onValueChange={(maskDespeckle) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { ...settings.inpaint, maskDespeckle } })} />
</div>
</div>
</section>
</section> : null}
</div>
);
}
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: "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 SectionTitle({ title }: { title: string }) {
return <div className="px-1 text-xs font-semibold uppercase tracking-[0.18em] text-white/35">{title}</div>;
}