feat: implement generation workflow with resource loading and candidate management

This commit is contained in:
syntaxbullet
2026-07-10 23:54:52 +02:00
parent 9697075d29
commit 51a54dbdb2
9 changed files with 308 additions and 117 deletions

View File

@@ -1,29 +1,22 @@
import { commandIds } from "@commands/ids";
import type { ImageDocument } from "@core/document";
import type { GenerationCandidate, GenerationCompareMode, GenerationState, SelectionState, ViewportState } from "@editor/state";
import type { GenerationCandidate, GenerationCompareMode, GenerationState } from "@editor/state";
import type { GenerateSettings } from "@editor/tools";
import type { AppStore } from "@editor/store";
import { createMaskedPixelReplacementSource } from "@operations/generation/candidateActions";
import { runGenerate, runGenerateFromCandidate } from "@operations/generation/runGenerate";
import { runGenerationJob } from "@operations/generation/generationJob";
import type { GenerationWorkflow } from "@operations/generation/workflow";
import { currentGenerationJob, GenerationJobStatus } from "../GenerationJobStatus";
import { createRefinementMask } from "@operations/masks/rasterActions";
import { checkGenerationPreconditions } from "@operations/generation/preconditions";
export type GenerateActionControlsProps = {
document: ImageDocument;
selection: SelectionState;
viewport: ViewportState;
settings: GenerateSettings;
generation: GenerationState;
dispatch: AppStore["dispatch"];
workflow: GenerationWorkflow;
};
export function GenerateActionControls({ document, selection, viewport, settings, generation, dispatch }: GenerateActionControlsProps) {
export function GenerateActionControls({ settings, generation, dispatch, workflow }: GenerateActionControlsProps) {
const job = currentGenerationJob(generation);
const busy = job?.status === "running";
const candidate = selectedCandidate(generation);
const precondition = checkGenerationPreconditions(document, selection, settings);
const precondition = workflow.precondition();
const canGenerate = precondition.ready && !busy;
const preconditionMessage = precondition.ready ? undefined : precondition.message;
@@ -35,7 +28,7 @@ export function GenerateActionControls({ document, selection, viewport, settings
className="h-12 rounded-full bg-white px-7 text-base font-semibold !text-black transition hover:bg-white/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/40 disabled:pointer-events-none disabled:opacity-35"
title={job?.status === "failed" ? job.error : preconditionMessage ?? "Generate with ComfyUI"}
onClick={() => {
void runGenerationJob({ kind: "generate", label: "Generating", dispatch, task: () => runGenerate({ document, selection, viewport, settings, dispatch }) });
void workflow.generate();
}}
>
{busy && job?.kind === "generate" ? "Generating..." : "Generate"}
@@ -46,12 +39,12 @@ export function GenerateActionControls({ document, selection, viewport, settings
<>
<CandidatePicker generation={generation} dispatch={dispatch} />
<CandidateControls
document={document}
candidate={candidate}
compareMode={generation.compareMode ?? "result"}
settings={settings}
busy={busy}
dispatch={dispatch}
workflow={workflow}
/>
</>
) : null}
@@ -82,23 +75,22 @@ function CandidatePicker({ generation, dispatch }: { generation: GenerationState
}
function CandidateControls({
document,
candidate,
compareMode,
settings,
busy,
dispatch,
workflow,
}: {
document: ImageDocument;
candidate: GenerationCandidate;
compareMode: GenerationCompareMode;
settings: GenerateSettings;
busy: boolean;
dispatch: AppStore["dispatch"];
workflow: GenerationWorkflow;
}) {
const rerun = (label: string, nextSettings: GenerateSettings) => {
dispatch(commandIds.toolSetGenerateSettings, nextSettings);
void runGenerationJob({ kind: "regenerate", label, dispatch, task: () => runGenerateFromCandidate({ candidate, settings: nextSettings, dispatch }) });
void workflow.regenerate(candidate.id, nextSettings, label);
};
const disabled = busy;
@@ -116,13 +108,13 @@ function CandidateControls({
/>
<CandidateButton disabled={disabled} label="Reuse seed" title="Regenerate with the same seed" onClick={() => rerun("Reuse seed", { ...candidate.settings, seed: candidate.seed })} />
<CandidateButton disabled={disabled} label="New seed" title="Regenerate with a new seed" onClick={() => rerun("New seed", { ...candidate.settings, seed: -1 })} />
<CandidateButton disabled={disabled} label="Add as layer" title="Add candidate to the document as a layer" onClick={() => applyCandidateAsLayer(candidate, dispatch)} />
<CandidateButton disabled={disabled} label="Add as layer" title="Add candidate to the document as a layer" onClick={() => workflow.applyCandidateAsLayer(candidate.id)} />
<CandidateButton
disabled={disabled}
label="Add + mask"
title="Add candidate as a layer with a fresh refinement mask"
onClick={() => {
void runGenerationJob({ kind: "refine", label: "Adding refinement mask", dispatch, task: () => applyCandidateAsRefinementLayer(candidate, dispatch) });
void workflow.applyCandidateAsRefinementLayer(candidate.id);
}}
/>
<CandidateButton
@@ -130,10 +122,7 @@ function CandidateControls({
label="Replace pixels"
title={candidate.inpaint ? "Replace masked pixels and preserve unmasked pixels" : "Only inpaint candidates can replace masked pixels"}
onClick={() => {
void runGenerationJob({ kind: "replace", label: "Replacing pixels", dispatch, task: async () => {
const source = await createMaskedPixelReplacementSource(document, candidate);
dispatch(commandIds.generationReplaceCandidatePixels, { candidateId: candidate.id, source, mimeType: "image/png" });
} });
void workflow.replaceCandidatePixels(candidate.id);
}}
/>
<CandidateButton
@@ -213,51 +202,3 @@ function CandidateButton({ label, title, disabled, busy, onClick }: { label: str
function selectedCandidate(generation: GenerationState): GenerationCandidate | undefined {
return generation.candidates.find((candidate) => candidate.id === generation.selectedCandidateId) ?? generation.candidates[0];
}
function applyCandidateAsLayer(candidate: GenerationCandidate, dispatch: AppStore["dispatch"]) {
applyCandidateAsLayerWithIds(candidate, { layerId: crypto.randomUUID(), assetId: crypto.randomUUID() }, dispatch);
}
async function applyCandidateAsRefinementLayer(candidate: GenerationCandidate, dispatch: AppStore["dispatch"]) {
const layerId = crypto.randomUUID();
const maskLayerId = crypto.randomUUID();
const maskAssetId = crypto.randomUUID();
applyCandidateAsLayerWithIds(candidate, { layerId, assetId: crypto.randomUUID() }, dispatch);
const width = Math.max(1, Math.round(candidate.intrinsicSize.w));
const height = Math.max(1, Math.round(candidate.intrinsicSize.h));
const source = await createRefinementMask(width, height);
dispatch(commandIds.documentAddLayerMask, {
layerId,
asset: {
id: maskAssetId,
name: `${candidate.placement.layerName} refinement mask`,
mimeType: "image/png",
source,
intrinsicSize: { w: width, h: height },
},
maskLayer: {
id: maskLayerId,
type: "raster",
name: `${candidate.placement.layerName} refinement mask`,
visible: true,
locked: false,
opacity: 1,
assetId: maskAssetId,
transform: {
position: { ...candidate.placement.transform.position },
scale: { ...candidate.placement.transform.scale },
rotation: candidate.placement.transform.rotation,
},
},
});
dispatch(commandIds.toolSetActive, { tool: "eraser" });
}
function applyCandidateAsLayerWithIds(candidate: GenerationCandidate, ids: { layerId: string; assetId: string }, dispatch: AppStore["dispatch"]) {
dispatch(commandIds.generationApplyCandidateAsLayer, {
candidateId: candidate.id,
assetId: ids.assetId,
layerId: ids.layerId,
});
}