feat: add ComfyUI integration for image generation

- Implemented ComfyUI API for generating images with various modes (text-to-image, image-to-image, inpaint, outpaint).
- Created GenerateSheet and associated controls for user input on generation settings.
- Added subtle scrollbar styles for improved UI experience.
- Enhanced canvas input handling to ignore key events when focused on editable elements.
- Optimized canvas resizing logic to prevent unnecessary dispatches.
- Introduced error handling for generation failures and loading models.
- Added functionality to upload images and masks for inpainting.
This commit is contained in:
syntaxbullet
2026-07-04 15:10:30 +02:00
parent af50a165da
commit 7188569672
23 changed files with 981 additions and 52 deletions

View File

@@ -24,6 +24,7 @@ export const commandIds = {
selectionClear: "selection.clear",
selectionAddLayer: "selection.addLayer",
toolSetActive: "tool.setActive",
toolSetGenerateSettings: "tool.setGenerateSettings",
toolSetBrushSettings: "tool.setBrushSettings",
toolSetChromaKeySettings: "tool.setChromaKeySettings",
toolSetMagicWandSettings: "tool.setMagicWandSettings",

View File

@@ -54,10 +54,10 @@ export type { CommandRegistry } from "./registry";
export { createCommandRegistry } from "./registry";
export { selectionAddLayerCommand, selectionClearCommand, selectionCommands, selectionSetCommand } from "./selection";
export type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
export { toolCommands, toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetChromaKeySettingsCommand, toolSetMagicWandSettingsCommand, toolSetMaskViewModeCommand } from "./tool";
export { toolCommands, toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetChromaKeySettingsCommand, toolSetGenerateSettingsCommand, toolSetMagicWandSettingsCommand, toolSetMaskViewModeCommand } from "./tool";
export { transformBeginCommand, transformCommands, transformEndCommand, transformSetBoundsCommand, transformUpdateCommand } from "./transform";
export type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform";
export type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool";
export type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool";
export {
viewportCommands,
viewportPanCommand,

View File

@@ -23,7 +23,7 @@ import type {
DocumentUngroupLayerPayload,
} from "./document";
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
import type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool";
import type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool";
import type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform";
import type {
ViewportFitArtboardPayload,
@@ -59,6 +59,7 @@ export type CommandPayloads = {
[commandIds.selectionClear]: void;
[commandIds.selectionAddLayer]: SelectionAddLayerPayload;
[commandIds.toolSetActive]: ToolSetActivePayload;
[commandIds.toolSetGenerateSettings]: ToolSetGenerateSettingsPayload;
[commandIds.toolSetBrushSettings]: ToolSetBrushSettingsPayload;
[commandIds.toolSetChromaKeySettings]: ToolSetChromaKeySettingsPayload;
[commandIds.toolSetMagicWandSettings]: ToolSetMagicWandSettingsPayload;

View File

@@ -5,11 +5,12 @@ import { toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEdi
const defaultBrush = { color: "#111827", size: 8, hardness: 100 };
const defaultChromaKey = { color: "#00ff00", tolerance: 32, softness: 24, feather: 0, choke: 0, despeckle: 0, spill: 50 };
const defaultMagicWand = { tolerance: 32, feather: 0, choke: 0, despeckle: 0, contiguous: true, mode: "replace" as const };
const defaultGenerate = { mode: "text-to-image" as const, model: "auto" as const, prompt: "", negativePrompt: "", strength: 75, steps: 30, cfg: 7, seed: -1, sampler: "euler", scheduler: "normal", width: 1024, height: 1024, outpaint: { left: 128, top: 128, right: 128, bottom: 128, feathering: 32 } };
describe("tool commands", () => {
test("sets active tool", () => {
const next = toolSetActiveCommand.execute({ state: createInitialAppState("Test") }, { tool: "brush" });
expect(next.editor.tools).toEqual({ activeTool: "brush", interactionMode: { type: "tool", tool: "brush" }, brush: defaultBrush, chromaKey: defaultChromaKey, magicWand: defaultMagicWand });
expect(next.editor.tools).toEqual({ activeTool: "brush", interactionMode: { type: "tool", tool: "brush" }, brush: defaultBrush, generate: defaultGenerate, chromaKey: defaultChromaKey, magicWand: defaultMagicWand });
});
test("sets brush settings", () => {
@@ -64,7 +65,7 @@ describe("tool commands", () => {
const panning = toolEnterTemporaryPanCommand.execute({ state: initial }, undefined);
const restored = toolExitTemporaryPanCommand.execute({ state: panning }, undefined);
expect(panning.editor.tools).toEqual({ activeTool: "select", interactionMode: { type: "temporary-pan", previousTool: "select" }, brush: defaultBrush, chromaKey: defaultChromaKey, magicWand: defaultMagicWand });
expect(panning.editor.tools).toEqual({ activeTool: "select", interactionMode: { type: "temporary-pan", previousTool: "select" }, brush: defaultBrush, generate: defaultGenerate, chromaKey: defaultChromaKey, magicWand: defaultMagicWand });
expect(restored.editor.tools).toEqual(initial.editor.tools);
});
});

View File

@@ -3,7 +3,7 @@ import type { Vec2D } from "@core/geometry";
import type { LayerId, ArtboardId, AssetId } from "@core/id";
import type { Layer } from "@core/layer";
import type { MaskViewMode } from "@editor/state";
import type { BrushSettings, ChromaKeySettings, MagicWandSettings, ToolId } from "@editor/tools";
import type { BrushSettings, ChromaKeySettings, GenerateSettings, MagicWandSettings, ToolId } from "@editor/tools";
import type { Command } from "./command";
import { commandIds } from "./ids";
@@ -13,6 +13,8 @@ export type ToolSetActivePayload = {
export type ToolSetBrushSettingsPayload = Partial<BrushSettings>;
export type ToolSetGenerateSettingsPayload = Partial<GenerateSettings>;
export type ToolSetChromaKeySettingsPayload = Partial<ChromaKeySettings>;
export type ToolSetMagicWandSettingsPayload = Partial<MagicWandSettings>;
@@ -56,6 +58,44 @@ export const toolSetActiveCommand: Command<ToolSetActivePayload> = {
},
};
export const toolSetGenerateSettingsCommand: Command<ToolSetGenerateSettingsPayload> = {
id: commandIds.toolSetGenerateSettings,
name: "Set generate settings",
execute({ state }, payload) {
const mode = payload.mode ?? state.editor.tools.generate.mode;
return {
...state,
editor: {
...state.editor,
tools: {
...state.editor.tools,
generate: {
mode,
model: payload.model ?? state.editor.tools.generate.model,
prompt: payload.prompt ?? state.editor.tools.generate.prompt,
negativePrompt: payload.negativePrompt ?? state.editor.tools.generate.negativePrompt,
strength: clampNumber(payload.strength ?? state.editor.tools.generate.strength, 0, 100),
steps: Math.round(clampNumber(payload.steps ?? state.editor.tools.generate.steps, 1, 150)),
cfg: clampNumber(payload.cfg ?? state.editor.tools.generate.cfg, 0, 30),
seed: Math.round(clampNumber(payload.seed ?? state.editor.tools.generate.seed, -1, Number.MAX_SAFE_INTEGER)),
sampler: payload.sampler ?? state.editor.tools.generate.sampler,
scheduler: payload.scheduler ?? state.editor.tools.generate.scheduler,
width: Math.round(clampNumber(payload.width ?? state.editor.tools.generate.width, 64, 4096)),
height: Math.round(clampNumber(payload.height ?? state.editor.tools.generate.height, 64, 4096)),
outpaint: {
left: Math.round(clampNumber(payload.outpaint?.left ?? state.editor.tools.generate.outpaint.left, 0, 2048)),
top: Math.round(clampNumber(payload.outpaint?.top ?? state.editor.tools.generate.outpaint.top, 0, 2048)),
right: Math.round(clampNumber(payload.outpaint?.right ?? state.editor.tools.generate.outpaint.right, 0, 2048)),
bottom: Math.round(clampNumber(payload.outpaint?.bottom ?? state.editor.tools.generate.outpaint.bottom, 0, 2048)),
feathering: Math.round(clampNumber(payload.outpaint?.feathering ?? state.editor.tools.generate.outpaint.feathering, 0, 512)),
},
},
},
},
};
},
};
export const toolSetBrushSettingsCommand: Command<ToolSetBrushSettingsPayload> = {
id: commandIds.toolSetBrushSettings,
name: "Set brush settings",
@@ -274,6 +314,7 @@ export const toolExitTemporaryPanCommand: Command = {
export const toolCommands = [
toolSetActiveCommand,
toolSetGenerateSettingsCommand,
toolSetBrushSettingsCommand,
toolSetChromaKeySettingsCommand,
toolSetMagicWandSettingsCommand,