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

@@ -1,14 +1,16 @@
import { useEffect, useState } from "react";
import { DownloadSimple, FolderOpen, Stack } from "@phosphor-icons/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { DownloadSimple, FolderOpen, Sparkle, Stack } from "@phosphor-icons/react";
import type { ImageStudioApp } from "@app/app";
import { commandIds } from "@commands/ids";
import { BottomControlsIsland } from "./BottomControlsIsland";
import { brushUnavailableHint } from "./canvas/brush";
import { CanvasViewport } from "./CanvasViewport";
import { GenerateSheet } from "./GenerateSheet";
import { LayersSheet } from "./LayersSheet";
import { ShortcutsDisplay } from "./ShortcutsDisplay";
import { ToolOverlay } from "./ToolOverlay";
import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets";
import type { ToolId } from "@editor/tools";
import { handleDeleteSelectionKey, handleHistoryKey, handleToolKey, keybindEventFromKeyboardEvent } from "@input/index";
import { useAppState } from "./useAppState";
import { downloadArtboardPng } from "./exportArtboardPng";
@@ -25,9 +27,31 @@ export function App({ app }: AppProps) {
const viewportActivityIsland = useViewportActivityIsland(state.editor.viewport);
const imageImport = useImageImport(app.store);
const [layersOpen, setLayersOpen] = useState(false);
const [generateOpen, setGenerateOpen] = useState(false);
const previousGenerateTool = useRef<ToolId>("select");
const transformTarget = state.editor.transformSession?.target ?? selectedTransformTarget(state.document, state.editor.selection);
const activeArtboard = state.document.artboards.find((artboard) => artboard.id === state.editor.selection.artboardId) ?? state.document.artboards[0];
const openGenerate = useCallback(() => {
const activeTool = app.store.getState().editor.tools.activeTool;
if (activeTool !== "generate") previousGenerateTool.current = activeTool;
app.store.dispatch(commandIds.toolSetActive, { tool: "generate" });
setLayersOpen(false);
setGenerateOpen(true);
}, [app.store]);
const closeGenerate = useCallback(() => {
setGenerateOpen(false);
if (app.store.getState().editor.tools.activeTool === "generate") {
app.store.dispatch(commandIds.toolSetActive, { tool: previousGenerateTool.current });
}
}, [app.store]);
const toggleGenerate = useCallback(() => {
if (generateOpen) closeGenerate();
else openGenerate();
}, [closeGenerate, generateOpen, openGenerate]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const target = event.target;
@@ -51,16 +75,23 @@ export function App({ app }: AppProps) {
return;
}
const key = event.key.toLowerCase();
const keybindEvent = keybindEventFromKeyboardEvent(event);
if (key === "g") {
toggleGenerate();
event.preventDefault();
return;
}
const toolConsumed = handleToolKey({ event: keybindEvent, dispatch: app.store.dispatch });
if (toolConsumed) {
event.preventDefault();
return;
}
const key = event.key.toLowerCase();
if (key === "l") {
closeGenerate();
setLayersOpen((open) => !open);
event.preventDefault();
return;
@@ -76,7 +107,7 @@ export function App({ app }: AppProps) {
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [app.store]);
}, [app.store, closeGenerate, toggleGenerate]);
const transformBounds = transformTarget ? resolveTransformTargetBounds(state.document, transformTarget) : undefined;
const brushHint = brushUnavailableHint(state.document, state.editor);
@@ -88,10 +119,13 @@ export function App({ app }: AppProps) {
<button type="button" className={topBarButtonClass()} onClick={imageImport.openFilePicker}>
<FolderOpen size={24} />
</button>
<button type="button" className={topBarButtonClass(generateOpen)} aria-pressed={generateOpen} onClick={toggleGenerate}>
<Sparkle size={24} weight={generateOpen ? "fill" : "regular"} />
</button>
<button type="button" className={topBarButtonClass()} disabled={!activeArtboard} onClick={() => activeArtboard && void downloadArtboardPng(activeArtboard, state.document.assets)}>
<DownloadSimple size={24} />
</button>
<button type="button" className={topBarButtonClass(layersOpen)} aria-pressed={layersOpen} onClick={() => setLayersOpen((open) => !open)}>
<button type="button" className={topBarButtonClass(layersOpen)} aria-pressed={layersOpen} onClick={() => { closeGenerate(); setLayersOpen((open) => !open); }}>
<Stack size={24} weight={layersOpen ? "fill" : "regular"} />
</button>
</div>
@@ -103,6 +137,15 @@ export function App({ app }: AppProps) {
dispatch={app.store.dispatch}
/>
</div>
<GenerateSheet
document={state.document}
selection={state.editor.selection}
viewport={state.editor.viewport}
settings={state.editor.tools.generate}
open={generateOpen}
onOpenChange={(open) => open ? openGenerate() : closeGenerate()}
dispatch={app.store.dispatch}
/>
<LayersSheet
document={state.document}
selection={state.editor.selection}
@@ -115,10 +158,11 @@ export function App({ app }: AppProps) {
document={state.document}
selection={state.editor.selection}
viewport={state.editor.viewport}
visible={state.editor.tools.activeTool === "brush" || state.editor.tools.activeTool === "eraser" || state.editor.tools.activeTool === "chromaKey" || state.editor.tools.activeTool === "magicWand" || Boolean(transformBounds) || viewportActivityIsland.visible}
visible={state.editor.tools.activeTool === "generate" || state.editor.tools.activeTool === "brush" || state.editor.tools.activeTool === "eraser" || state.editor.tools.activeTool === "chromaKey" || state.editor.tools.activeTool === "magicWand" || Boolean(transformBounds) || viewportActivityIsland.visible}
action={viewportActivityIsland.action}
activeTool={state.editor.tools.activeTool}
brushSettings={state.editor.tools.brush}
generateSettings={state.editor.tools.generate}
chromaKeySettings={state.editor.tools.chromaKey}
magicWandSettings={state.editor.tools.magicWand}
editingMask={Boolean(state.editor.maskEdit)}