feat: add ComfyUI integration for image generation and management
- Implemented ComfyGenerateRequest type and associated functions for generating images using various architectures and modes. - Added functions for listing generation options and handling image uploads. - Created workflows for different generation modes including SDXL, Z-Image, Z-Image Turbo, and Anima. - Introduced GenerationJobStatus component to display the status of ongoing generation jobs. - Developed MaskControls for managing mask operations and displaying mask analysis. - Created palette items for tool selection, layer management, and generation settings.
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { commandIds } from "@commands/ids";
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { GenerationCandidate, GenerationCompareMode, GenerationState, SelectionState, ViewportState } from "@editor/state";
|
||||
import type { GenerateSettings } from "@editor/tools";
|
||||
import type { AppStore } from "@editor/store";
|
||||
import { createMaskedPixelReplacementSource } from "../generate/candidateActions";
|
||||
import { runGenerate, runGenerateFromCandidate } from "../generate/runGenerate";
|
||||
import { createSolidMaskSource } from "../mask/maskRaster";
|
||||
import { createMaskedPixelReplacementSource } from "@operations/generation/candidateActions";
|
||||
import { runGenerate, runGenerateFromCandidate } from "@operations/generation/runGenerate";
|
||||
import { runGenerationJob } from "@operations/generation/generationJob";
|
||||
import { currentGenerationJob, GenerationJobStatus } from "../GenerationJobStatus";
|
||||
import { createRefinementMask } from "@operations/masks/rasterActions";
|
||||
|
||||
export type GenerateActionControlsProps = {
|
||||
document: ImageDocument;
|
||||
@@ -18,44 +19,25 @@ export type GenerateActionControlsProps = {
|
||||
};
|
||||
|
||||
export function GenerateActionControls({ document, selection, viewport, settings, generation, dispatch }: GenerateActionControlsProps) {
|
||||
const [busy, setBusy] = useState<string>();
|
||||
const [error, setError] = useState<string>();
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||
const job = currentGenerationJob(generation);
|
||||
const busy = job?.status === "running";
|
||||
const candidate = selectedCandidate(generation);
|
||||
const canGenerate = Boolean(settings.prompt.trim()) && !busy;
|
||||
|
||||
useEffect(() => {
|
||||
if (!busy) {
|
||||
setElapsedSeconds(0);
|
||||
return;
|
||||
}
|
||||
|
||||
setElapsedSeconds(0);
|
||||
const startedAt = Date.now();
|
||||
const interval = window.setInterval(() => {
|
||||
setElapsedSeconds(Math.floor((Date.now() - startedAt) / 1000));
|
||||
}, 1000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [busy]);
|
||||
|
||||
return (
|
||||
<div className="flex max-w-[calc(100vw-2rem)] flex-wrap items-center justify-center gap-2 px-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canGenerate}
|
||||
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={error ?? "Generate with ComfyUI"}
|
||||
title={job?.status === "failed" ? job.error : "Generate with ComfyUI"}
|
||||
onClick={() => {
|
||||
setBusy("Generating");
|
||||
setError(undefined);
|
||||
void runGenerate({ document, selection, viewport, settings, dispatch })
|
||||
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : "Generation failed"))
|
||||
.finally(() => setBusy(undefined));
|
||||
void runGenerationJob({ kind: "generate", label: "Generating", dispatch, task: () => runGenerate({ document, selection, viewport, settings, dispatch }) });
|
||||
}}
|
||||
>
|
||||
{busy === "Generating" ? `Generating ${formatElapsed(elapsedSeconds)}` : "Generate"}
|
||||
{busy && job?.kind === "generate" ? "Generating..." : "Generate"}
|
||||
</button>
|
||||
{busy ? <span className="rounded-full bg-white/10 px-3 py-2 text-xs font-medium text-white/60">{busy} {formatElapsed(elapsedSeconds)}</span> : null}
|
||||
<GenerationJobStatus generation={generation} />
|
||||
{candidate ? (
|
||||
<>
|
||||
<CandidatePicker generation={generation} dispatch={dispatch} />
|
||||
@@ -65,13 +47,10 @@ export function GenerateActionControls({ document, selection, viewport, settings
|
||||
compareMode={generation.compareMode ?? "result"}
|
||||
settings={settings}
|
||||
busy={busy}
|
||||
setBusy={setBusy}
|
||||
setError={setError}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{error ? <span className="max-w-80 truncate rounded-full bg-red-500/15 px-3 py-2 text-xs font-medium text-red-100" title={error}>{error}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -104,70 +83,53 @@ function CandidateControls({
|
||||
compareMode,
|
||||
settings,
|
||||
busy,
|
||||
setBusy,
|
||||
setError,
|
||||
dispatch,
|
||||
}: {
|
||||
document: ImageDocument;
|
||||
candidate: GenerationCandidate;
|
||||
compareMode: GenerationCompareMode;
|
||||
settings: GenerateSettings;
|
||||
busy?: string;
|
||||
setBusy: (busy: string | undefined) => void;
|
||||
setError: (error: string | undefined) => void;
|
||||
busy: boolean;
|
||||
dispatch: AppStore["dispatch"];
|
||||
}) {
|
||||
const rerun = (label: string, nextSettings: GenerateSettings) => {
|
||||
setBusy(label);
|
||||
setError(undefined);
|
||||
dispatch(commandIds.toolSetGenerateSettings, nextSettings);
|
||||
void runGenerateFromCandidate({ candidate, settings: nextSettings, dispatch })
|
||||
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : `${label} failed`))
|
||||
.finally(() => setBusy(undefined));
|
||||
void runGenerationJob({ kind: "regenerate", label, dispatch, task: () => runGenerateFromCandidate({ candidate, settings: nextSettings, dispatch }) });
|
||||
};
|
||||
const disabled = Boolean(busy);
|
||||
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]">
|
||||
<CandidatePreview candidate={candidate} />
|
||||
<span className="px-2 text-xs font-medium text-white/55">Seed {candidate.seed}</span>
|
||||
<CandidateCompareControls compareMode={compareMode} disabled={disabled} dispatch={dispatch} />
|
||||
<CandidateButton disabled={disabled} label="Regenerate" title="Regenerate same mask and crop" busy={busy === "Regenerate"} onClick={() => rerun("Regenerate", candidate.settings)} />
|
||||
<CandidateButton disabled={disabled} label="Regenerate" title="Regenerate same mask and crop" onClick={() => rerun("Regenerate", candidate.settings)} />
|
||||
<CandidateButton
|
||||
disabled={disabled}
|
||||
label="Lower"
|
||||
title="Lower strength and regenerate same mask"
|
||||
busy={busy === "Lower"}
|
||||
onClick={() => rerun("Lower", { ...candidate.settings, strength: Math.max(0, candidate.settings.strength - 10), seed: candidate.seed })}
|
||||
/>
|
||||
<CandidateButton disabled={disabled} label="Reuse seed" title="Regenerate with the same seed" busy={busy === "Reuse seed"} onClick={() => rerun("Reuse seed", { ...candidate.settings, seed: candidate.seed })} />
|
||||
<CandidateButton disabled={disabled} label="New seed" title="Regenerate with a new seed" busy={busy === "New seed"} onClick={() => rerun("New seed", { ...candidate.settings, seed: -1 })} />
|
||||
<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 + mask"
|
||||
title="Add candidate as a layer with a fresh refinement mask"
|
||||
busy={busy === "Refine"}
|
||||
onClick={() => {
|
||||
setBusy("Refine");
|
||||
setError(undefined);
|
||||
void applyCandidateAsRefinementLayer(candidate, dispatch)
|
||||
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : "Refine setup failed"))
|
||||
.finally(() => setBusy(undefined));
|
||||
void runGenerationJob({ kind: "refine", label: "Adding refinement mask", dispatch, task: () => applyCandidateAsRefinementLayer(candidate, dispatch) });
|
||||
}}
|
||||
/>
|
||||
<CandidateButton
|
||||
disabled={disabled || !candidate.inpaint}
|
||||
label="Replace pixels"
|
||||
title={candidate.inpaint ? "Replace masked pixels and preserve unmasked pixels" : "Only inpaint candidates can replace masked pixels"}
|
||||
busy={busy === "Replace"}
|
||||
onClick={() => {
|
||||
setBusy("Replace");
|
||||
setError(undefined);
|
||||
void createMaskedPixelReplacementSource(document, candidate)
|
||||
.then((source) => dispatch(commandIds.generationReplaceCandidatePixels, { candidateId: candidate.id, source, mimeType: "image/png" }))
|
||||
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : "Replace failed"))
|
||||
.finally(() => setBusy(undefined));
|
||||
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" });
|
||||
} });
|
||||
}}
|
||||
/>
|
||||
<CandidateButton
|
||||
@@ -259,7 +221,7 @@ async function applyCandidateAsRefinementLayer(candidate: GenerationCandidate, d
|
||||
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 createSolidMaskSource(width, height, "white");
|
||||
const source = await createRefinementMask(width, height);
|
||||
|
||||
dispatch(commandIds.documentAddLayerMask, {
|
||||
layerId,
|
||||
@@ -295,9 +257,3 @@ function applyCandidateAsLayerWithIds(candidate: GenerationCandidate, ids: { lay
|
||||
layerId: ids.layerId,
|
||||
});
|
||||
}
|
||||
|
||||
function formatElapsed(seconds: number) {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainder = seconds % 60;
|
||||
return `${minutes}:${remainder.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user