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:
syntaxbullet
2026-07-10 23:15:02 +02:00
parent 41bbd1e16b
commit 5e4b548ad4
80 changed files with 2114 additions and 1954 deletions

View File

@@ -1,10 +1,10 @@
import type { Asset } from "@core/asset";
import type { AssetGenerationProvenance, GeneratedAssetAcceptance } from "@core/asset-provenance";
import type { ImageDocument } from "@core/document";
import type { ArtboardId, AssetId, LayerId } from "@core/id";
import type { ArtboardId, AssetId, GenerationCandidateId, GenerationJobId, LayerId } from "@core/id";
import type { ImageLayer } from "@core/image-layer";
import type { Layer } from "@core/layer";
import type { GenerationCandidate, GenerationCompareMode, GenerationState } from "@editor/state";
import type { AppState, GenerationCandidate, GenerationCompareMode, GenerationJobKind, GenerationOptions, GenerationState } from "@editor/state";
import type { Command } from "./command";
import { commandIds } from "./ids";
@@ -13,7 +13,7 @@ export type GenerationAddCandidatePayload = {
};
export type GenerationSelectCandidatePayload = {
candidateId?: string;
candidateId?: GenerationCandidateId;
};
export type GenerationSetCompareModePayload = {
@@ -21,22 +21,29 @@ export type GenerationSetCompareModePayload = {
};
export type GenerationRemoveCandidatePayload = {
candidateId: string;
candidateId: GenerationCandidateId;
};
export type GenerationApplyCandidateAsLayerPayload = {
candidateId: string;
candidateId: GenerationCandidateId;
assetId: AssetId;
layerId: LayerId;
};
export type GenerationReplaceCandidatePixelsPayload = {
candidateId: string;
candidateId: GenerationCandidateId;
source: string;
mimeType?: string;
};
export type GenerationStartJobPayload = { jobId: GenerationJobId; kind: GenerationJobKind; label: string; startedAt: number };
export type GenerationSucceedJobPayload = { jobId: GenerationJobId; finishedAt: number };
export type GenerationFailJobPayload = { jobId: GenerationJobId; finishedAt: number; error: string };
export type GenerationSetResourcesPayload = { options: GenerationOptions };
export type GenerationFailResourcesPayload = { error: string };
const maxCandidates = 12;
const maxJobs = 20;
const generationCompareModes = new Set<GenerationCompareMode>(["result", "before", "split"]);
export const generationAddCandidateCommand: Command<GenerationAddCandidatePayload> = {
@@ -50,6 +57,7 @@ export const generationAddCandidateCommand: Command<GenerationAddCandidatePayloa
editor: {
...state.editor,
generation: {
...state.editor.generation,
candidates,
selectedCandidateId: payload.candidate.id,
compareMode: "result",
@@ -126,7 +134,7 @@ export const generationClearCandidatesCommand: Command = {
...state,
editor: {
...state.editor,
generation: { candidates: [], selectedCandidateId: undefined, compareMode: "result" },
generation: { ...state.editor.generation, candidates: [], selectedCandidateId: undefined, compareMode: "result" },
},
};
},
@@ -210,6 +218,66 @@ export const generationReplaceCandidatePixelsCommand: Command<GenerationReplaceC
},
};
export const generationStartJobCommand: Command<GenerationStartJobPayload> = {
id: commandIds.generationStartJob,
name: "Start generation job",
history: { mode: "ignore" },
execute({ state }, payload) {
if (!payload.jobId || !payload.label.trim() || !Number.isFinite(payload.startedAt)) return state;
if (state.editor.generation.jobs.some((job) => job.status === "running" || job.id === payload.jobId)) return state;
const job: GenerationState["jobs"][number] = { id: payload.jobId, kind: payload.kind, label: payload.label.trim(), status: "running", startedAt: payload.startedAt };
return updateJobs(state, [job, ...state.editor.generation.jobs].slice(0, maxJobs));
},
};
export const generationSucceedJobCommand: Command<GenerationSucceedJobPayload> = {
id: commandIds.generationSucceedJob,
name: "Complete generation job",
history: { mode: "ignore" },
execute({ state }, payload) {
return settleJob(state, payload.jobId, payload.finishedAt, "succeeded");
},
};
export const generationFailJobCommand: Command<GenerationFailJobPayload> = {
id: commandIds.generationFailJob,
name: "Fail generation job",
history: { mode: "ignore" },
execute({ state }, payload) {
if (!payload.error.trim()) return state;
return settleJob(state, payload.jobId, payload.finishedAt, "failed", payload.error.trim());
},
};
export const generationLoadResourcesCommand: Command = {
id: commandIds.generationLoadResources,
name: "Load generation resources",
history: { mode: "ignore" },
execute({ state }) {
if (state.editor.generation.resources.status === "loading") return state;
return updateResources(state, { status: "loading" });
},
};
export const generationSetResourcesCommand: Command<GenerationSetResourcesPayload> = {
id: commandIds.generationSetResources,
name: "Set generation resources",
history: { mode: "ignore" },
execute({ state }, payload) {
return updateResources(state, { status: "ready", options: payload.options });
},
};
export const generationFailResourcesCommand: Command<GenerationFailResourcesPayload> = {
id: commandIds.generationFailResources,
name: "Fail generation resources",
history: { mode: "ignore" },
execute({ state }, payload) {
if (!payload.error.trim()) return state;
return updateResources(state, { status: "failed", error: payload.error.trim() });
},
};
export const generationCommands = [
generationAddCandidateCommand,
generationSelectCandidateCommand,
@@ -218,8 +286,29 @@ export const generationCommands = [
generationClearCandidatesCommand,
generationApplyCandidateAsLayerCommand,
generationReplaceCandidatePixelsCommand,
generationStartJobCommand,
generationSucceedJobCommand,
generationFailJobCommand,
generationLoadResourcesCommand,
generationSetResourcesCommand,
generationFailResourcesCommand,
] satisfies Command<unknown>[];
function updateJobs(state: AppState, jobs: GenerationState["jobs"]): AppState {
return { ...state, editor: { ...state.editor, generation: { ...state.editor.generation, jobs } } };
}
function updateResources(state: AppState, resources: GenerationState["resources"]): AppState {
return { ...state, editor: { ...state.editor, generation: { ...state.editor.generation, resources } } };
}
function settleJob(state: AppState, jobId: GenerationJobId, finishedAt: number, status: "succeeded" | "failed", error?: string): AppState {
if (!Number.isFinite(finishedAt)) return state;
const job = state.editor.generation.jobs.find((candidate) => candidate.id === jobId);
if (!job || job.status !== "running" || finishedAt < job.startedAt) return state;
return updateJobs(state, state.editor.generation.jobs.map((candidate) => candidate.id === jobId ? { ...candidate, status, finishedAt, error } : candidate));
}
type LayerLocation = {
artboardId: ArtboardId;
layer: Layer;
@@ -251,7 +340,7 @@ function findLayerInTree(layers: readonly Layer[], layerId: LayerId): Layer | un
return undefined;
}
function removeGenerationCandidate(generation: GenerationState, candidateId: string): GenerationState {
function removeGenerationCandidate(generation: GenerationState, candidateId: GenerationCandidateId): GenerationState {
const removedIndex = generation.candidates.findIndex((candidate) => candidate.id === candidateId);
if (removedIndex < 0) return generation;
@@ -264,6 +353,7 @@ function removeGenerationCandidate(generation: GenerationState, candidateId: str
: candidates[Math.min(removedIndex, candidates.length - 1)]?.id;
return {
...generation,
candidates,
selectedCandidateId,
compareMode: candidates.length > 0 ? generation.compareMode : "result",