feat: implement generation workflow with resource loading and candidate management
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
import { CaretDown, CaretUp } from "@phosphor-icons/react";
|
||||
import { commandIds } from "@commands/ids";
|
||||
import type { AppStore } from "@editor/store";
|
||||
import type { GenerationOptions, GenerationResourcesState } from "@editor/state";
|
||||
import { generateArchitectureDefaults } from "@editor/tools";
|
||||
import type { GenerateArchitecture, GenerateMode, GenerateModel, GenerateSettings } from "@editor/tools";
|
||||
import type { GenerationResourcesState } from "@editor/state";
|
||||
import type { GenerateArchitecture, GenerateMode, GenerateSettings } from "@editor/tools";
|
||||
import { resolveGenerationModeOptions, resolveGenerationModelOptions, resolveGenerationStringOptions, resolveGenerationSupportOptions } from "@operations/generation/options";
|
||||
import { BottomControlSelectMenu, type BottomControlSelectOption } from "./SelectMenu";
|
||||
import { BottomControlSlider } from "./Slider";
|
||||
|
||||
@@ -55,11 +55,11 @@ export function GenerateControls({ settings, resources, dispatch }: GenerateCont
|
||||
const [inpaintOpen, setInpaintOpen] = useState(false);
|
||||
const [sizeOpen, setSizeOpen] = useState(false);
|
||||
const sizeRef = useRef<HTMLDivElement>(null);
|
||||
const modelOptions = resolveModelOptions(settings, comfyOptions);
|
||||
const supportOptions = resolveSupportOptions(settings, comfyOptions);
|
||||
const samplerOptions = resolveStringOptions(comfyOptions?.samplers, settings.sampler);
|
||||
const schedulerOptions = resolveStringOptions(comfyOptions?.schedulers, settings.scheduler);
|
||||
const modeOptions = resolveModeOptions(settings, comfyOptions);
|
||||
const modelOptions = resolveGenerationModelOptions(settings, comfyOptions);
|
||||
const supportOptions = resolveGenerationSupportOptions(settings, comfyOptions);
|
||||
const samplerOptions = resolveGenerationStringOptions(comfyOptions?.samplers, settings.sampler);
|
||||
const schedulerOptions = resolveGenerationStringOptions(comfyOptions?.schedulers, settings.scheduler);
|
||||
const modeOptions = resolveGenerationModeOptions(settings, comfyOptions, modes);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sizeOpen) return;
|
||||
@@ -197,38 +197,6 @@ export function GenerateControls({ settings, resources, dispatch }: GenerateCont
|
||||
);
|
||||
}
|
||||
|
||||
function resolveModelOptions(settings: GenerateSettings, comfyOptions: GenerationOptions | undefined): readonly BottomControlSelectOption<GenerateModel>[] {
|
||||
const architecture = comfyOptions?.architectures?.find((option) => option.value === settings.architecture);
|
||||
const models = architecture?.models ?? (settings.architecture === "sdxl" ? comfyOptions?.models : undefined) ?? [];
|
||||
const fallbackModel = architecture?.defaultModel ?? generateArchitectureDefaults[settings.architecture].model;
|
||||
const values = unique(["auto", ...models, ...(models.length === 0 && fallbackModel !== "auto" ? [fallbackModel] : []), settings.model]);
|
||||
return values.map((model) => ({ value: model, label: model === "auto" ? "Auto" : model }));
|
||||
}
|
||||
|
||||
function resolveSupportOptions(settings: GenerateSettings, comfyOptions: GenerationOptions | undefined) {
|
||||
const defaults = generateArchitectureDefaults[settings.architecture];
|
||||
return {
|
||||
textEncoders: resolveStringOptions([...(comfyOptions?.textEncoders ?? []), defaults.textEncoder].filter((value) => value !== "auto"), settings.textEncoder),
|
||||
vaes: resolveStringOptions([...(comfyOptions?.vaes ?? []), defaults.vae].filter((value) => value !== "auto"), settings.vae),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveStringOptions(values: string[] | undefined, current: string): readonly BottomControlSelectOption<string>[] {
|
||||
return unique([...(values ?? []), current]).map((value) => ({ value, label: value }));
|
||||
}
|
||||
|
||||
function resolveModeOptions(settings: GenerateSettings, comfyOptions: GenerationOptions | undefined): readonly BottomControlSelectOption<GenerateMode>[] {
|
||||
const architecture = comfyOptions?.architectures?.find((option) => option.value === settings.architecture);
|
||||
const supportedModes = architecture?.supportedModes?.length ? architecture.supportedModes : generateArchitectureDefaults[settings.architecture].supportedModes;
|
||||
const availableModes = modes.filter((mode) => supportedModes.includes(mode.value));
|
||||
if (availableModes.some((mode) => mode.value === settings.mode)) return availableModes;
|
||||
return [modes.find((mode) => mode.value === settings.mode), ...availableModes].filter((mode): mode is BottomControlSelectOption<GenerateMode> => Boolean(mode));
|
||||
}
|
||||
|
||||
function unique<T>(values: T[]): T[] {
|
||||
return Array.from(new Set(values));
|
||||
}
|
||||
|
||||
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