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,23 +1,24 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect } 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 { brushUnavailableHint } from "@operations/paint/brush";
import { CanvasViewport } from "./CanvasViewport";
import { CommandPalette } from "./CommandPalette";
import { GenerateSheet } from "./GenerateSheet";
import { GenerationJobStatus } from "./GenerationJobStatus";
import { LayersSheet } from "./LayersSheet";
import { ShortcutsDisplay } from "./ShortcutsDisplay";
import { ToolOverlay } from "./ToolOverlay";
import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets";
import type { AppState } from "@editor/state";
import type { ToolId } from "@editor/tools";
import { handleCommandPaletteKey, handleDeleteSelectionKey, handleHistoryKey, handleToolKey, keybindEventFromKeyboardEvent } from "@input/index";
import { shallowEqual, useAppState } from "./useAppState";
import { downloadArtboardPng } from "./exportArtboardPng";
import { downloadArtboardPng } from "@operations/export/downloadArtboard";
import { useImageImport } from "./useImageImport";
import { useViewportActivityIsland } from "./useViewportActivityIsland";
import { loadGenerationResources } from "@operations/generation/loadResources";
import "./index.css";
export type AppProps = {
@@ -26,40 +27,25 @@ export type AppProps = {
export function App({ app }: AppProps) {
const shellState = useAppState(app.store, selectAppShellState, shallowEqual);
const { document, selection, viewport, tools, generation, commandPalette, transformSession, maskEdit } = shellState;
const { document, selection, viewport, tools, generation, commandPalette, transformSession, maskEdit, workspace } = shellState;
const viewportActivityIsland = useViewportActivityIsland(viewport);
const imageImport = useImageImport(app.store);
const [layersOpen, setLayersOpen] = useState(false);
const previousGenerateTool = useRef<ToolId>("select");
const transformTarget = transformSession?.target ?? selectedTransformTarget(document, selection);
const activeArtboard = document.artboards.find((artboard) => artboard.id === selection.artboardId) ?? document.artboards[0];
const generateOpen = tools.activeTool === "generate";
const generateOpen = workspace.panel === "generate";
const layersOpen = workspace.panel === "layers";
useEffect(() => {
if (tools.activeTool !== "generate") {
previousGenerateTool.current = tools.activeTool;
return;
}
setLayersOpen(false);
}, [tools.activeTool]);
const getGenerateReturnTool = useCallback((): ToolId => {
return previousGenerateTool.current === "generate" ? "select" : previousGenerateTool.current;
}, []);
if (generateOpen) void loadGenerationResources(app.store);
}, [app.store, generateOpen]);
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);
app.store.dispatch(commandIds.workspaceSetPanel, { panel: "generate" });
}, [app.store]);
const closeGenerate = useCallback(() => {
if (app.store.getState().editor.tools.activeTool === "generate") {
app.store.dispatch(commandIds.toolSetActive, { tool: getGenerateReturnTool() });
}
}, [app.store, getGenerateReturnTool]);
app.store.dispatch(commandIds.workspaceSetPanel, { panel: "none" });
}, [app.store]);
const toggleGenerate = useCallback(() => {
if (generateOpen) closeGenerate();
@@ -67,19 +53,16 @@ export function App({ app }: AppProps) {
}, [closeGenerate, generateOpen, openGenerate]);
const openLayers = useCallback(() => {
closeGenerate();
setLayersOpen(true);
}, [closeGenerate]);
app.store.dispatch(commandIds.workspaceSetPanel, { panel: "layers" });
}, [app.store]);
const closeLayers = useCallback(() => {
setLayersOpen(false);
}, []);
app.store.dispatch(commandIds.workspaceSetPanel, { panel: "none" });
}, [app.store]);
const toggleLayers = useCallback(() => {
const nextOpen = !layersOpen;
if (nextOpen) closeGenerate();
setLayersOpen(nextOpen);
}, [closeGenerate, layersOpen]);
app.store.dispatch(commandIds.workspaceSetPanel, { panel: layersOpen ? "none" : "layers" });
}, [app.store, layersOpen]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
@@ -167,6 +150,9 @@ export function App({ app }: AppProps) {
/>
<header className="pointer-events-none absolute inset-x-3 top-3 z-10 flex h-20 items-center justify-end gap-4 rounded-full px-4 text-white backdrop-blur-xl">
<div className="pointer-events-auto flex items-center gap-2">
<button type="button" className="border-0 bg-transparent p-0" onClick={openGenerate} title="Open generation activity">
<GenerationJobStatus generation={generation} compact />
</button>
<button type="button" className={topBarButtonClass()} onClick={imageImport.openFilePicker}>
<FolderOpen size={24} />
</button>
@@ -190,6 +176,7 @@ export function App({ app }: AppProps) {
</div>
<GenerateSheet
settings={tools.generate}
resources={generation.resources}
open={generateOpen}
dispatch={app.store.dispatch}
/>
@@ -243,6 +230,7 @@ type AppShellState = {
commandPalette: AppState["editor"]["commandPalette"];
transformSession: AppState["editor"]["transformSession"];
maskEdit: AppState["editor"]["maskEdit"];
workspace: AppState["editor"]["workspace"];
};
function selectAppShellState(state: AppState): AppShellState {
@@ -255,6 +243,7 @@ function selectAppShellState(state: AppState): AppShellState {
commandPalette: state.editor.commandPalette,
transformSession: state.editor.transformSession,
maskEdit: state.editor.maskEdit,
workspace: state.editor.workspace,
};
}

View File

@@ -4,7 +4,7 @@ import type { AppState, MaskEditState } from "@editor/state";
import type { AppStore } from "@editor/store";
import type { InteractionMode } from "@editor/tools";
import type { GlobalKeybindConsumer, GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index";
import { brushUnavailableHint, canPreviewBrush, type BrushTargetEditorState } from "./canvas/brush";
import { brushUnavailableHint, canPreviewBrush, type BrushTargetEditorState } from "@operations/paint/brush";
import { canvasCursorClass } from "./canvas/cursor";
import { useCanvasInput } from "./canvas/useCanvasInput";
import { useCanvasRenderer } from "./canvas/useCanvasRenderer";
@@ -37,10 +37,10 @@ export function CanvasViewport({
useCanvasRenderer(canvasRef, store);
useCanvasResize(canvasRef, store.dispatch);
const input = useCanvasInput(canvasRef, store, inputOptions);
useCanvasInput(canvasRef, store, inputOptions);
const brushHint = brushUnavailableHint(cursorState.document, cursorState.editor);
const hasBrushPreview = Boolean(cursorState.hasBrushPreview && !brushHint && canPreviewBrush(cursorState.document, cursorState.editor));
const cursorClass = canvasCursorClass(cursorState.editor.tools.interactionMode, input, hasBrushPreview, !brushHint);
const cursorClass = canvasCursorClass(cursorState.editor.tools.interactionMode, cursorState.isPanning, hasBrushPreview, !brushHint);
return <canvas ref={canvasRef} className={`h-full w-full ${cursorClass}`} />;
}
@@ -49,6 +49,7 @@ type CanvasCursorState = {
document: ImageDocument;
editor: BrushTargetEditorState;
hasBrushPreview: boolean;
isPanning: boolean;
};
function selectCanvasCursorState(state: AppState): CanvasCursorState {
@@ -63,6 +64,7 @@ function selectCanvasCursorState(state: AppState): CanvasCursorState {
maskEdit: state.editor.maskEdit,
},
hasBrushPreview: Boolean(state.editor.brushPreview),
isPanning: state.editor.pointerSession?.type === "pan",
};
}
@@ -70,6 +72,7 @@ function canvasCursorStatesEqual(a: CanvasCursorState, b: CanvasCursorState): bo
return (
a.document === b.document &&
a.hasBrushPreview === b.hasBrushPreview &&
a.isPanning === b.isPanning &&
a.editor.selection === b.editor.selection &&
a.editor.tools.activeTool === b.editor.tools.activeTool &&
interactionModesEqual(a.editor.tools.interactionMode, b.editor.tools.interactionMode) &&

View File

@@ -1,39 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, type KeyboardEvent, type ReactNode } from "react";
import {
ArrowDown,
ArrowUp,
Command,
CornersOut,
Cursor,
DownloadSimple,
DropHalf,
Eraser,
Eye,
EyeSlash,
FolderOpen,
FolderPlus,
Hand,
Lock,
LockOpen,
MagicWand,
MagnifyingGlass,
Minus,
PaintBrush,
Plus,
Sparkle,
Stack,
Trash,
} from "@phosphor-icons/react";
import { useCallback, useEffect, useMemo, useRef, type KeyboardEvent } from "react";
import { Command, MagnifyingGlass } from "@phosphor-icons/react";
import { commandIds } from "@commands/ids";
import type { Artboard } from "@core/artboard";
import type { ImageDocument } from "@core/document";
import type { GenerationCompareMode, GenerationState, SelectionState, ViewportState, CommandPaletteState } from "@editor/state";
import type { GenerationState, SelectionState, ViewportState, CommandPaletteState } from "@editor/state";
import type { AppStore } from "@editor/store";
import { availableToolIds, type GenerateMode, type ToolId, type ToolState } from "@editor/tools";
import { createDocumentReadIndex, type DocumentReadIndex, type IndexedLayerInfo } from "@editor/document-indexes";
import { downloadArtboardPng } from "./exportArtboardPng";
import { addArtboard, addEmptyLayer, addGroupLayer, deleteSelection, groupLayers, moveLayer } from "./layerActions";
import { labelForTool } from "./toolLabels";
import type { ToolState } from "@editor/tools";
import { createDocumentReadIndex } from "@editor/document-indexes";
import { createPaletteItems, type PaletteItem } from "./paletteItems";
export type CommandPaletteProps = {
state: CommandPaletteState;
@@ -51,17 +25,6 @@ export type CommandPaletteProps = {
closeLayers: () => void;
};
type PaletteItem = {
id: string;
title: string;
section: string;
subtitle?: string;
keywords?: string[];
disabled?: boolean;
icon: ReactNode;
run: () => void;
};
export function CommandPalette({
state,
document,
@@ -240,331 +203,6 @@ export function CommandPalette({
);
}
function createPaletteItems(options: {
document: ImageDocument;
documentIndex: DocumentReadIndex;
activeArtboard?: Artboard;
activeArtboardId?: string;
selectedLayer?: IndexedLayerInfo;
canGroup: boolean;
canUngroup: boolean;
selection: SelectionState;
viewport: ViewportState;
tools: ToolState;
generation: GenerationState;
layersOpen: boolean;
dispatch: AppStore["dispatch"];
openFilePicker: () => void;
openGenerate: () => void;
openLayers: () => void;
closeLayers: () => void;
}): PaletteItem[] {
const {
document,
documentIndex,
activeArtboard,
activeArtboardId,
selectedLayer,
canGroup,
canUngroup,
selection,
viewport,
tools,
generation,
layersOpen,
dispatch,
openFilePicker,
openGenerate,
openLayers,
closeLayers,
} = options;
const hasCandidates = generation.candidates.length > 0;
const items: PaletteItem[] = [];
items.push(
...availableToolIds.map((tool) => ({
id: `tool-${tool}`,
section: "Tools",
title: `Switch to ${labelForTool(tool)}`,
subtitle: tool === tools.activeTool ? "Current tool" : undefined,
keywords: [tool],
icon: toolIcon(tool),
run: () => {
if (tool === "generate") openGenerate();
else dispatch(commandIds.toolSetActive, { tool });
},
})),
);
items.push(
{
id: "import-image",
section: "File",
title: "Import image",
subtitle: "Add an image as a layer",
keywords: ["open", "file", "layer"],
icon: <FolderOpen size={20} />,
run: openFilePicker,
},
{
id: "export-artboard",
section: "File",
title: "Export artboard as PNG",
subtitle: activeArtboard ? activeArtboard.name : "No artboard selected",
keywords: ["download", "png"],
disabled: !activeArtboard,
icon: <DownloadSimple size={20} />,
run: () => {
if (activeArtboard) void downloadArtboardPng(activeArtboard, document.assets);
},
},
);
items.push(
{
id: layersOpen ? "close-layers" : "open-layers",
section: "Layers",
title: layersOpen ? "Close layers panel" : "Open layers panel",
keywords: ["panel", "stack"],
icon: <Stack size={20} weight={layersOpen ? "fill" : "regular"} />,
run: layersOpen ? closeLayers : openLayers,
},
{
id: "add-artboard",
section: "Layers",
title: "Add artboard",
icon: <Plus size={20} />,
run: () => addArtboard(document, dispatch),
},
{
id: "add-empty-layer",
section: "Layers",
title: "Add empty layer",
subtitle: activeArtboardId ? undefined : "No artboard available",
keywords: ["new", "raster"],
disabled: !activeArtboardId,
icon: <Plus size={20} />,
run: () => {
if (activeArtboardId) addEmptyLayer(document, activeArtboardId, selectedLayer, dispatch);
},
},
{
id: "add-group",
section: "Layers",
title: "Add group",
subtitle: activeArtboardId ? undefined : "No artboard available",
disabled: !activeArtboardId,
icon: <FolderPlus size={20} />,
run: () => {
if (activeArtboardId) addGroupLayer(activeArtboardId, dispatch);
},
},
{
id: "group-selection",
section: "Layers",
title: "Group selected layers",
subtitle: canGroup ? `${selection.layerIds.length} selected` : "Select one or more layers",
disabled: !canGroup,
icon: <Stack size={20} />,
run: () => {
if (selection.artboardId) groupLayers(selection.artboardId, selection.layerIds, dispatch);
},
},
{
id: "ungroup-selection",
section: "Layers",
title: "Ungroup selected group",
subtitle: selectedLayer?.layer.name,
disabled: !canUngroup || !selectedLayer,
icon: <Stack size={20} weight="fill" />,
run: () => {
if (selectedLayer?.layer.type === "group") dispatch(commandIds.documentUngroupLayer, { groupId: selectedLayer.layer.id });
},
},
{
id: "move-layer-up",
section: "Layers",
title: "Move selected layer up",
subtitle: selectedLayer?.layer.name,
disabled: !selectedLayer,
icon: <ArrowUp size={20} />,
run: () => {
if (selectedLayer) moveLayer(documentIndex, selectedLayer, -1, dispatch);
},
},
{
id: "move-layer-down",
section: "Layers",
title: "Move selected layer down",
subtitle: selectedLayer?.layer.name,
disabled: !selectedLayer,
icon: <ArrowDown size={20} />,
run: () => {
if (selectedLayer) moveLayer(documentIndex, selectedLayer, 1, dispatch);
},
},
{
id: "toggle-layer-visible",
section: "Layers",
title: selectedLayer?.layer.visible === false ? "Show selected layer" : "Hide selected layer",
subtitle: selectedLayer?.layer.name,
disabled: !selectedLayer,
icon: selectedLayer?.layer.visible === false ? <Eye size={20} /> : <EyeSlash size={20} />,
run: () => {
if (selectedLayer) dispatch(commandIds.documentSetLayerVisible, { layerId: selectedLayer.layer.id, visible: !selectedLayer.layer.visible });
},
},
{
id: "toggle-layer-lock",
section: "Layers",
title: selectedLayer?.layer.locked ? "Unlock selected layer" : "Lock selected layer",
subtitle: selectedLayer?.layer.name,
disabled: !selectedLayer,
icon: selectedLayer?.layer.locked ? <LockOpen size={20} /> : <Lock size={20} />,
run: () => {
if (selectedLayer) dispatch(commandIds.documentSetLayerLocked, { layerId: selectedLayer.layer.id, locked: !selectedLayer.layer.locked });
},
},
{
id: "delete-selection",
section: "Layers",
title: selectedLayer ? "Delete selected layer" : "Delete selected artboard",
subtitle: selectedLayer?.layer.name ?? activeArtboard?.name,
disabled: !selectedLayer && !selection.artboardId,
icon: <Trash size={20} />,
run: () => deleteSelection(selection, selectedLayer, dispatch),
},
);
items.push(
{
id: "open-generate",
section: "Generate",
title: "Open generate panel",
subtitle: tools.activeTool === "generate" ? "Current tool" : undefined,
keywords: ["ai"],
icon: <Sparkle size={20} />,
run: openGenerate,
},
...generateModeItems.map((modeItem) => ({
id: `generate-mode-${modeItem.mode}`,
section: "Generate",
title: modeItem.title,
subtitle: tools.generate.mode === modeItem.mode ? "Current mode" : undefined,
keywords: ["mode", modeItem.mode],
icon: <Sparkle size={20} />,
run: () => {
openGenerate();
dispatch(commandIds.toolSetGenerateSettings, { mode: modeItem.mode });
},
})),
{
id: "generate-random-seed",
section: "Generate",
title: "Use random seed",
subtitle: tools.generate.seed === -1 ? "Current seed" : `Seed ${tools.generate.seed}`,
keywords: ["seed"],
icon: <Sparkle size={20} />,
run: () => dispatch(commandIds.toolSetGenerateSettings, { seed: -1 }),
},
{
id: "clear-generation-candidates",
section: "Generate",
title: "Clear candidates",
subtitle: hasCandidates ? `${generation.candidates.length} candidate${generation.candidates.length === 1 ? "" : "s"}` : "No candidates",
disabled: !hasCandidates,
icon: <Trash size={20} />,
run: () => dispatch(commandIds.generationClearCandidates, undefined),
},
...generationCompareItems.map((compareItem) => ({
id: `generation-compare-${compareItem.mode}`,
section: "Generate",
title: compareItem.title,
subtitle: generation.compareMode === compareItem.mode ? "Current compare mode" : undefined,
disabled: !hasCandidates,
icon: <Sparkle size={20} />,
run: () => dispatch(commandIds.generationSetCompareMode, { mode: compareItem.mode }),
})),
);
items.push(
{
id: "zoom-in",
section: "Zoom",
title: "Zoom in",
subtitle: `${Math.round(viewport.zoom * 100)}%`,
icon: <Plus size={20} />,
run: () => dispatch(commandIds.viewportSetZoom, { zoom: viewport.zoom * 1.2 }),
},
{
id: "zoom-out",
section: "Zoom",
title: "Zoom out",
subtitle: `${Math.round(viewport.zoom * 100)}%`,
icon: <Minus size={20} />,
run: () => dispatch(commandIds.viewportSetZoom, { zoom: viewport.zoom / 1.2 }),
},
{
id: "zoom-100",
section: "Zoom",
title: "Zoom to 100%",
icon: <CornersOut size={20} />,
run: () => dispatch(commandIds.viewportSetZoom, { zoom: 1 }),
},
{
id: "fit-artboard",
section: "Zoom",
title: "Fit artboard",
subtitle: activeArtboard?.name,
disabled: !activeArtboard,
icon: <CornersOut size={20} />,
run: () => dispatch(commandIds.viewportFitArtboard, undefined),
},
);
items.push(
{
id: "debug-reset-viewport",
section: "Debug",
title: "Reset viewport",
icon: <CornersOut size={20} />,
run: () => dispatch(commandIds.viewportReset, undefined),
},
{
id: "debug-clear-selection",
section: "Debug",
title: "Clear selection",
subtitle: selection.layerIds.length > 0 || selection.artboardId ? undefined : "Nothing selected",
disabled: selection.layerIds.length === 0 && !selection.artboardId,
icon: <Cursor size={20} />,
run: () => dispatch(commandIds.selectionClear, undefined),
},
{
id: "debug-clear-candidates",
section: "Debug",
title: "Clear generation state",
disabled: !hasCandidates,
icon: <Trash size={20} />,
run: () => dispatch(commandIds.generationClearCandidates, undefined),
},
);
return items;
}
const generateModeItems: Array<{ mode: GenerateMode; title: string }> = [
{ mode: "text-to-image", title: "Text-to-image mode" },
{ mode: "image-to-image", title: "Image-to-image mode" },
{ mode: "inpaint", title: "Inpaint mode" },
{ mode: "outpaint", title: "Outpaint mode" },
];
const generationCompareItems: Array<{ mode: GenerationCompareMode; title: string }> = [
{ mode: "result", title: "Show result" },
{ mode: "before", title: "Show before" },
{ mode: "split", title: "Split compare" },
];
function filterItems(items: PaletteItem[], query: string) {
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
if (terms.length === 0) return items;
@@ -575,25 +213,6 @@ function filterItems(items: PaletteItem[], query: string) {
});
}
function toolIcon(tool: ToolId) {
switch (tool) {
case "select":
return <Cursor size={20} />;
case "generate":
return <Sparkle size={20} />;
case "brush":
return <PaintBrush size={20} />;
case "eraser":
return <Eraser size={20} />;
case "chromaKey":
return <DropHalf size={20} />;
case "magicWand":
return <MagicWand size={20} />;
case "pan":
return <Hand size={20} />;
}
}
function paletteItemClass(active: boolean, disabled: boolean) {
const base = "flex min-h-14 w-full items-center gap-3 rounded-xl px-3 py-2 text-left transition focus:outline-none";
if (disabled) return `${base} cursor-not-allowed text-white/30 opacity-45`;

View File

@@ -1,14 +1,16 @@
import type { GenerateSettings } from "@editor/tools";
import type { AppStore } from "@editor/store";
import type { GenerationResourcesState } from "@editor/state";
import { GenerateControls } from "./bottom-controls/GenerateControls";
export type GenerateSheetProps = {
settings: GenerateSettings;
resources: GenerationResourcesState;
open: boolean;
dispatch: AppStore["dispatch"];
};
export function GenerateSheet({ settings, open, dispatch }: GenerateSheetProps) {
export function GenerateSheet({ settings, resources, open, dispatch }: GenerateSheetProps) {
return (
<aside
aria-hidden={!open}
@@ -19,7 +21,7 @@ export function GenerateSheet({ settings, open, dispatch }: GenerateSheetProps)
>
{open ? (
<div className="subtle-scrollbar min-h-0 flex-1 overflow-auto py-4">
<GenerateControls settings={settings} dispatch={dispatch} />
<GenerateControls settings={settings} resources={resources} dispatch={dispatch} />
</div>
) : null}
</aside>

View File

@@ -0,0 +1,29 @@
import { useEffect, useState } from "react";
import type { GenerationJob, GenerationState } from "@editor/state";
export function currentGenerationJob(generation: GenerationState): GenerationJob | undefined {
return generation.jobs.find((job) => job.status === "running") ?? generation.jobs[0];
}
export function GenerationJobStatus({ generation, compact = false }: { generation: GenerationState; compact?: boolean }) {
const job = currentGenerationJob(generation);
const [, setTick] = useState(0);
useEffect(() => {
if (job?.status !== "running") return;
const interval = window.setInterval(() => setTick((tick) => tick + 1), 1000);
return () => window.clearInterval(interval);
}, [job?.id, job?.status]);
if (!job) return null;
const elapsed = Math.max(0, Math.floor(((job.finishedAt ?? Date.now()) - job.startedAt) / 1000));
const label = job.status === "running" ? `${job.label} ${formatElapsed(elapsed)}` : job.status === "failed" ? job.error ?? `${job.label} failed` : `${job.label} complete`;
const tone = job.status === "failed" ? "bg-red-500/15 text-red-100" : job.status === "running" ? "bg-white/10 text-white/70" : "bg-emerald-500/15 text-emerald-100";
return <span className={`${compact ? "max-w-56" : "max-w-80"} truncate rounded-full px-3 py-2 text-xs font-medium ${tone}`} title={label} aria-live="polite">{label}</span>;
}
function formatElapsed(seconds: number) {
const minutes = Math.floor(seconds / 60);
return `${minutes}:${(seconds % 60).toString().padStart(2, "0")}`;
}

View File

@@ -1,7 +1,6 @@
import { useEffect, useMemo, useRef, useState, type DragEvent, type MutableRefObject } from "react";
import { useMemo, useRef, useState, type DragEvent, type MutableRefObject } from "react";
import { ArrowDown, ArrowUp, DownloadSimple, Eye, EyeSlash, FolderPlus, Lock, LockOpen, Plus, Stack, Trash } from "@phosphor-icons/react";
import { commandIds } from "@commands/ids";
import type { Asset } from "@core/asset";
import type { ImageDocument } from "@core/document";
import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
@@ -10,9 +9,9 @@ import { createDocumentReadIndex, type DocumentReadIndex } from "@editor/documen
import type { MaskEditState, SelectionState } from "@editor/state";
import type { AppStore } from "@editor/store";
import { resolveLayerDrop } from "@input/index";
import { downloadArtboardPng } from "./exportArtboardPng";
import { addArtboard, addEmptyLayer, addGroupLayer, addLayerMask, deleteSelection, groupLayers, moveLayer } from "./layerActions";
import { analyzeMaskSource, applyMaskRasterOperation, type MaskAnalysis, type MaskRasterOperation } from "./mask/maskRaster";
import { downloadArtboardPng } from "@operations/export/downloadArtboard";
import { addArtboard, addEmptyLayer, addGroupLayer, addLayerMask, deleteSelection, groupLayers, moveLayer } from "@operations/document/layerActions";
import { MaskOperationButtons, MaskStatus } from "./layers/MaskControls";
export type LayersSheetProps = {
document: ImageDocument;
@@ -370,83 +369,6 @@ function LayerRow({
);
}
function MaskStatus({ asset }: { asset: Asset }) {
const [analysis, setAnalysis] = useState<MaskAnalysis>();
useEffect(() => {
let cancelled = false;
void analyzeMaskSource(asset.source, asset.intrinsicSize.w, asset.intrinsicSize.h)
.then((nextAnalysis) => {
if (!cancelled) setAnalysis(nextAnalysis);
})
.catch(() => {
if (!cancelled) setAnalysis(undefined);
});
return () => {
cancelled = true;
};
}, [asset.source, asset.intrinsicSize.w, asset.intrinsicSize.h]);
if (!analysis) return <span className="rounded-full bg-white/5 px-3 py-1 text-xs text-sky-100/45">Reading</span>;
return (
<span className="inline-flex min-w-0 items-center gap-2 rounded-full bg-white/5 px-2 py-1 text-xs text-sky-100/65">
<img src={analysis.thumbnail} alt="" className="h-7 w-10 rounded-md bg-black/30 object-cover ring-1 ring-white/10" />
<span className="whitespace-nowrap">Reveal {formatPercent(analysis.coverage)}</span>
<span className="whitespace-nowrap text-sky-100/45">Inpaint {formatPercent(analysis.hiddenCoverage)}</span>
</span>
);
}
function MaskOperationButtons({ maskLayerId, maskAsset, dispatch }: { maskLayerId: string; maskAsset: Asset; dispatch: AppStore["dispatch"] }) {
return (
<>
<MaskOperationButton label="Invert" title="Invert mask" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "invert" }} dispatch={dispatch} />
<MaskOperationButton label="White" title="Fill mask white" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "fill", fill: "white" }} dispatch={dispatch} />
<MaskOperationButton label="Black" title="Fill mask black" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "fill", fill: "black" }} dispatch={dispatch} />
<MaskOperationButton label="Clear" title="Clear mask" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "fill", fill: "clear" }} dispatch={dispatch} />
<MaskOperationButton label="Feather" title="Feather mask edge" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "feather", radius: 3 }} dispatch={dispatch} />
<MaskOperationButton label="Expand" title="Expand mask" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "expand", radius: 3 }} dispatch={dispatch} />
<MaskOperationButton label="Contract" title="Contract mask" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "contract", radius: 3 }} dispatch={dispatch} />
<MaskOperationButton label="Blur" title="Blur mask edge" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "blur", radius: 2 }} dispatch={dispatch} />
<MaskOperationButton label="Clean" title="Despeckle mask" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "despeckle", strength: 8 }} dispatch={dispatch} />
</>
);
}
function MaskOperationButton({
label,
title,
maskLayerId,
maskAsset,
operation,
dispatch,
}: {
label: string;
title: string;
maskLayerId: string;
maskAsset: Asset;
operation: MaskRasterOperation;
dispatch: AppStore["dispatch"];
}) {
const [busy, setBusy] = useState(false);
return (
<button
type="button"
className={maskActionButtonClass()}
disabled={busy}
title={title}
onClick={() => {
setBusy(true);
void applyMaskRasterOperation(maskAsset.source, maskAsset.intrinsicSize.w, maskAsset.intrinsicSize.h, operation)
.then((source) => dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId, source, mimeType: "image/png", operation }))
.finally(() => setBusy(false));
}}
>
{busy ? "..." : label}
</button>
);
}
type EditingTitle =
| { type: "artboard"; id: ArtboardId; draft: string }
| { type: "layer"; id: string; draft: string };
@@ -497,9 +419,5 @@ function labeledToolbarButtonClass() {
}
function maskActionButtonClass() {
return "h-8 rounded-full bg-white/5 px-3 text-xs font-medium text-sky-100/65 transition hover:bg-white/10 hover:text-sky-50 disabled:pointer-events-none disabled:opacity-35";
}
function formatPercent(value: number) {
return `${Math.round(Math.max(0, Math.min(1, value)) * 100)}%`;
return "rounded-full bg-white/5 px-2.5 py-1 text-[0.7rem] font-semibold text-white/60 transition hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-35";
}

View File

@@ -2,13 +2,10 @@ import { useEffect } from "react";
import { DropHalf } from "@phosphor-icons/react";
import { commandIds } from "@commands/ids";
import type { ImageDocument } from "@core/document";
import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
import { resolveTransformTargetBounds } from "@editor/transform-targets";
import type { AppStore } from "@editor/store";
import type { ChromaKeySettings } from "@editor/tools";
import type { SelectionState } from "@editor/state";
import { blurMaskValues, despeckleMaskValues, dilateMaskValues, erodeMaskValues } from "../mask/maskRaster";
import { applyChromaKeyMask, previewChromaKey, resolveChromaKeyTarget } from "@operations/masks/chromaKey";
import { BottomControlColorPicker } from "./ColorPicker";
import { BottomControlDivider } from "./Divider";
import { BottomControlSlider } from "./Slider";
@@ -38,7 +35,7 @@ export function ChromaKeyControls({ document, selection, settings, dispatch }: C
return;
}
void chromaKeySource(target.asset.source, target.asset.intrinsicSize.w, target.asset.intrinsicSize.h, settings).then((source) => {
void previewChromaKey(target.asset.source, target.asset.intrinsicSize.w, target.asset.intrinsicSize.h, settings).then((source) => {
if (cancelled) return;
dispatch(commandIds.toolSetBrushStrokePreview, { layerId: target.layer.id, assetId: target.asset.id, source });
});
@@ -153,161 +150,3 @@ export function ChromaKeyControls({ document, selection, settings, dispatch }: C
</div>
);
}
function resolveChromaKeyTarget(document: ImageDocument, selection: SelectionState) {
const layerId = selection.layerIds[0];
if (selection.layerIds.length !== 1 || !layerId) return undefined;
const layer = findLayer(document.artboards.find((artboard) => artboard.id === selection.artboardId)?.layers ?? [], layerId);
if (!layer || layer.type === "group") return undefined;
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id });
const layerMask = getLayerMask(layer);
const maskLayer = layerMask?.enabled ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerMask.maskLayerId) : undefined;
const maskAsset = maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
return asset && bounds ? { layer, asset, bounds, maskLayer, maskAsset } : undefined;
}
function findLayer(layers: readonly Layer[], layerId: string): Layer | undefined {
for (const layer of layers) {
if (layer.id === layerId) return layer;
if (layer.type === "group") {
const found = findLayer(layer.children, layerId);
if (found) return found;
}
}
return undefined;
}
async function applyChromaKeyMask(target: NonNullable<ReturnType<typeof resolveChromaKeyTarget>>, settings: ChromaKeySettings, dispatch: AppStore["dispatch"]) {
const source = await chromaKeyMaskSource(target.asset.source, target.asset.intrinsicSize.w, target.asset.intrinsicSize.h, settings);
dispatch(commandIds.toolSetBrushStrokePreview, undefined);
if (target.maskAsset && target.maskLayer && target.maskLayer.type !== "group") {
dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId: target.maskLayer.id, source, mimeType: "image/png", operation: { type: "chromaKey" } });
return;
}
const assetId = crypto.randomUUID();
const maskLayerId = crypto.randomUUID();
const width = Math.max(1, Math.round(target.asset.intrinsicSize.w));
const height = Math.max(1, Math.round(target.asset.intrinsicSize.h));
dispatch(commandIds.documentAddLayerMask, {
layerId: target.layer.id,
asset: {
id: assetId,
name: `${target.layer.name} Chroma Mask`,
mimeType: "image/png",
source,
intrinsicSize: { w: width, h: height },
},
maskLayer: {
id: maskLayerId,
type: "raster",
name: `${target.layer.name} Chroma Mask`,
visible: true,
locked: false,
opacity: 1,
assetId,
transform: {
position: { x: target.bounds.x, y: target.bounds.y },
scale: { x: target.bounds.w / width, y: target.bounds.h / height },
rotation: target.layer.transform.rotation,
},
},
});
dispatch(commandIds.toolExitMaskEdit, undefined);
dispatch(commandIds.toolSetActive, { tool: "chromaKey" });
}
async function chromaKeySource(source: string, width: number, height: number, settings: ChromaKeySettings) {
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(width));
canvas.height = Math.max(1, Math.round(height));
const context = canvas.getContext("2d");
if (!context) return source;
const image = await loadImage(source);
context.drawImage(image, 0, 0, canvas.width, canvas.height);
const data = context.getImageData(0, 0, canvas.width, canvas.height);
const alpha = chromaKeyAlpha(data, canvas.width, canvas.height, settings);
for (let pixel = 0; pixel < alpha.length; pixel++) data.data[pixel * 4 + 3] = alpha[pixel] ?? 255;
context.putImageData(data, 0, 0);
return canvas.toDataURL("image/png");
}
async function chromaKeyMaskSource(source: string, width: number, height: number, settings: ChromaKeySettings) {
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(width));
canvas.height = Math.max(1, Math.round(height));
const context = canvas.getContext("2d");
if (!context) return source;
const image = await loadImage(source);
context.drawImage(image, 0, 0, canvas.width, canvas.height);
const data = context.getImageData(0, 0, canvas.width, canvas.height);
const alpha = chromaKeyAlpha(data, canvas.width, canvas.height, settings);
for (let pixel = 0; pixel < alpha.length; pixel++) {
const index = pixel * 4;
data.data[index] = 255;
data.data[index + 1] = 255;
data.data[index + 2] = 255;
data.data[index + 3] = alpha[pixel] ?? 255;
}
context.putImageData(data, 0, 0);
return canvas.toDataURL("image/png");
}
function hexToRgb(color: string) {
const hex = color.replace("#", "");
return { r: Number.parseInt(hex.slice(0, 2), 16), g: Number.parseInt(hex.slice(2, 4), 16), b: Number.parseInt(hex.slice(4, 6), 16) };
}
function chromaKeyAlpha(data: ImageData, width: number, height: number, settings: ChromaKeySettings) {
const key = hexToRgb(settings.color);
const alpha = new Uint8ClampedArray(width * height);
for (let pixel = 0; pixel < alpha.length; pixel++) {
const index = pixel * 4;
const red = data.data[index] ?? 0;
const green = data.data[index + 1] ?? 0;
const blue = data.data[index + 2] ?? 0;
const sourceAlpha = data.data[index + 3] ?? 255;
alpha[pixel] = Math.round(sourceAlpha * chromaKeyKeepFactor(red, green, blue, key, settings));
}
return postProcessAlpha(alpha, width, height, settings);
}
function chromaKeyKeepFactor(red: number, green: number, blue: number, key: { r: number; g: number; b: number }, settings: ChromaKeySettings) {
const tolerance = Math.max(0, Math.min(255, settings.tolerance));
const softness = Math.max(0, Math.min(255, settings.softness));
const spill = Math.max(0, Math.min(100, settings.spill)) / 100;
const distance = Math.hypot(red - key.r, green - key.g, blue - key.b);
const edgeKeep = distance <= tolerance ? 0 : softness > 0 && distance < tolerance + softness ? (distance - tolerance) / softness : 1;
if (spill <= 0) return edgeKeep;
const dominant = key.g >= key.r && key.g >= key.b ? green : key.r >= key.b ? red : blue;
const neutral = key.g >= key.r && key.g >= key.b ? Math.max(red, blue) : key.r >= key.b ? Math.max(green, blue) : Math.max(red, green);
const spillAmount = Math.max(0, dominant - neutral) / 255;
return Math.max(0, Math.min(edgeKeep, 1 - spillAmount * spill));
}
function postProcessAlpha(alpha: Uint8ClampedArray, width: number, height: number, settings: ChromaKeySettings) {
let next = alpha;
const despeckle = Math.round(Math.max(0, Math.min(20, settings.despeckle)));
const choke = Math.round(Math.max(-20, Math.min(20, settings.choke)));
const feather = Math.round(Math.max(0, Math.min(20, settings.feather)));
if (despeckle > 0) next = despeckleMaskValues(next, width, height, despeckle);
if (choke > 0) next = erodeMaskValues(next, width, height, choke);
if (choke < 0) next = dilateMaskValues(next, width, height, -choke);
if (feather > 0) next = blurMaskValues(next, width, height, feather);
return next;
}
function loadImage(source: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("Failed to load image"));
image.src = source;
});
}

View File

@@ -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")}`;
}

View File

@@ -2,6 +2,7 @@ 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 { BottomControlSelectMenu, type BottomControlSelectOption } from "./SelectMenu";
@@ -43,56 +44,23 @@ const inpaintMaskedContentOptions = [
export type GenerateControlsProps = {
settings: GenerateSettings;
resources: GenerationResourcesState;
dispatch: AppStore["dispatch"];
};
type ComfyArchitectureOption = {
value: GenerateArchitecture;
label: string;
defaultModel: string;
models: string[];
supportedModes: GenerateMode[];
};
type ComfyOptionsResponse = {
architectures?: ComfyArchitectureOption[];
models?: string[];
textEncoders?: string[];
vaes?: string[];
samplers?: string[];
schedulers?: string[];
};
export function GenerateControls({ settings, dispatch }: GenerateControlsProps) {
const [comfyOptions, setComfyOptions] = useState<ComfyOptionsResponse>();
export function GenerateControls({ settings, resources, dispatch }: GenerateControlsProps) {
const comfyOptions = resources.options;
const [advancedOpen, setAdvancedOpen] = useState(false);
const [outpaintOpen, setOutpaintOpen] = useState(false);
const [inpaintOpen, setInpaintOpen] = useState(false);
const [sizeOpen, setSizeOpen] = useState(false);
const sizeRef = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string>();
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);
useEffect(() => {
let cancelled = false;
void fetch("/api/comfy/models")
.then((response) => response.ok ? response.json() : Promise.reject(new Error("Unable to load ComfyUI models")))
.then((body: ComfyOptionsResponse) => {
if (cancelled) return;
setComfyOptions(body);
})
.catch((reason: unknown) => {
if (!cancelled) setError(reason instanceof Error ? reason.message : "Unable to load ComfyUI models");
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!sizeOpen) return;
const close = (event: PointerEvent) => {
@@ -104,7 +72,7 @@ export function GenerateControls({ settings, dispatch }: GenerateControlsProps)
return (
<div className="grid gap-5 pb-2 tabular-nums">
{error ? <p className="rounded-full bg-red-500/10 px-3 py-2 text-xs text-red-200">{error}</p> : null}
{resources.error ? <p className="rounded-full bg-red-500/10 px-3 py-2 text-xs text-red-200">{resources.error}</p> : null}
<section className={panelSectionClass()}>
<label className="grid gap-2">
@@ -229,7 +197,7 @@ export function GenerateControls({ settings, dispatch }: GenerateControlsProps)
);
}
function resolveModelOptions(settings: GenerateSettings, comfyOptions: ComfyOptionsResponse | undefined): readonly BottomControlSelectOption<GenerateModel>[] {
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;
@@ -237,7 +205,7 @@ function resolveModelOptions(settings: GenerateSettings, comfyOptions: ComfyOpti
return values.map((model) => ({ value: model, label: model === "auto" ? "Auto" : model }));
}
function resolveSupportOptions(settings: GenerateSettings, comfyOptions: ComfyOptionsResponse | undefined) {
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),
@@ -249,7 +217,7 @@ function resolveStringOptions(values: string[] | undefined, current: string): re
return unique([...(values ?? []), current]).map((value) => ({ value, label: value }));
}
function resolveModeOptions(settings: GenerateSettings, comfyOptions: ComfyOptionsResponse | undefined): readonly BottomControlSelectOption<GenerateMode>[] {
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));

View File

@@ -1,291 +0,0 @@
import { commandIds } from "@commands/ids";
import type { ImageDocument } from "@core/document";
import type { Vec2D } from "@core/geometry";
import type { Layer } from "@core/layer";
import type { RasterLayer } from "@core/raster-layer";
import type { MaskEditState, SelectionState } from "@editor/state";
import { isPanInteractionMode, type ToolState } from "@editor/tools";
import type { AppStore } from "@editor/store";
export type BrushSession = {
layerId: string;
assetId: string;
width: number;
height: number;
canvas: HTMLCanvasElement;
context: CanvasRenderingContext2D;
ready: Promise<boolean>;
previousPoint: Vec2D;
mode: "brush" | "eraser";
changed?: boolean;
pending?: Promise<void>;
cancelled?: boolean;
previewClosed?: boolean;
previewRequested?: boolean;
previewInFlight?: boolean;
previewFrame?: number;
previewSource?: string;
};
export type BrushTargetEditorState = {
selection: SelectionState;
tools: Pick<ToolState, "activeTool" | "interactionMode">;
maskEdit?: MaskEditState;
};
export function beginBrushSession(document: ImageDocument, editor: BrushTargetEditorState, point: Vec2D): BrushSession | undefined {
const layer = resolveBrushTargetLayer(document, editor);
if (!layer || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
if (!asset) return undefined;
const canvas = globalThis.document.createElement("canvas");
canvas.width = Math.max(1, Math.round(asset.intrinsicSize.w));
canvas.height = Math.max(1, Math.round(asset.intrinsicSize.h));
const context = canvas.getContext("2d");
if (!context) return undefined;
const session: BrushSession = {
layerId: layer.id,
assetId: layer.assetId,
width: canvas.width,
height: canvas.height,
canvas,
context,
ready: Promise.resolve(false),
previousPoint: point,
mode: editor.tools.activeTool,
};
session.ready = initializeBrushSession(session, asset.source).catch(() => false);
return session;
}
export function canPreviewBrush(document: ImageDocument, editor: BrushTargetEditorState): boolean {
return Boolean(resolveBrushTargetLayer(document, editor));
}
export function brushUnavailableHint(document: ImageDocument, editor: BrushTargetEditorState): string | undefined {
if (isPanInteractionMode(editor.tools.interactionMode) || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
if (resolveBrushTargetLayer(document, editor)) return undefined;
const layerId = editor.maskEdit?.maskLayerId ?? editor.selection.layerIds[0];
if (!layerId) {
if (editor.selection.artboardId) return "Brushes paint layers, not artboards. Select or add a raster layer first.";
return "Select a raster layer or layer mask to paint.";
}
const layer = findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId);
if (!layer) return "Select a raster layer or layer mask to paint.";
if (layer.locked) return "Unlock this layer before painting.";
if (layer.type === "image") return "Image layers are non-destructive. Add a layer mask to paint or erase.";
if (layer.type === "group") return "Select a raster layer inside the group to paint.";
if (!editor.maskEdit && !layer.visible) return "Show this layer before painting.";
return "Select a raster layer or layer mask to paint.";
}
function resolveBrushTargetLayer(document: ImageDocument, editor: BrushTargetEditorState): RasterLayer | undefined {
if (isPanInteractionMode(editor.tools.interactionMode) || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
const editingMask = Boolean(editor.maskEdit);
const layerId = editor.maskEdit?.maskLayerId ?? editor.selection.layerIds[0];
if (!layerId) return undefined;
const layer = findRasterLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId);
if (!layer || layer.locked || (!editingMask && !layer.visible)) return undefined;
return layer;
}
export function updateBrushSession(options: {
store: AppStore;
session: BrushSession;
point: Vec2D;
color: string;
size: number;
hardness: number;
}): BrushSession {
const state = options.store.getState();
const layer = findRasterLayer(state.document.artboards.flatMap((artboard) => artboard.layers), options.session.layerId);
if (!layer || layer.assetId !== options.session.assetId) return options.session;
const from = options.session.previousPoint;
const to = options.point;
options.session.previousPoint = to;
options.session.pending = (options.session.pending ?? Promise.resolve())
.then(async () => {
if (options.session.cancelled) return;
if (!(await options.session.ready) || options.session.cancelled) return;
drawStrokeSegment({
context: options.session.context,
from: documentPointToAssetPoint(from, layer, options.session.width, options.session.height),
to: documentPointToAssetPoint(to, layer, options.session.width, options.session.height),
color: state.editor.maskEdit ? "#ffffff" : options.color,
size: options.size,
hardness: options.hardness,
mode: options.session.mode,
});
if (options.session.cancelled) return;
options.session.changed = true;
requestBrushStrokePreview({ store: options.store, session: options.session });
})
.catch(() => undefined);
return options.session;
}
export async function commitBrushSession(options: { store: AppStore; session: BrushSession }) {
await options.session.pending;
if (options.session.cancelled) return;
const source = options.session.changed ? canvasToDataUrl(options.session.canvas) : undefined;
if (source) {
const state = options.store.getState();
const maskEdit = state.editor.maskEdit;
if (maskEdit?.maskLayerId === options.session.layerId) {
options.store.dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId: maskEdit.maskLayerId, source, mimeType: "image/png", operation: { type: "paint" } });
} else {
options.store.dispatch(commandIds.documentUpdateAssetSource, { assetId: options.session.assetId, source });
}
}
options.store.dispatch(commandIds.toolSetBrushStrokePreview, undefined);
closeBrushStrokePreview(options.session);
}
export function cancelBrushSession(options: { store: AppStore; session: BrushSession }) {
options.session.cancelled = true;
options.store.dispatch(commandIds.toolSetBrushStrokePreview, undefined);
closeBrushStrokePreview(options.session);
}
function documentPointToAssetPoint(point: Vec2D, layer: RasterLayer, width: number, height: number): Vec2D {
return {
x: ((point.x - layer.transform.position.x) / Math.max(0.0001, layer.transform.scale.x) / width) * width,
y: ((point.y - layer.transform.position.y) / Math.max(0.0001, layer.transform.scale.y) / height) * height,
};
}
async function initializeBrushSession(session: BrushSession, source: string) {
const image = await loadImage(source);
if (session.cancelled) return false;
session.context.clearRect(0, 0, session.width, session.height);
session.context.drawImage(image, 0, 0, session.width, session.height);
return true;
}
function drawStrokeSegment(options: {
context: CanvasRenderingContext2D;
from: Vec2D;
to: Vec2D;
color: string;
size: number;
hardness: number;
mode: "brush" | "eraser";
}) {
const { context } = options;
const hardness = Math.max(0, Math.min(100, options.hardness)) / 100;
context.save();
context.globalCompositeOperation = options.mode === "eraser" ? "destination-out" : "source-over";
context.strokeStyle = options.color;
context.shadowColor = options.mode === "eraser" ? "rgba(0,0,0,1)" : options.color;
context.shadowBlur = (1 - hardness) * options.size;
context.lineWidth = options.size;
context.lineCap = "round";
context.lineJoin = "round";
context.beginPath();
context.moveTo(options.from.x, options.from.y);
context.lineTo(options.to.x, options.to.y);
context.stroke();
context.restore();
}
function requestBrushStrokePreview(options: { store: AppStore; session: BrushSession }) {
if (options.session.cancelled || options.session.previewClosed) return;
options.session.previewRequested = true;
if (options.session.previewFrame !== undefined || options.session.previewInFlight) return;
options.session.previewFrame = requestAnimationFrame(() => {
options.session.previewFrame = undefined;
void publishBrushStrokePreview(options);
});
}
async function publishBrushStrokePreview(options: { store: AppStore; session: BrushSession }) {
if (options.session.cancelled || options.session.previewClosed || !options.session.previewRequested) return;
options.session.previewRequested = false;
options.session.previewInFlight = true;
const source = await canvasToObjectUrl(options.session.canvas).catch(() => undefined);
options.session.previewInFlight = false;
if (!source) {
if (options.session.previewRequested) requestBrushStrokePreview(options);
return;
}
if (options.session.cancelled || options.session.previewClosed) {
URL.revokeObjectURL(source);
return;
}
const previousSource = options.session.previewSource;
options.session.previewSource = source;
options.store.dispatch(commandIds.toolSetBrushStrokePreview, { layerId: options.session.layerId, assetId: options.session.assetId, source });
if (previousSource) URL.revokeObjectURL(previousSource);
if (options.session.previewRequested) requestBrushStrokePreview(options);
}
function closeBrushStrokePreview(session: BrushSession) {
session.previewClosed = true;
if (session.previewFrame !== undefined) {
cancelAnimationFrame(session.previewFrame);
session.previewFrame = undefined;
}
if (session.previewSource) {
URL.revokeObjectURL(session.previewSource);
session.previewSource = undefined;
}
}
function canvasToObjectUrl(canvas: HTMLCanvasElement) {
return new Promise<string | undefined>((resolve) => {
canvas.toBlob((blob) => {
resolve(blob ? URL.createObjectURL(blob) : undefined);
}, "image/png");
});
}
function canvasToDataUrl(canvas: HTMLCanvasElement) {
try {
return canvas.toDataURL("image/png");
} catch {
return undefined;
}
}
function loadImage(source: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("Failed to load raster layer"));
image.src = source;
});
}
function findRasterLayer(layers: Layer[], layerId: string): RasterLayer | undefined {
const layer = findLayer(layers, layerId);
return layer?.type === "raster" ? layer : undefined;
}
function findLayer(layers: Layer[], layerId: string): Layer | undefined {
for (const layer of layers) {
if (layer.id === layerId) return layer;
if (layer.type === "group") {
const child = findLayer(layer.children, layerId);
if (child) return child;
}
}
return undefined;
}

View File

@@ -1,9 +1,7 @@
import type { InteractionMode } from "@editor/tools";
import { isPanInteractionMode } from "@editor/tools";
import type { CanvasInputState } from "./useCanvasInput";
export function canvasCursorClass(interactionMode: InteractionMode, input: CanvasInputState, hasBrushPreview = false, canBrush = true) {
if (input.isPanning) return "cursor-grabbing";
export function canvasCursorClass(interactionMode: InteractionMode, isPanning: boolean, hasBrushPreview = false, canBrush = true) {
if (isPanning) return "cursor-grabbing";
if (isPanInteractionMode(interactionMode)) return "cursor-grab";
if (interactionMode.type === "tool" && (interactionMode.tool === "brush" || interactionMode.tool === "eraser")) {
if (!canBrush) return "cursor-not-allowed";

View File

@@ -1,158 +0,0 @@
import { commandIds } from "@commands/ids";
import type { ImageDocument } from "@core/document";
import type { Vec2D } from "@core/geometry";
import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
import { resolveTransformTargetBounds } from "@editor/transform-targets";
import type { AppStore } from "@editor/store";
import type { EditorState } from "@editor/state";
import { blurMaskValues, despeckleMaskValues, dilateMaskValues, erodeMaskValues, maskValueFromRgba } from "../mask/maskRaster";
export async function applyMagicWandAt(store: AppStore, point: Vec2D, modeOverride?: EditorState["tools"]["magicWand"]["mode"]) {
const state = store.getState();
if (state.editor.tools.activeTool !== "magicWand") return false;
const target = resolveTarget(state.document, state.editor);
if (!target) return true;
const x = Math.floor((point.x - target.layer.transform.position.x) / Math.max(0.0001, target.layer.transform.scale.x));
const y = Math.floor((point.y - target.layer.transform.position.y) / Math.max(0.0001, target.layer.transform.scale.y));
if (x < 0 || y < 0 || x >= target.asset.intrinsicSize.w || y >= target.asset.intrinsicSize.h) return true;
const source = await createWandMask(target.asset.source, target.maskAsset?.source, Math.round(target.asset.intrinsicSize.w), Math.round(target.asset.intrinsicSize.h), x, y, { ...state.editor.tools.magicWand, mode: modeOverride ?? state.editor.tools.magicWand.mode });
if (target.maskAsset && target.maskLayer && target.maskLayer.type !== "group") {
store.dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId: target.maskLayer.id, source, mimeType: "image/png", operation: { type: "magicWand" } });
return true;
}
const assetId = crypto.randomUUID();
const maskLayerId = crypto.randomUUID();
const width = Math.max(1, Math.round(target.asset.intrinsicSize.w));
const height = Math.max(1, Math.round(target.asset.intrinsicSize.h));
store.dispatch(commandIds.documentAddLayerMask, {
layerId: target.layer.id,
asset: { id: assetId, name: `${target.layer.name} Wand Mask`, mimeType: "image/png", source, intrinsicSize: { w: width, h: height } },
maskLayer: { id: maskLayerId, type: "raster", name: `${target.layer.name} Wand Mask`, visible: true, locked: false, opacity: 1, assetId, transform: { position: { x: target.bounds.x, y: target.bounds.y }, scale: { x: target.bounds.w / width, y: target.bounds.h / height }, rotation: target.layer.transform.rotation } },
});
store.dispatch(commandIds.toolExitMaskEdit, undefined);
store.dispatch(commandIds.toolSetActive, { tool: "magicWand" });
return true;
}
function resolveTarget(document: ImageDocument, editor: EditorState) {
const layerId = editor.selection.layerIds[0];
if (!layerId || editor.selection.layerIds.length !== 1) return undefined;
const layer = findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId);
if (!layer || layer.type === "group") return undefined;
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id });
const layerMask = getLayerMask(layer);
const maskLayer = layerMask?.enabled ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerMask.maskLayerId) : undefined;
const maskAsset = maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
return asset && bounds ? { layer, asset, bounds, maskLayer, maskAsset } : undefined;
}
async function createWandMask(source: string, existingMaskSource: string | undefined, width: number, height: number, startX: number, startY: number, settings: EditorState["tools"]["magicWand"]) {
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, width);
canvas.height = Math.max(1, height);
const context = canvas.getContext("2d");
if (!context) return source;
const image = await loadImage(source);
context.drawImage(image, 0, 0, canvas.width, canvas.height);
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
const start = (startY * canvas.width + startX) * 4;
const key = [imageData.data[start] ?? 0, imageData.data[start + 1] ?? 0, imageData.data[start + 2] ?? 0];
const selected = postProcessSelection(settings.contiguous ? floodSelect(imageData, canvas.width, canvas.height, startX, startY, key, settings.tolerance) : globalSelect(imageData, key, settings.tolerance), canvas.width, canvas.height, settings);
const existingMask = existingMaskSource ? await loadMaskValues(existingMaskSource, canvas.width, canvas.height) : undefined;
for (let pixel = 0; pixel < selected.length; pixel++) {
const current = existingMask?.[pixel] ?? 255;
const selectionValue = selected[pixel] ?? 0;
const value = settings.mode === "add"
? Math.min(current, 255 - selectionValue)
: settings.mode === "subtract"
? Math.max(current, selectionValue)
: 255 - selectionValue;
const index = pixel * 4;
imageData.data[index] = 255;
imageData.data[index + 1] = 255;
imageData.data[index + 2] = 255;
imageData.data[index + 3] = value;
}
context.putImageData(imageData, 0, 0);
return canvas.toDataURL("image/png");
}
function floodSelect(data: ImageData, width: number, height: number, startX: number, startY: number, key: number[], tolerance: number) {
const selected = new Uint8Array(width * height);
const queue: Array<[number, number]> = [[startX, startY]];
while (queue.length) {
const [x, y] = queue.pop()!;
if (x < 0 || y < 0 || x >= width || y >= height) continue;
const pixel = y * width + x;
if (selected[pixel]) continue;
if (!matches(data, pixel, key, tolerance)) continue;
selected[pixel] = 1;
queue.push([x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]);
}
return selected;
}
function globalSelect(data: ImageData, key: number[], tolerance: number) {
const selected = new Uint8Array(data.width * data.height);
for (let pixel = 0; pixel < selected.length; pixel++) if (matches(data, pixel, key, tolerance)) selected[pixel] = 1;
return selected;
}
function matches(data: ImageData, pixel: number, key: number[], tolerance: number) {
const index = pixel * 4;
return Math.hypot((data.data[index] ?? 0) - (key[0] ?? 0), (data.data[index + 1] ?? 0) - (key[1] ?? 0), (data.data[index + 2] ?? 0) - (key[2] ?? 0)) <= tolerance;
}
function postProcessSelection(selected: Uint8Array, width: number, height: number, settings: EditorState["tools"]["magicWand"]) {
let next: Uint8ClampedArray = selectionToMaskValues(selected);
const despeckle = Math.round(Math.max(0, Math.min(20, settings.despeckle)));
const choke = Math.round(Math.max(-20, Math.min(20, settings.choke)));
const feather = Math.round(Math.max(0, Math.min(20, settings.feather)));
if (despeckle > 0) next = despeckleMaskValues(next, width, height, despeckle);
if (choke > 0) next = erodeMaskValues(next, width, height, choke);
if (choke < 0) next = dilateMaskValues(next, width, height, -choke);
if (feather > 0) next = blurMaskValues(next, width, height, feather);
return next;
}
function selectionToMaskValues(selected: Uint8Array) {
const values = new Uint8ClampedArray(selected.length);
for (let index = 0; index < selected.length; index += 1) values[index] = selected[index] ? 255 : 0;
return values;
}
async function loadMaskValues(source: string, width: number, height: number) {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) return undefined;
const image = await loadImage(source);
context.drawImage(image, 0, 0, width, height);
const data = context.getImageData(0, 0, width, height);
const values = new Uint8ClampedArray(width * height);
for (let pixel = 0; pixel < values.length; pixel++) values[pixel] = maskValueFromRgba(data.data, pixel * 4);
return values;
}
function findLayer(layers: readonly Layer[], layerId: string): Layer | undefined {
for (const layer of layers) {
if (layer.id === layerId) return layer;
if (layer.type === "group") {
const found = findLayer(layer.children, layerId);
if (found) return found;
}
}
return undefined;
}
function loadImage(source: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("Failed to load image"));
image.src = source;
});
}

View File

@@ -158,6 +158,7 @@ const visualEditorChanges: Array<[string, (state: AppState) => AppState]> = [
editor: {
...state.editor,
generation: {
...state.editor.generation,
candidates: [
{
id: "candidate",

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, type RefObject } from "react";
import { useEffect, useRef, type RefObject } from "react";
import { commandIds } from "@commands/ids";
import type { AppStore } from "@editor/store";
import { isPanInteractionMode } from "@editor/tools";
@@ -12,8 +12,8 @@ import {
pointerInputEventFromPointerEvent,
wheelInputEventFromWheelEvent,
} from "@input/index";
import { beginBrushSession, canPreviewBrush, cancelBrushSession, commitBrushSession, updateBrushSession, type BrushSession } from "./brush";
import { applyMagicWandAt } from "./magic-wand";
import { beginBrushSession, canPreviewBrush, cancelBrushSession, commitBrushSession, updateBrushSession, type BrushSession } from "@operations/paint/brush";
import { applyMagicWandAt } from "@operations/masks/magic-wand";
export type CanvasInputOptions = {
globalKeybindConsumer: GlobalKeybindConsumer;
@@ -21,16 +21,11 @@ export type CanvasInputOptions = {
globalWheelConsumer: GlobalWheelConsumer;
};
export type CanvasInputState = {
isPanning: boolean;
};
export function useCanvasInput(
canvasRef: RefObject<HTMLCanvasElement | null>,
store: AppStore,
options: CanvasInputOptions,
): CanvasInputState {
const [isPanning, setIsPanning] = useState(false);
) {
const brushSession = useRef<BrushSession | undefined>(undefined);
const brushSessionId = useRef(0);
@@ -115,7 +110,7 @@ export function useCanvasInput(
if (consumed) {
clearBrushPreview();
canvas.setPointerCapture(event.pointerId);
setIsPanning(true);
store.dispatch(commandIds.editorSetPointerSession, { type: "pan" });
event.preventDefault();
return;
}
@@ -203,7 +198,7 @@ export function useCanvasInput(
const consumed = panHandler.pointerUp(inputEvent);
if (!consumed) return;
setIsPanning(false);
store.dispatch(commandIds.editorSetPointerSession, undefined);
clearBrushPreview();
event.preventDefault();
};
@@ -250,7 +245,6 @@ export function useCanvasInput(
};
}, [canvasRef, options, store]);
return { isPanning };
}
function isEditableKeyboardTarget(target: EventTarget | null) {

View File

@@ -1,156 +0,0 @@
import type { Artboard } from "@core/artboard";
import type { Asset } from "@core/asset";
import type { Rect } from "@core/geometry";
import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
export async function downloadArtboardPng(artboard: Artboard, assets: readonly Asset[]) {
const width = Math.max(1, Math.round(artboard.bounds.w));
const height = Math.max(1, Math.round(artboard.bounds.h));
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) throw new Error("Canvas 2D is not available");
if (artboard.backgroundColor !== "transparent") {
context.fillStyle = artboard.backgroundColor;
context.fillRect(0, 0, width, height);
}
const maskLayerIds = collectMaskLayerIds(artboard.layers);
context.save();
context.translate(-artboard.bounds.x, -artboard.bounds.y);
for (const layer of renderStack(artboard.layers)) await drawLayer(context, layer, artboard.layers, assets, artboard.bounds, { maskLayerIds });
context.restore();
const url = canvas.toDataURL("image/png");
const link = document.createElement("a");
link.href = url;
link.download = `${safeFilename(artboard.name)}.png`;
link.click();
}
async function drawLayer(
context: CanvasRenderingContext2D,
layer: Layer,
layerTree: readonly Layer[],
assets: readonly Asset[],
artboardBounds: Rect,
options: { maskLayerIds: ReadonlySet<string>; ignoreOwnMask?: boolean },
) {
if (!layer.visible || (!options.ignoreOwnMask && options.maskLayerIds.has(layer.id))) return;
const layerMask = getLayerMask(layer);
if (!options.ignoreOwnMask && layerMask?.enabled) {
const maskLayer = findLayer(layerTree, layerMask.maskLayerId);
if (!maskLayer) return;
await drawMaskedLayer(context, layer, maskLayer, layerTree, assets, artboardBounds);
return;
}
context.save();
context.globalAlpha *= layer.opacity;
if (layer.type === "group") {
for (const child of renderStack(layer.children)) await drawLayer(context, child, layerTree, assets, artboardBounds, options);
context.restore();
return;
}
const asset = assets.find((candidate) => candidate.id === layer.assetId);
if (!asset) {
context.restore();
return;
}
const image = await loadImage(asset.source);
context.drawImage(
image,
layer.transform.position.x,
layer.transform.position.y,
asset.intrinsicSize.w * layer.transform.scale.x,
asset.intrinsicSize.h * layer.transform.scale.y,
);
context.restore();
}
async function drawMaskedLayer(
context: CanvasRenderingContext2D,
layer: Layer,
maskLayer: Layer,
layerTree: readonly Layer[],
assets: readonly Asset[],
artboardBounds: Rect,
) {
const layerCanvas = createArtboardCanvas(artboardBounds);
const layerContext = translatedContext(layerCanvas, artboardBounds);
await drawLayer(layerContext, layer, layerTree, assets, artboardBounds, { maskLayerIds: new Set(), ignoreOwnMask: true });
layerContext.restore();
const maskCanvas = createArtboardCanvas(artboardBounds);
const maskContext = translatedContext(maskCanvas, artboardBounds);
await drawLayer(maskContext, maskLayer, layerTree, assets, artboardBounds, { maskLayerIds: new Set(), ignoreOwnMask: true });
maskContext.restore();
const compositeContext = layerCanvas.getContext("2d");
if (!compositeContext) return;
compositeContext.globalCompositeOperation = "destination-in";
compositeContext.drawImage(maskCanvas, 0, 0);
compositeContext.globalCompositeOperation = "source-over";
context.drawImage(layerCanvas, artboardBounds.x, artboardBounds.y);
}
function renderStack(layers: readonly Layer[]) {
return [...layers].reverse();
}
function createArtboardCanvas(bounds: Rect) {
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(bounds.w));
canvas.height = Math.max(1, Math.round(bounds.h));
return canvas;
}
function translatedContext(canvas: HTMLCanvasElement, bounds: Rect) {
const context = canvas.getContext("2d");
if (!context) throw new Error("Canvas 2D is not available");
context.save();
context.translate(-bounds.x, -bounds.y);
return context;
}
function collectMaskLayerIds(layers: readonly Layer[], ids = new Set<string>()) {
for (const layer of layers) {
const layerMask = getLayerMask(layer);
if (layerMask) ids.add(layerMask.maskLayerId);
if (layer.type === "group") collectMaskLayerIds(layer.children, ids);
}
return ids;
}
function findLayer(layers: readonly Layer[], layerId: string): Layer | undefined {
for (const layer of layers) {
if (layer.id === layerId) return layer;
if (layer.type === "group") {
const child = findLayer(layer.children, layerId);
if (child) return child;
}
}
return undefined;
}
function loadImage(source: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("Failed to load image for export"));
image.src = source;
});
}
function safeFilename(name: string) {
return name.trim().replace(/[^a-z0-9-_]+/gi, "-").replace(/^-+|-+$/g, "") || "artboard";
}

View File

@@ -1,50 +0,0 @@
import type { ImageDocument } from "@core/document";
import type { GenerationCandidate } from "@editor/state";
import { loadImageCanvas, maskValueFromRgba } from "../mask/maskRaster";
export async function createMaskedPixelReplacementSource(document: ImageDocument, candidate: GenerationCandidate): Promise<string> {
if (!candidate.inpaint) throw new Error("Only inpaint candidates can replace masked pixels.");
const targetAsset = document.assets.find((asset) => asset.id === candidate.inpaint?.sourceAssetId);
if (!targetAsset) throw new Error("The source layer for this candidate no longer exists.");
const targetCanvas = await loadImageCanvas(targetAsset.source, targetAsset.intrinsicSize.w, targetAsset.intrinsicSize.h);
const generatedCanvas = await loadImageCanvas(candidate.source, candidate.width, candidate.height);
const maskCanvas = await loadImageCanvas(candidate.inpaint.maskImage, candidate.width, candidate.height);
const targetContext = require2dContext(targetCanvas);
const generatedContext = require2dContext(generatedCanvas);
const maskContext = require2dContext(maskCanvas);
const targetData = targetContext.getImageData(0, 0, targetCanvas.width, targetCanvas.height);
const generatedData = generatedContext.getImageData(0, 0, generatedCanvas.width, generatedCanvas.height);
const maskData = maskContext.getImageData(0, 0, maskCanvas.width, maskCanvas.height);
const crop = candidate.inpaint.crop.assetBounds;
for (let y = 0; y < candidate.height; y += 1) {
for (let x = 0; x < candidate.width; x += 1) {
const targetX = Math.round(crop.x) + x;
const targetY = Math.round(crop.y) + y;
if (targetX < 0 || targetY < 0 || targetX >= targetCanvas.width || targetY >= targetCanvas.height) continue;
const generatedIndex = (y * generatedCanvas.width + x) * 4;
const targetIndex = (targetY * targetCanvas.width + targetX) * 4;
const mask = maskValueFromRgba(maskData.data, generatedIndex) / 255;
if (mask <= 0) continue;
for (let channel = 0; channel < 4; channel += 1) {
const previous = targetData.data[targetIndex + channel] ?? 0;
const next = generatedData.data[generatedIndex + channel] ?? previous;
targetData.data[targetIndex + channel] = Math.round(previous * (1 - mask) + next * mask);
}
}
}
targetContext.putImageData(targetData, 0, 0);
return targetCanvas.toDataURL("image/png");
}
function require2dContext(canvas: HTMLCanvasElement): CanvasRenderingContext2D {
const context = canvas.getContext("2d");
if (!context) throw new Error("Unable to prepare generated candidate");
return context;
}

View File

@@ -1,36 +0,0 @@
import { describe, expect, test } from "bun:test";
import { applyMaskedContentModeToRgba } from "./inpaintPrep";
describe("inpaint prep", () => {
test("converts only masked original pixels to grayscale", () => {
const pixels = new Uint8ClampedArray([
255, 0, 0, 255,
0, 0, 255, 255,
]);
const mask = new Uint8ClampedArray([255, 0]);
const next = applyMaskedContentModeToRgba(pixels, 2, 1, mask, "original");
expect(next[0]).toBe(next[1]);
expect(next[1]).toBe(next[2]);
expect(Array.from(next.slice(4, 8))).toEqual([0, 0, 255, 255]);
});
test("uses an edge map inside the mask without recoloring unmasked context", () => {
const pixels = new Uint8ClampedArray([
255, 0, 0, 255,
0, 255, 0, 255,
0, 0, 255, 255,
255, 255, 0, 255,
]);
const mask = new Uint8ClampedArray([255, 0, 0, 0]);
const next = applyMaskedContentModeToRgba(pixels, 2, 2, mask, "edges");
expect(next[0]).toBe(next[1]);
expect(next[1]).toBe(next[2]);
expect(Array.from(next.slice(4, 8))).toEqual([0, 255, 0, 255]);
expect(Array.from(next.slice(8, 12))).toEqual([0, 0, 255, 255]);
expect(Array.from(next.slice(12, 16))).toEqual([255, 255, 0, 255]);
});
});

View File

@@ -1,260 +0,0 @@
import type { Asset } from "@core/asset";
import type { ImageDocument } from "@core/document";
import type { Rect } from "@core/geometry";
import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
import type { SelectionState } from "@editor/state";
import type { GenerateSettings } from "@editor/tools";
import { createDocumentReadIndex, resolveIndexedLayerBounds } from "@editor/document-indexes";
import { createNormalizedMaskSource, cropCanvas, cropMaskValuesToDataUrl, expandRectWithinBounds, loadImageCanvas } from "../mask/maskRaster";
export type InpaintBundle = {
inputImage: string;
maskImage: string;
width: number;
height: number;
targetLayerId: string;
maskLayerId: string;
sourceAssetId: string;
maskAssetId: string;
crop: {
assetBounds: Rect;
documentBounds: Rect;
padding: number;
maskedAreaOnly: boolean;
};
mask: {
polarity: GenerateSettings["inpaint"]["maskPolarity"];
activeBounds: Rect;
};
placement: {
artboardId: string;
layerName: string;
transform: Extract<Layer, { type: "image" | "raster" }>["transform"];
};
backend: {
growMaskBy: number;
maskedContent: GenerateSettings["inpaint"]["maskedContent"];
maskBlur: number;
maskFeather: number;
maskExpand: number;
cropPadding: number;
};
};
type InpaintTarget = {
artboardId: string;
layer: Extract<Layer, { type: "image" | "raster" }>;
asset: Asset;
bounds: Rect;
maskLayer: Extract<Layer, { type: "image" | "raster" }>;
maskAsset: Asset;
maskBounds: Rect;
};
const modelMultiple = 8;
const minModelSize = 64;
const maxModelSize = 4096;
export async function buildInpaintBundle(document: ImageDocument, selection: SelectionState, settings: GenerateSettings): Promise<InpaintBundle> {
const target = resolveInpaintTarget(document, selection);
validateInpaintTarget(target);
const width = Math.max(1, Math.round(target.asset.intrinsicSize.w));
const height = Math.max(1, Math.round(target.asset.intrinsicSize.h));
const normalizedMask = await createNormalizedMaskSource(target.maskAsset.source, width, height, {
polarity: settings.inpaint.maskPolarity,
expand: settings.inpaint.maskExpand,
feather: settings.inpaint.maskFeather,
blur: settings.inpaint.maskBlur,
despeckle: settings.inpaint.maskDespeckle,
});
if (!normalizedMask.bounds) throw new Error("The selected layer mask has no inpaint pixels.");
const crop = settings.inpaint.maskedAreaOnly
? expandRectWithinBounds(normalizedMask.bounds, settings.inpaint.cropPadding, { w: width, h: height }, modelMultiple, minModelSize)
: { x: 0, y: 0, w: width, h: height };
const outputWidth = toModelSize(crop.w, "width");
const outputHeight = toModelSize(crop.h, "height");
const inputCanvas = prepareMaskedContentInputCanvas(await loadImageCanvas(target.asset.source, width, height), normalizedMask.values, settings.inpaint.maskedContent);
const inputImage = cropCanvas(inputCanvas, crop, outputWidth, outputHeight);
const maskImage = cropMaskValuesToDataUrl(normalizedMask.values, width, height, crop, outputWidth, outputHeight);
const scaleX = target.bounds.w / width;
const scaleY = target.bounds.h / height;
const documentBounds = {
x: target.bounds.x + crop.x * scaleX,
y: target.bounds.y + crop.y * scaleY,
w: outputWidth * scaleX,
h: outputHeight * scaleY,
};
return {
inputImage,
maskImage,
width: outputWidth,
height: outputHeight,
targetLayerId: target.layer.id,
maskLayerId: target.maskLayer.id,
sourceAssetId: target.asset.id,
maskAssetId: target.maskAsset.id,
crop: {
assetBounds: crop,
documentBounds,
padding: settings.inpaint.cropPadding,
maskedAreaOnly: settings.inpaint.maskedAreaOnly,
},
mask: {
polarity: settings.inpaint.maskPolarity,
activeBounds: normalizedMask.bounds,
},
placement: {
artboardId: target.artboardId,
layerName: `${target.layer.name} inpaint`,
transform: {
position: { x: documentBounds.x, y: documentBounds.y },
scale: { x: documentBounds.w / outputWidth, y: documentBounds.h / outputHeight },
rotation: target.layer.transform.rotation,
},
},
backend: {
growMaskBy: settings.inpaint.growMaskBy,
maskedContent: settings.inpaint.maskedContent,
maskBlur: settings.inpaint.maskBlur,
maskFeather: settings.inpaint.maskFeather,
maskExpand: settings.inpaint.maskExpand,
cropPadding: settings.inpaint.cropPadding,
},
};
}
function resolveInpaintTarget(document: ImageDocument, selection: SelectionState): InpaintTarget {
if (selection.layerIds.length !== 1 || !selection.layerIds[0]) throw new Error("Select one image or raster layer to inpaint.");
const documentIndex = createDocumentReadIndex(document);
const layerInfo = documentIndex.layerInfoById.get(selection.layerIds[0]);
if (!layerInfo || layerInfo.layer.type === "group") throw new Error("Select one image or raster layer to inpaint.");
const asset = documentIndex.assetById.get(layerInfo.layer.assetId);
if (!asset) throw new Error("The selected layer is missing its source image.");
const layerMask = getLayerMask(layerInfo.layer);
if (!layerMask?.enabled) throw new Error("Add a layer mask before running inpaint.");
const maskLayer = documentIndex.layerById.get(layerMask.maskLayerId);
if (!maskLayer || maskLayer.type === "group") throw new Error("The selected layer mask is missing.");
const maskAsset = documentIndex.assetById.get(maskLayer.assetId);
if (!maskAsset) throw new Error("The selected layer mask is missing its image data.");
const bounds = resolveIndexedLayerBounds(documentIndex, layerInfo.layer);
const maskBounds = resolveIndexedLayerBounds(documentIndex, maskLayer);
if (!bounds || !maskBounds) throw new Error("Unable to resolve the selected layer and mask bounds.");
return { artboardId: layerInfo.artboardId, layer: layerInfo.layer, asset, bounds, maskLayer, maskAsset, maskBounds };
}
function validateInpaintTarget(target: InpaintTarget) {
if (target.asset.intrinsicSize.w <= 0 || target.asset.intrinsicSize.h <= 0) throw new Error("The selected image has an invalid size.");
if (target.maskAsset.intrinsicSize.w <= 0 || target.maskAsset.intrinsicSize.h <= 0) throw new Error("The selected mask has an invalid size.");
if (Math.round(target.asset.intrinsicSize.w) !== Math.round(target.maskAsset.intrinsicSize.w) || Math.round(target.asset.intrinsicSize.h) !== Math.round(target.maskAsset.intrinsicSize.h)) {
throw new Error("The selected layer and mask image sizes do not match.");
}
if (!rectsAligned(target.bounds, target.maskBounds) || Math.abs(target.layer.transform.rotation - target.maskLayer.transform.rotation) > 0.001) {
throw new Error("The selected layer and mask are not aligned.");
}
}
function rectsAligned(a: Rect, b: Rect): boolean {
return Math.abs(a.x - b.x) <= 0.5 && Math.abs(a.y - b.y) <= 0.5 && Math.abs(a.w - b.w) <= 0.5 && Math.abs(a.h - b.h) <= 0.5;
}
function toModelSize(value: number, axis: "width" | "height"): number {
const rounded = Math.max(minModelSize, Math.ceil(value / modelMultiple) * modelMultiple);
if (rounded > maxModelSize) throw new Error(`The inpaint ${axis} is too large for the model. Use masked-area inpaint or a smaller source.`);
return rounded;
}
function prepareMaskedContentInputCanvas(sourceCanvas: HTMLCanvasElement, maskValues: Uint8ClampedArray, mode: GenerateSettings["inpaint"]["maskedContent"]): HTMLCanvasElement {
if (mode === "neutral" || mode === "originalColor") return sourceCanvas;
const context = sourceCanvas.getContext("2d");
if (!context) return sourceCanvas;
const imageData = context.getImageData(0, 0, sourceCanvas.width, sourceCanvas.height);
imageData.data.set(applyMaskedContentModeToRgba(imageData.data, sourceCanvas.width, sourceCanvas.height, maskValues, mode));
context.putImageData(imageData, 0, 0);
return sourceCanvas;
}
export function applyMaskedContentModeToRgba(data: Uint8ClampedArray, width: number, height: number, maskValues: Uint8ClampedArray, mode: GenerateSettings["inpaint"]["maskedContent"]): Uint8ClampedArray {
const next = new Uint8ClampedArray(data);
if (mode === "neutral" || mode === "originalColor") return next;
const edgeValues = mode === "edges" ? createEdgeMapValues(data, width, height) : undefined;
for (let pixel = 0; pixel < width * height; pixel += 1) {
const amount = (maskValues[pixel] ?? 0) / 255;
if (amount <= 0) continue;
const index = pixel * 4;
const red = data[index] ?? 0;
const green = data[index + 1] ?? 0;
const blue = data[index + 2] ?? 0;
const target = edgeValues ? edgeValues[pixel] ?? 128 : luminance(red, green, blue);
next[index] = blendChannel(red, target, amount);
next[index + 1] = blendChannel(green, target, amount);
next[index + 2] = blendChannel(blue, target, amount);
}
return next;
}
function createEdgeMapValues(data: Uint8ClampedArray, width: number, height: number): Uint8ClampedArray {
const gray = new Float32Array(width * height);
const edges = new Uint8ClampedArray(width * height);
for (let pixel = 0; pixel < width * height; pixel += 1) {
const index = pixel * 4;
gray[pixel] = luminance(data[index] ?? 0, data[index + 1] ?? 0, data[index + 2] ?? 0);
}
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const gx =
-sampleGray(gray, width, height, x - 1, y - 1) +
sampleGray(gray, width, height, x + 1, y - 1) -
2 * sampleGray(gray, width, height, x - 1, y) +
2 * sampleGray(gray, width, height, x + 1, y) -
sampleGray(gray, width, height, x - 1, y + 1) +
sampleGray(gray, width, height, x + 1, y + 1);
const gy =
-sampleGray(gray, width, height, x - 1, y - 1) -
2 * sampleGray(gray, width, height, x, y - 1) -
sampleGray(gray, width, height, x + 1, y - 1) +
sampleGray(gray, width, height, x - 1, y + 1) +
2 * sampleGray(gray, width, height, x, y + 1) +
sampleGray(gray, width, height, x + 1, y + 1);
const magnitude = Math.hypot(gx, gy);
edges[y * width + x] = Math.round(clampNumber(128 + Math.max(0, magnitude - 24) * 0.75, 128, 255));
}
}
return edges;
}
function sampleGray(values: Float32Array, width: number, height: number, x: number, y: number): number {
const clampedX = Math.max(0, Math.min(width - 1, x));
const clampedY = Math.max(0, Math.min(height - 1, y));
return values[clampedY * width + clampedX] ?? 0;
}
function luminance(red: number, green: number, blue: number): number {
return Math.round(0.2126 * red + 0.7152 * green + 0.0722 * blue);
}
function blendChannel(previous: number, next: number, amount: number): number {
return Math.round(previous * (1 - amount) + next * amount);
}
function clampNumber(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}

View File

@@ -1,268 +0,0 @@
import { commandIds } from "@commands/ids";
import type { ImageDocument } from "@core/document";
import type { Transform } from "@core/geometry";
import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
import type { AppStore } from "@editor/store";
import type { GenerationCandidate, SelectionState, ViewportState } from "@editor/state";
import type { GenerateSettings } from "@editor/tools";
import { buildInpaintBundle, type InpaintBundle } from "./inpaintPrep";
export async function runGenerate(options: {
document: ImageDocument;
selection: SelectionState;
viewport: ViewportState;
settings: GenerateSettings;
dispatch: AppStore["dispatch"];
}) {
const { document, selection, settings, dispatch } = options;
const artboard = selection.artboardId ? document.artboards.find((candidate) => candidate.id === selection.artboardId) : document.artboards[0];
if (!artboard) return;
const target = resolveSelectedImage(document, selection);
const inpaintBundle = settings.mode === "inpaint" ? await buildInpaintBundle(document, selection, settings) : undefined;
const inputImage = inpaintBundle?.inputImage ?? (target && settings.mode !== "text-to-image" ? await imageSourceToDataUrl(target.asset.source) : undefined);
const maskImage = inpaintBundle?.maskImage;
const seed = resolveSeed(settings.seed);
const requestSettings = { ...settings, seed };
const width = inpaintBundle?.width ?? settings.width;
const height = inpaintBundle?.height ?? settings.height;
const generated = await requestGenerate({
settings: requestSettings,
width,
height,
inputImage,
maskImage,
inpaintBundle,
});
const intrinsicSize = await loadImageSize(generated.source);
const targetArtboardId = inpaintBundle?.placement.artboardId ?? artboard.id;
const placement = {
artboardId: targetArtboardId,
layerName: inpaintBundle?.placement.layerName ?? "Generated image",
transform: generatedLayerTransform(inpaintBundle, intrinsicSize),
};
dispatch(commandIds.generationAddCandidate, {
candidate: createGenerationCandidate({
source: generated.source,
mimeType: generated.mimeType,
intrinsicSize,
settings: requestSettings,
seed,
width,
height,
inputImage,
maskImage,
placement,
inpaintBundle,
}),
});
}
export async function runGenerateFromCandidate(options: {
candidate: GenerationCandidate;
settings?: GenerateSettings;
dispatch: AppStore["dispatch"];
}) {
const settings = options.settings ?? options.candidate.settings;
const seed = resolveSeed(settings.seed);
const requestSettings = { ...settings, seed };
const generated = await requestGenerate({
settings: requestSettings,
width: options.candidate.width,
height: options.candidate.height,
inputImage: options.candidate.inputImage,
maskImage: options.candidate.maskImage,
inpaintCandidate: options.candidate,
});
const intrinsicSize = await loadImageSize(generated.source);
options.dispatch(commandIds.generationAddCandidate, {
candidate: {
...options.candidate,
id: crypto.randomUUID(),
source: generated.source,
mimeType: generated.mimeType,
intrinsicSize,
settings: requestSettings,
seed,
},
});
}
function createGenerationCandidate(options: {
source: string;
mimeType: string;
intrinsicSize: { w: number; h: number };
settings: GenerateSettings;
seed: number;
width: number;
height: number;
inputImage?: string;
maskImage?: string;
placement: GenerationCandidate["placement"];
inpaintBundle?: InpaintBundle;
}): GenerationCandidate {
return {
id: crypto.randomUUID(),
source: options.source,
mimeType: options.mimeType,
intrinsicSize: options.intrinsicSize,
mode: options.settings.mode,
settings: options.settings,
seed: options.seed,
width: options.width,
height: options.height,
inputImage: options.inputImage,
maskImage: options.maskImage,
placement: options.placement,
inpaint: options.inpaintBundle
? {
targetLayerId: options.inpaintBundle.targetLayerId,
maskLayerId: options.inpaintBundle.maskLayerId,
sourceAssetId: options.inpaintBundle.sourceAssetId,
maskAssetId: options.inpaintBundle.maskAssetId,
inputImage: options.inpaintBundle.inputImage,
maskImage: options.inpaintBundle.maskImage,
crop: options.inpaintBundle.crop,
mask: options.inpaintBundle.mask,
backend: options.inpaintBundle.backend,
}
: undefined,
};
}
async function requestGenerate(options: {
settings: GenerateSettings;
width: number;
height: number;
inputImage?: string;
maskImage?: string;
inpaintBundle?: InpaintBundle;
inpaintCandidate?: GenerationCandidate;
}) {
const response = await fetch("/api/comfy/generate", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
architecture: options.settings.architecture,
mode: options.settings.mode,
model: options.settings.model,
textEncoder: options.settings.textEncoder,
vae: options.settings.vae,
prompt: options.settings.prompt,
negativePrompt: options.settings.negativePrompt,
strength: options.settings.strength,
steps: options.settings.steps,
cfg: options.settings.cfg,
seed: options.settings.seed,
sampler: options.settings.sampler,
scheduler: options.settings.scheduler,
width: options.width,
height: options.height,
outpaint: options.settings.outpaint,
inpaint: resolveInpaintRequest(options.inpaintBundle, options.inpaintCandidate, options.settings),
inputImage: options.inputImage,
maskImage: options.maskImage,
}),
});
if (!response.ok) throw new Error(await response.text());
return await response.json() as { source: string; mimeType: string };
}
function resolveInpaintRequest(inpaintBundle: InpaintBundle | undefined, inpaintCandidate: GenerationCandidate | undefined, settings: GenerateSettings) {
if (inpaintBundle) {
return {
growMaskBy: inpaintBundle.backend.growMaskBy,
maskedContent: inpaintBundle.backend.maskedContent,
maskBlur: inpaintBundle.backend.maskBlur,
maskFeather: inpaintBundle.backend.maskFeather,
maskExpand: inpaintBundle.backend.maskExpand,
cropPadding: inpaintBundle.backend.cropPadding,
maskPolarity: inpaintBundle.mask.polarity,
crop: inpaintBundle.crop,
placement: inpaintBundle.placement,
};
}
if (inpaintCandidate?.inpaint) {
return {
growMaskBy: inpaintCandidate.inpaint.backend.growMaskBy,
maskedContent: inpaintCandidate.inpaint.backend.maskedContent,
maskBlur: inpaintCandidate.inpaint.backend.maskBlur,
maskFeather: inpaintCandidate.inpaint.backend.maskFeather,
maskExpand: inpaintCandidate.inpaint.backend.maskExpand,
cropPadding: inpaintCandidate.inpaint.backend.cropPadding,
maskPolarity: inpaintCandidate.inpaint.mask.polarity,
crop: inpaintCandidate.inpaint.crop,
placement: inpaintCandidate.placement,
};
}
return settings.inpaint;
}
function generatedLayerTransform(inpaintBundle: InpaintBundle | undefined, intrinsicSize: { w: number; h: number }): Transform {
if (!inpaintBundle) return { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 };
return {
position: { ...inpaintBundle.placement.transform.position },
scale: {
x: (inpaintBundle.placement.transform.scale.x * inpaintBundle.width) / Math.max(1, intrinsicSize.w),
y: (inpaintBundle.placement.transform.scale.y * inpaintBundle.height) / Math.max(1, intrinsicSize.h),
},
rotation: inpaintBundle.placement.transform.rotation,
};
}
function resolveSeed(seed: number): number {
return seed < 0 ? Math.floor(Math.random() * 2 ** 32) : Math.round(seed);
}
function resolveSelectedImage(document: ImageDocument, selection: SelectionState) {
const layerId = selection.layerIds[0];
if (!layerId) return undefined;
const layer = findLayer(document.artboards.find((artboard) => artboard.id === selection.artboardId)?.layers ?? [], layerId);
if (!layer || layer.type === "group") return undefined;
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
const layerMask = getLayerMask(layer);
const maskLayer = layerMask?.enabled ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerMask.maskLayerId) : undefined;
const maskAsset = maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
return asset ? { layer, asset, maskAsset } : undefined;
}
function findLayer(layers: readonly Layer[], layerId: string): Layer | undefined {
for (const layer of layers) {
if (layer.id === layerId) return layer;
if (layer.type === "group") {
const found = findLayer(layer.children, layerId);
if (found) return found;
}
}
return undefined;
}
async function imageSourceToDataUrl(source: string) {
if (source.startsWith("data:")) return source;
const image = await loadImage(source);
const canvas = document.createElement("canvas");
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
const context = canvas.getContext("2d");
if (!context) throw new Error("Unable to read selected image");
context.drawImage(image, 0, 0);
return canvas.toDataURL("image/png");
}
function loadImageSize(source: string): Promise<{ w: number; h: number }> {
return loadImage(source).then((image) => ({ w: image.naturalWidth, h: image.naturalHeight }));
}
function loadImage(source: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("Failed to load image"));
image.src = source;
});
}

View File

@@ -1,159 +0,0 @@
import { commandIds } from "@commands/ids";
import type { ImageDocument } from "@core/document";
import type { ArtboardId } from "@core/id";
import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
import type { DocumentReadIndex, IndexedLayerInfo } from "@editor/document-indexes";
import { resolveIndexedLayerBounds } from "@editor/document-indexes";
import type { SelectionState } from "@editor/state";
import type { AppStore } from "@editor/store";
export function addArtboard(document: ImageDocument, dispatch: AppStore["dispatch"]) {
const index = document.artboards.length + 1;
dispatch(commandIds.documentAddArtboard, {
id: crypto.randomUUID(),
name: `Artboard ${index}`,
bounds: { x: (index - 1) * 40, y: (index - 1) * 40, w: 800, h: 600 },
});
}
export function addEmptyLayer(
document: ImageDocument,
artboardId: ArtboardId,
selectedLayer: IndexedLayerInfo | undefined,
dispatch: AppStore["dispatch"],
) {
const artboard = document.artboards.find((candidate) => candidate.id === artboardId);
if (!artboard) return;
const assetId = crypto.randomUUID();
const layerId = crypto.randomUUID();
const width = Math.max(1, Math.round(artboard.bounds.w));
const height = Math.max(1, Math.round(artboard.bounds.h));
const source = `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"></svg>`)}`;
dispatch(commandIds.documentAddAsset, {
asset: {
id: assetId,
name: "Empty Layer",
mimeType: "image/svg+xml",
source,
intrinsicSize: { w: width, h: height },
},
});
dispatch(commandIds.documentAddRasterLayer, {
artboardId,
parentGroupId: selectedLayer?.layer.type === "group" ? selectedLayer.layer.id : undefined,
layer: {
id: layerId,
type: "raster",
name: "Layer",
visible: true,
locked: false,
opacity: 1,
assetId,
transform: { position: { x: artboard.bounds.x, y: artboard.bounds.y }, scale: { x: 1, y: 1 }, rotation: 0 },
},
});
dispatch(commandIds.selectionSet, { artboardId, layerIds: [layerId] });
}
export function addGroupLayer(artboardId: ArtboardId, dispatch: AppStore["dispatch"]) {
dispatch(commandIds.documentAddGroupLayer, { artboardId, group: createGroup("Group") });
}
export function groupLayers(artboardId: ArtboardId, layerIds: string[], dispatch: AppStore["dispatch"]) {
dispatch(commandIds.documentGroupLayers, { artboardId, layerIds, group: createGroup("Group") });
}
export function deleteSelection(selection: SelectionState, selectedLayer: IndexedLayerInfo | undefined, dispatch: AppStore["dispatch"]) {
if (selectedLayer) {
dispatch(commandIds.documentRemoveLayer, { layerId: selectedLayer.layer.id });
return;
}
if (selection.artboardId) dispatch(commandIds.documentRemoveArtboard, { id: selection.artboardId });
}
export function moveLayer(documentIndex: DocumentReadIndex, info: IndexedLayerInfo, direction: -1 | 1, dispatch: AppStore["dispatch"]) {
const siblings = info.siblings;
const maskLayerIds = documentIndex.maskLayerIdsByLayerList.get(siblings) ?? emptyLayerIds;
const blocks = siblings.flatMap((layer, index) => {
if (maskLayerIds.has(layer.id)) return [];
const layerMask = getLayerMask(layer);
const maskIndex = layerMask ? siblings.findIndex((candidate) => candidate.id === layerMask.maskLayerId) : -1;
const start = maskIndex >= 0 ? Math.min(maskIndex, index) : index;
const end = maskIndex >= 0 ? Math.max(maskIndex, index) : index;
return [{ layerId: layer.id, start, end, size: end - start + 1 }];
});
const currentBlockIndex = blocks.findIndex((block) => block.layerId === info.layer.id);
const currentBlock = blocks[currentBlockIndex];
const targetBlock = blocks[currentBlockIndex + direction];
if (!currentBlock || !targetBlock) return;
const insertionIndex = direction === -1 ? targetBlock.start : targetBlock.end + 1;
const removedBeforeInsertion = currentBlock.end < insertionIndex ? currentBlock.size : currentBlock.start < insertionIndex ? insertionIndex - currentBlock.start : 0;
dispatch(commandIds.documentMoveLayer, {
layerId: info.layer.id,
toArtboardId: info.artboardId,
toParentGroupId: info.parentGroupId,
toIndex: insertionIndex - removedBeforeInsertion,
});
}
export function addLayerMask(documentIndex: DocumentReadIndex, layerInfo: IndexedLayerInfo, dispatch: AppStore["dispatch"]) {
const layer = layerInfo.layer;
if (layer.type === "group") return;
const asset = documentIndex.assetById.get(layer.assetId);
const bounds = resolveIndexedLayerBounds(documentIndex, layer);
if (!asset || !bounds) return;
const assetId = crypto.randomUUID();
const maskLayerId = crypto.randomUUID();
const width = Math.max(1, Math.round(asset.intrinsicSize.w));
const height = Math.max(1, Math.round(asset.intrinsicSize.h));
const source = `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><rect width="${width}" height="${height}" fill="white"/></svg>`)}`;
dispatch(commandIds.documentAddLayerMask, {
layerId: layer.id,
asset: {
id: assetId,
name: `${layer.name} Mask`,
mimeType: "image/svg+xml",
source,
intrinsicSize: { w: width, h: height },
},
maskLayer: {
id: maskLayerId,
type: "raster",
name: `${layer.name} Mask`,
visible: true,
locked: false,
opacity: 1,
assetId,
transform: {
position: { x: bounds.x, y: bounds.y },
scale: { x: bounds.w / width, y: bounds.h / height },
rotation: layer.transform.rotation,
},
},
});
}
const emptyLayerIds = new Set<string>();
function createGroup(name: string): Extract<Layer, { type: "group" }> {
return {
id: crypto.randomUUID(),
type: "group",
name,
visible: true,
locked: false,
opacity: 1,
transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 },
children: [],
};
}

View File

@@ -0,0 +1,84 @@
import { useEffect, useState } from "react";
import type { Asset } from "@core/asset";
import type { AppStore } from "@editor/store";
import { analyzeMask, runMaskOperation, type MaskAnalysis, type MaskRasterOperation } from "@operations/masks/rasterActions";
export function MaskStatus({ asset }: { asset: Asset }) {
const [analysis, setAnalysis] = useState<MaskAnalysis>();
useEffect(() => {
let cancelled = false;
void analyzeMask(asset)
.then((nextAnalysis) => {
if (!cancelled) setAnalysis(nextAnalysis);
})
.catch(() => {
if (!cancelled) setAnalysis(undefined);
});
return () => {
cancelled = true;
};
}, [asset.source, asset.intrinsicSize.w, asset.intrinsicSize.h]);
if (!analysis) return <span className="rounded-full bg-white/5 px-3 py-1 text-xs text-sky-100/45">Reading</span>;
return (
<span className="inline-flex min-w-0 items-center gap-2 rounded-full bg-white/5 px-2 py-1 text-xs text-sky-100/65">
<img src={analysis.thumbnail} alt="" className="h-7 w-10 rounded-md bg-black/30 object-cover ring-1 ring-white/10" />
<span className="whitespace-nowrap">Reveal {formatPercent(analysis.coverage)}</span>
<span className="whitespace-nowrap text-sky-100/45">Inpaint {formatPercent(analysis.hiddenCoverage)}</span>
</span>
);
}
export function MaskOperationButtons({ maskLayerId, maskAsset, dispatch }: { maskLayerId: string; maskAsset: Asset; dispatch: AppStore["dispatch"] }) {
return (
<>
<MaskOperationButton label="Invert" title="Invert mask" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "invert" }} dispatch={dispatch} />
<MaskOperationButton label="White" title="Fill mask white" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "fill", fill: "white" }} dispatch={dispatch} />
<MaskOperationButton label="Black" title="Fill mask black" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "fill", fill: "black" }} dispatch={dispatch} />
<MaskOperationButton label="Clear" title="Clear mask" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "fill", fill: "clear" }} dispatch={dispatch} />
<MaskOperationButton label="Feather" title="Feather mask edge" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "feather", radius: 3 }} dispatch={dispatch} />
<MaskOperationButton label="Expand" title="Expand mask" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "expand", radius: 3 }} dispatch={dispatch} />
<MaskOperationButton label="Contract" title="Contract mask" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "contract", radius: 3 }} dispatch={dispatch} />
<MaskOperationButton label="Blur" title="Blur mask edge" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "blur", radius: 2 }} dispatch={dispatch} />
<MaskOperationButton label="Clean" title="Despeckle mask" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "despeckle", strength: 8 }} dispatch={dispatch} />
</>
);
}
function MaskOperationButton({
label,
title,
maskLayerId,
maskAsset,
operation,
dispatch,
}: {
label: string;
title: string;
maskLayerId: string;
maskAsset: Asset;
operation: MaskRasterOperation;
dispatch: AppStore["dispatch"];
}) {
const [busy, setBusy] = useState(false);
return (
<button
type="button"
className={maskActionButtonClass()}
disabled={busy}
title={title}
onClick={() => {
setBusy(true);
void runMaskOperation(maskLayerId, maskAsset, operation, dispatch)
.finally(() => setBusy(false));
}}
>
{busy ? "..." : label}
</button>
);
}
function maskActionButtonClass() { return "rounded-full bg-white/5 px-2.5 py-1 text-[0.7rem] font-semibold text-white/60 transition hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-35"; }
function formatPercent(value: number) { return `${Math.round(value * 100)}%`; }

View File

@@ -1,40 +0,0 @@
import { describe, expect, test } from "bun:test";
import { cropMaskValuesToRgba, expandRectWithinBounds, invertMaskValues } from "./maskRaster";
describe("mask raster utilities", () => {
test("exports the normalized drawn mask without filling the whole crop", () => {
const revealedMask = new Uint8ClampedArray(4 * 3).fill(255);
revealedMask[1 * 4 + 2] = 0;
const inpaintMask = invertMaskValues(revealedMask);
const rgba = cropMaskValuesToRgba(inpaintMask, 4, 3, { x: 1, y: 0, w: 3, h: 3 }, 4, 4);
const activePixels = activeRedPixels(rgba);
expect(activePixels).toEqual([{ x: 1, y: 1 }]);
for (let pixel = 0; pixel < rgba.length / 4; pixel += 1) expect(rgba[pixel * 4 + 3]).toBe(255);
});
test("expands masked-area crops to the model minimum when possible", () => {
expect(expandRectWithinBounds({ x: 20, y: 20, w: 4, h: 4 }, 4, { w: 100, h: 100 }, 8, 64)).toEqual({
x: 0,
y: 0,
w: 64,
h: 64,
});
expect(expandRectWithinBounds({ x: 80, y: 80, w: 4, h: 4 }, 4, { w: 100, h: 100 }, 8, 64)).toEqual({
x: 36,
y: 36,
w: 64,
h: 64,
});
});
});
function activeRedPixels(rgba: Uint8ClampedArray) {
const width = 4;
const pixels: Array<{ x: number; y: number }> = [];
for (let pixel = 0; pixel < rgba.length / 4; pixel += 1) {
if ((rgba[pixel * 4] ?? 0) > 127) pixels.push({ x: pixel % width, y: Math.floor(pixel / width) });
}
return pixels;
}

View File

@@ -1,374 +0,0 @@
import type { Rect } from "@core/geometry";
export type MaskFill = "white" | "black" | "clear";
export type MaskRasterOperation =
| { type: "invert" }
| { type: "fill"; fill: MaskFill }
| { type: "feather"; radius: number }
| { type: "expand"; radius: number }
| { type: "contract"; radius: number }
| { type: "blur"; radius: number }
| { type: "despeckle"; strength: number };
export type MaskAnalysis = {
width: number;
height: number;
revealedPixels: number;
hiddenPixels: number;
coverage: number;
hiddenCoverage: number;
bounds?: Rect;
hiddenBounds?: Rect;
thumbnail: string;
};
export type NormalizedMaskOptions = {
polarity: "hidden" | "revealed";
expand?: number;
feather?: number;
blur?: number;
despeckle?: number;
};
export async function createSolidMaskSource(width: number, height: number, fill: MaskFill): Promise<string> {
const canvas = createCanvas(width, height);
const context = require2dContext(canvas);
context.clearRect(0, 0, canvas.width, canvas.height);
if (fill === "white") {
context.fillStyle = "#ffffff";
context.fillRect(0, 0, canvas.width, canvas.height);
} else if (fill === "black") {
context.fillStyle = "#000000";
context.fillRect(0, 0, canvas.width, canvas.height);
}
return canvas.toDataURL("image/png");
}
export async function applyMaskRasterOperation(source: string, width: number, height: number, operation: MaskRasterOperation): Promise<string> {
if (operation.type === "fill") return createSolidMaskSource(width, height, operation.fill);
const mask = await loadMaskValues(source, width, height);
const next = applyMaskValueOperation(mask.values, mask.width, mask.height, operation);
return maskValuesToDataUrl(next, mask.width, mask.height);
}
export async function analyzeMaskSource(source: string, width: number, height: number): Promise<MaskAnalysis> {
const mask = await loadMaskValues(source, width, height);
const revealed = analyzeValues(mask.values, mask.width, mask.height, false);
const hiddenValues = invertMaskValues(mask.values);
const hidden = analyzeValues(hiddenValues, mask.width, mask.height, false);
return {
width: mask.width,
height: mask.height,
revealedPixels: revealed.pixels,
hiddenPixels: hidden.pixels,
coverage: revealed.coverage,
hiddenCoverage: hidden.coverage,
bounds: revealed.bounds,
hiddenBounds: hidden.bounds,
thumbnail: maskValuesToDataUrl(mask.values, mask.width, mask.height, 72, 48),
};
}
export async function createNormalizedMaskSource(source: string, width: number, height: number, options: NormalizedMaskOptions): Promise<{ source: string; values: Uint8ClampedArray; bounds?: Rect }> {
const mask = await loadMaskValues(source, width, height);
let values = options.polarity === "hidden" ? invertMaskValues(mask.values) : new Uint8ClampedArray(mask.values);
const despeckle = Math.round(clampNumber(options.despeckle ?? 0, 0, 64));
const expand = Math.round(clampNumber(options.expand ?? 0, -256, 256));
const feather = Math.round(clampNumber(options.feather ?? 0, 0, 256));
const blur = Math.round(clampNumber(options.blur ?? 0, 0, 256));
if (despeckle > 0) values = despeckleMaskValues(values, mask.width, mask.height, despeckle);
if (expand > 0) values = dilateMaskValues(values, mask.width, mask.height, expand);
if (expand < 0) values = erodeMaskValues(values, mask.width, mask.height, -expand);
if (feather > 0) values = blurMaskValues(values, mask.width, mask.height, feather);
if (blur > 0) values = blurMaskValues(values, mask.width, mask.height, blur);
const analysis = analyzeValues(values, mask.width, mask.height, false);
return { source: maskValuesToDataUrl(values, mask.width, mask.height), values, bounds: analysis.bounds };
}
export async function loadImageCanvas(source: string, width?: number, height?: number): Promise<HTMLCanvasElement> {
const image = await loadImage(source);
const canvas = createCanvas(width ?? image.naturalWidth, height ?? image.naturalHeight);
const context = require2dContext(canvas);
context.clearRect(0, 0, canvas.width, canvas.height);
context.drawImage(image, 0, 0, canvas.width, canvas.height);
return canvas;
}
export async function imageSourceToPngDataUrl(source: string): Promise<string> {
if (source.startsWith("data:image/png;base64,")) return source;
const canvas = await loadImageCanvas(source);
return canvas.toDataURL("image/png");
}
export function cropCanvas(sourceCanvas: HTMLCanvasElement, crop: Rect, outputWidth = crop.w, outputHeight = crop.h): string {
const canvas = createCanvas(outputWidth, outputHeight);
const context = require2dContext(canvas);
context.clearRect(0, 0, outputWidth, outputHeight);
context.drawImage(sourceCanvas, crop.x, crop.y, crop.w, crop.h, 0, 0, crop.w, crop.h);
return canvas.toDataURL("image/png");
}
export function cropMaskValuesToDataUrl(values: Uint8ClampedArray, width: number, height: number, crop: Rect, outputWidth = crop.w, outputHeight = crop.h): string {
const canvas = createCanvas(outputWidth, outputHeight);
const context = require2dContext(canvas);
const imageData = context.createImageData(outputWidth, outputHeight);
imageData.data.set(cropMaskValuesToRgba(values, width, height, crop, outputWidth, outputHeight));
context.putImageData(imageData, 0, 0);
return canvas.toDataURL("image/png");
}
export function cropMaskValuesToRgba(values: Uint8ClampedArray, width: number, height: number, crop: Rect, outputWidth = crop.w, outputHeight = crop.h): Uint8ClampedArray {
const safeOutputWidth = Math.max(1, Math.round(outputWidth));
const safeOutputHeight = Math.max(1, Math.round(outputHeight));
const data = new Uint8ClampedArray(safeOutputWidth * safeOutputHeight * 4);
for (let pixel = 0; pixel < safeOutputWidth * safeOutputHeight; pixel += 1) data[pixel * 4 + 3] = 255;
for (let y = 0; y < Math.min(crop.h, safeOutputHeight); y += 1) {
for (let x = 0; x < Math.min(crop.w, safeOutputWidth); x += 1) {
const sourceX = crop.x + x;
const sourceY = crop.y + y;
if (sourceX < 0 || sourceY < 0 || sourceX >= width || sourceY >= height) continue;
const value = values[sourceY * width + sourceX] ?? 0;
const index = (y * safeOutputWidth + x) * 4;
data[index] = value;
data[index + 1] = value;
data[index + 2] = value;
}
}
return data;
}
export function expandRectWithinBounds(rect: Rect, padding: number, bounds: { w: number; h: number }, multiple = 1, minSize = 1): Rect {
const padded = Math.max(0, Math.round(padding));
let x1 = Math.max(0, Math.floor(rect.x) - padded);
let y1 = Math.max(0, Math.floor(rect.y) - padded);
let x2 = Math.min(bounds.w, Math.ceil(rect.x + rect.w) + padded);
let y2 = Math.min(bounds.h, Math.ceil(rect.y + rect.h) + padded);
const safeMinSize = Math.max(1, Math.round(minSize));
const targetWidth = Math.min(bounds.w, roundUp(Math.max(safeMinSize, x2 - x1), multiple));
const targetHeight = Math.min(bounds.h, roundUp(Math.max(safeMinSize, y2 - y1), multiple));
const extraWidth = targetWidth - (x2 - x1);
const extraHeight = targetHeight - (y2 - y1);
x1 = Math.max(0, x1 - Math.floor(extraWidth / 2));
y1 = Math.max(0, y1 - Math.floor(extraHeight / 2));
x2 = Math.min(bounds.w, x1 + targetWidth);
y2 = Math.min(bounds.h, y1 + targetHeight);
x1 = Math.max(0, x2 - targetWidth);
y1 = Math.max(0, y2 - targetHeight);
return { x: x1, y: y1, w: Math.max(1, x2 - x1), h: Math.max(1, y2 - y1) };
}
export function maskValueFromRgba(data: Uint8ClampedArray, index: number): number {
const red = data[index] ?? 0;
const green = data[index + 1] ?? 0;
const blue = data[index + 2] ?? 0;
const alpha = data[index + 3] ?? 0;
const luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue;
return Math.round((alpha * luminance) / 255);
}
export function applyMaskValueOperation(values: Uint8ClampedArray, width: number, height: number, operation: Exclude<MaskRasterOperation, { type: "fill" }>): Uint8ClampedArray {
switch (operation.type) {
case "invert":
return invertMaskValues(values);
case "feather":
case "blur":
return blurMaskValues(values, width, height, Math.round(clampNumber(operation.radius, 0, 256)));
case "expand":
return dilateMaskValues(values, width, height, Math.round(clampNumber(operation.radius, 0, 256)));
case "contract":
return erodeMaskValues(values, width, height, Math.round(clampNumber(operation.radius, 0, 256)));
case "despeckle":
return despeckleMaskValues(values, width, height, Math.round(clampNumber(operation.strength, 0, 64)));
}
}
export function invertMaskValues(values: Uint8ClampedArray): Uint8ClampedArray {
const next = new Uint8ClampedArray(values.length);
for (let index = 0; index < values.length; index += 1) next[index] = 255 - (values[index] ?? 0);
return next;
}
export function erodeMaskValues(values: Uint8ClampedArray, width: number, height: number, radius: number): Uint8ClampedArray {
const safeRadius = Math.round(clampNumber(radius, 0, 256));
if (safeRadius <= 0) return new Uint8ClampedArray(values);
const next = new Uint8ClampedArray(values.length);
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
let value = 255;
for (let oy = -safeRadius; oy <= safeRadius; oy += 1) {
for (let ox = -safeRadius; ox <= safeRadius; ox += 1) value = Math.min(value, values[clampInt(y + oy, 0, height - 1) * width + clampInt(x + ox, 0, width - 1)] ?? 0);
}
next[y * width + x] = value;
}
}
return next;
}
export function dilateMaskValues(values: Uint8ClampedArray, width: number, height: number, radius: number): Uint8ClampedArray {
const safeRadius = Math.round(clampNumber(radius, 0, 256));
if (safeRadius <= 0) return new Uint8ClampedArray(values);
const next = new Uint8ClampedArray(values.length);
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
let value = 0;
for (let oy = -safeRadius; oy <= safeRadius; oy += 1) {
for (let ox = -safeRadius; ox <= safeRadius; ox += 1) value = Math.max(value, values[clampInt(y + oy, 0, height - 1) * width + clampInt(x + ox, 0, width - 1)] ?? 0);
}
next[y * width + x] = value;
}
}
return next;
}
export function blurMaskValues(values: Uint8ClampedArray, width: number, height: number, radius: number): Uint8ClampedArray {
const safeRadius = Math.round(clampNumber(radius, 0, 256));
if (safeRadius <= 0) return new Uint8ClampedArray(values);
const next = new Uint8ClampedArray(values.length);
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
let total = 0;
let count = 0;
for (let oy = -safeRadius; oy <= safeRadius; oy += 1) {
for (let ox = -safeRadius; ox <= safeRadius; ox += 1) {
total += values[clampInt(y + oy, 0, height - 1) * width + clampInt(x + ox, 0, width - 1)] ?? 0;
count += 1;
}
}
next[y * width + x] = Math.round(total / count);
}
}
return next;
}
export function despeckleMaskValues(values: Uint8ClampedArray, width: number, height: number, strength: number): Uint8ClampedArray {
const safeStrength = Math.round(clampNumber(strength, 0, 64));
if (safeStrength <= 0) return new Uint8ClampedArray(values);
const radius = Math.max(1, Math.ceil(safeStrength / 6));
const threshold = Math.max(1, Math.round(safeStrength / 2));
const next = new Uint8ClampedArray(values);
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const index = y * width + x;
const visible = (values[index] ?? 0) > 127;
let same = 0;
for (let oy = -radius; oy <= radius; oy += 1) {
for (let ox = -radius; ox <= radius; ox += 1) {
if (ox === 0 && oy === 0) continue;
const sample = values[clampInt(y + oy, 0, height - 1) * width + clampInt(x + ox, 0, width - 1)] ?? 0;
if ((sample > 127) === visible) same += 1;
}
}
if (same <= threshold) next[index] = visible ? 0 : 255;
}
}
return next;
}
async function loadMaskValues(source: string, width: number, height: number): Promise<{ width: number; height: number; values: Uint8ClampedArray }> {
const canvas = await loadImageCanvas(source, width, height);
const context = require2dContext(canvas);
const data = context.getImageData(0, 0, canvas.width, canvas.height);
const values = new Uint8ClampedArray(canvas.width * canvas.height);
for (let pixel = 0; pixel < values.length; pixel += 1) values[pixel] = maskValueFromRgba(data.data, pixel * 4);
return { width: canvas.width, height: canvas.height, values };
}
function maskValuesToDataUrl(values: Uint8ClampedArray, width: number, height: number, outputWidth = width, outputHeight = height): string {
const canvas = createCanvas(outputWidth, outputHeight);
const context = require2dContext(canvas);
const imageData = context.createImageData(outputWidth, outputHeight);
for (let y = 0; y < outputHeight; y += 1) {
for (let x = 0; x < outputWidth; x += 1) {
const sourceX = Math.floor((x / outputWidth) * width);
const sourceY = Math.floor((y / outputHeight) * height);
const value = values[clampInt(sourceY, 0, height - 1) * width + clampInt(sourceX, 0, width - 1)] ?? 0;
const index = (y * outputWidth + x) * 4;
imageData.data[index] = 255;
imageData.data[index + 1] = 255;
imageData.data[index + 2] = 255;
imageData.data[index + 3] = value;
}
}
context.putImageData(imageData, 0, 0);
return canvas.toDataURL("image/png");
}
function analyzeValues(values: Uint8ClampedArray, width: number, height: number, includeSoftPixels: boolean): { pixels: number; coverage: number; bounds?: Rect } {
let pixels = 0;
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const value = values[y * width + x] ?? 0;
const active = includeSoftPixels ? value > 0 : value > 127;
if (!active) continue;
pixels += 1;
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x + 1);
maxY = Math.max(maxY, y + 1);
}
}
return {
pixels,
coverage: pixels / Math.max(1, width * height),
bounds: pixels > 0 ? { x: minX, y: minY, w: maxX - minX, h: maxY - minY } : undefined,
};
}
function createCanvas(width: number, height: number): HTMLCanvasElement {
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(width));
canvas.height = Math.max(1, Math.round(height));
return canvas;
}
function require2dContext(canvas: HTMLCanvasElement): CanvasRenderingContext2D {
const context = canvas.getContext("2d");
if (!context) throw new Error("Unable to create mask canvas");
return context;
}
function loadImage(source: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("Failed to load image"));
image.src = source;
});
}
function roundUp(value: number, multiple: number): number {
const safeMultiple = Math.max(1, Math.round(multiple));
return Math.ceil(value / safeMultiple) * safeMultiple;
}
function clampNumber(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
return Math.max(min, Math.min(max, value));
}
function clampInt(value: number, min: number, max: number): number {
return Math.round(Math.max(min, Math.min(max, value)));
}

358
view/paletteItems.tsx Normal file
View File

@@ -0,0 +1,358 @@
import { ArrowDown, ArrowUp, CornersOut, Cursor, DownloadSimple, DropHalf, Eraser, Eye, EyeSlash, FolderOpen, FolderPlus, Hand, Lock, LockOpen, MagicWand, Minus, PaintBrush, Plus, Sparkle, Stack, Trash } from "@phosphor-icons/react";
import type { ReactNode } from "react";
import { commandIds } from "@commands/ids";
import type { Artboard } from "@core/artboard";
import type { ImageDocument } from "@core/document";
import type { GenerationCompareMode, GenerationState, SelectionState, ViewportState } from "@editor/state";
import type { AppStore } from "@editor/store";
import { availableToolIds, type GenerateMode, type ToolId, type ToolState } from "@editor/tools";
import type { DocumentReadIndex, IndexedLayerInfo } from "@editor/document-indexes";
import { downloadArtboardPng } from "@operations/export/downloadArtboard";
import { addArtboard, addEmptyLayer, addGroupLayer, deleteSelection, groupLayers, moveLayer } from "@operations/document/layerActions";
import { labelForTool } from "./toolLabels";
export type PaletteItem = { id: string; title: string; section: string; subtitle?: string; keywords?: string[]; disabled?: boolean; icon: ReactNode; run: () => void };
export function createPaletteItems(options: {
document: ImageDocument;
documentIndex: DocumentReadIndex;
activeArtboard?: Artboard;
activeArtboardId?: string;
selectedLayer?: IndexedLayerInfo;
canGroup: boolean;
canUngroup: boolean;
selection: SelectionState;
viewport: ViewportState;
tools: ToolState;
generation: GenerationState;
layersOpen: boolean;
dispatch: AppStore["dispatch"];
openFilePicker: () => void;
openGenerate: () => void;
openLayers: () => void;
closeLayers: () => void;
}): PaletteItem[] {
const {
document,
documentIndex,
activeArtboard,
activeArtboardId,
selectedLayer,
canGroup,
canUngroup,
selection,
viewport,
tools,
generation,
layersOpen,
dispatch,
openFilePicker,
openGenerate,
openLayers,
closeLayers,
} = options;
const hasCandidates = generation.candidates.length > 0;
const items: PaletteItem[] = [];
items.push(
...availableToolIds.map((tool) => ({
id: `tool-${tool}`,
section: "Tools",
title: `Switch to ${labelForTool(tool)}`,
subtitle: tool === tools.activeTool ? "Current tool" : undefined,
keywords: [tool],
icon: toolIcon(tool),
run: () => {
if (tool === "generate") openGenerate();
else dispatch(commandIds.toolSetActive, { tool });
},
})),
);
items.push(
{
id: "import-image",
section: "File",
title: "Import image",
subtitle: "Add an image as a layer",
keywords: ["open", "file", "layer"],
icon: <FolderOpen size={20} />,
run: openFilePicker,
},
{
id: "export-artboard",
section: "File",
title: "Export artboard as PNG",
subtitle: activeArtboard ? activeArtboard.name : "No artboard selected",
keywords: ["download", "png"],
disabled: !activeArtboard,
icon: <DownloadSimple size={20} />,
run: () => {
if (activeArtboard) void downloadArtboardPng(activeArtboard, document.assets);
},
},
);
items.push(
{
id: layersOpen ? "close-layers" : "open-layers",
section: "Layers",
title: layersOpen ? "Close layers panel" : "Open layers panel",
keywords: ["panel", "stack"],
icon: <Stack size={20} weight={layersOpen ? "fill" : "regular"} />,
run: layersOpen ? closeLayers : openLayers,
},
{
id: "add-artboard",
section: "Layers",
title: "Add artboard",
icon: <Plus size={20} />,
run: () => addArtboard(document, dispatch),
},
{
id: "add-empty-layer",
section: "Layers",
title: "Add empty layer",
subtitle: activeArtboardId ? undefined : "No artboard available",
keywords: ["new", "raster"],
disabled: !activeArtboardId,
icon: <Plus size={20} />,
run: () => {
if (activeArtboardId) addEmptyLayer(document, activeArtboardId, selectedLayer, dispatch);
},
},
{
id: "add-group",
section: "Layers",
title: "Add group",
subtitle: activeArtboardId ? undefined : "No artboard available",
disabled: !activeArtboardId,
icon: <FolderPlus size={20} />,
run: () => {
if (activeArtboardId) addGroupLayer(activeArtboardId, dispatch);
},
},
{
id: "group-selection",
section: "Layers",
title: "Group selected layers",
subtitle: canGroup ? `${selection.layerIds.length} selected` : "Select one or more layers",
disabled: !canGroup,
icon: <Stack size={20} />,
run: () => {
if (selection.artboardId) groupLayers(selection.artboardId, selection.layerIds, dispatch);
},
},
{
id: "ungroup-selection",
section: "Layers",
title: "Ungroup selected group",
subtitle: selectedLayer?.layer.name,
disabled: !canUngroup || !selectedLayer,
icon: <Stack size={20} weight="fill" />,
run: () => {
if (selectedLayer?.layer.type === "group") dispatch(commandIds.documentUngroupLayer, { groupId: selectedLayer.layer.id });
},
},
{
id: "move-layer-up",
section: "Layers",
title: "Move selected layer up",
subtitle: selectedLayer?.layer.name,
disabled: !selectedLayer,
icon: <ArrowUp size={20} />,
run: () => {
if (selectedLayer) moveLayer(documentIndex, selectedLayer, -1, dispatch);
},
},
{
id: "move-layer-down",
section: "Layers",
title: "Move selected layer down",
subtitle: selectedLayer?.layer.name,
disabled: !selectedLayer,
icon: <ArrowDown size={20} />,
run: () => {
if (selectedLayer) moveLayer(documentIndex, selectedLayer, 1, dispatch);
},
},
{
id: "toggle-layer-visible",
section: "Layers",
title: selectedLayer?.layer.visible === false ? "Show selected layer" : "Hide selected layer",
subtitle: selectedLayer?.layer.name,
disabled: !selectedLayer,
icon: selectedLayer?.layer.visible === false ? <Eye size={20} /> : <EyeSlash size={20} />,
run: () => {
if (selectedLayer) dispatch(commandIds.documentSetLayerVisible, { layerId: selectedLayer.layer.id, visible: !selectedLayer.layer.visible });
},
},
{
id: "toggle-layer-lock",
section: "Layers",
title: selectedLayer?.layer.locked ? "Unlock selected layer" : "Lock selected layer",
subtitle: selectedLayer?.layer.name,
disabled: !selectedLayer,
icon: selectedLayer?.layer.locked ? <LockOpen size={20} /> : <Lock size={20} />,
run: () => {
if (selectedLayer) dispatch(commandIds.documentSetLayerLocked, { layerId: selectedLayer.layer.id, locked: !selectedLayer.layer.locked });
},
},
{
id: "delete-selection",
section: "Layers",
title: selectedLayer ? "Delete selected layer" : "Delete selected artboard",
subtitle: selectedLayer?.layer.name ?? activeArtboard?.name,
disabled: !selectedLayer && !selection.artboardId,
icon: <Trash size={20} />,
run: () => deleteSelection(selection, selectedLayer, dispatch),
},
);
items.push(
{
id: "open-generate",
section: "Generate",
title: "Open generate panel",
subtitle: tools.activeTool === "generate" ? "Current tool" : undefined,
keywords: ["ai"],
icon: <Sparkle size={20} />,
run: openGenerate,
},
...generateModeItems.map((modeItem) => ({
id: `generate-mode-${modeItem.mode}`,
section: "Generate",
title: modeItem.title,
subtitle: tools.generate.mode === modeItem.mode ? "Current mode" : undefined,
keywords: ["mode", modeItem.mode],
icon: <Sparkle size={20} />,
run: () => {
openGenerate();
dispatch(commandIds.toolSetGenerateSettings, { mode: modeItem.mode });
},
})),
{
id: "generate-random-seed",
section: "Generate",
title: "Use random seed",
subtitle: tools.generate.seed === -1 ? "Current seed" : `Seed ${tools.generate.seed}`,
keywords: ["seed"],
icon: <Sparkle size={20} />,
run: () => dispatch(commandIds.toolSetGenerateSettings, { seed: -1 }),
},
{
id: "clear-generation-candidates",
section: "Generate",
title: "Clear candidates",
subtitle: hasCandidates ? `${generation.candidates.length} candidate${generation.candidates.length === 1 ? "" : "s"}` : "No candidates",
disabled: !hasCandidates,
icon: <Trash size={20} />,
run: () => dispatch(commandIds.generationClearCandidates, undefined),
},
...generationCompareItems.map((compareItem) => ({
id: `generation-compare-${compareItem.mode}`,
section: "Generate",
title: compareItem.title,
subtitle: generation.compareMode === compareItem.mode ? "Current compare mode" : undefined,
disabled: !hasCandidates,
icon: <Sparkle size={20} />,
run: () => dispatch(commandIds.generationSetCompareMode, { mode: compareItem.mode }),
})),
);
items.push(
{
id: "zoom-in",
section: "Zoom",
title: "Zoom in",
subtitle: `${Math.round(viewport.zoom * 100)}%`,
icon: <Plus size={20} />,
run: () => dispatch(commandIds.viewportSetZoom, { zoom: viewport.zoom * 1.2 }),
},
{
id: "zoom-out",
section: "Zoom",
title: "Zoom out",
subtitle: `${Math.round(viewport.zoom * 100)}%`,
icon: <Minus size={20} />,
run: () => dispatch(commandIds.viewportSetZoom, { zoom: viewport.zoom / 1.2 }),
},
{
id: "zoom-100",
section: "Zoom",
title: "Zoom to 100%",
icon: <CornersOut size={20} />,
run: () => dispatch(commandIds.viewportSetZoom, { zoom: 1 }),
},
{
id: "fit-artboard",
section: "Zoom",
title: "Fit artboard",
subtitle: activeArtboard?.name,
disabled: !activeArtboard,
icon: <CornersOut size={20} />,
run: () => dispatch(commandIds.viewportFitArtboard, undefined),
},
);
items.push(
{
id: "debug-reset-viewport",
section: "Debug",
title: "Reset viewport",
icon: <CornersOut size={20} />,
run: () => dispatch(commandIds.viewportReset, undefined),
},
{
id: "debug-clear-selection",
section: "Debug",
title: "Clear selection",
subtitle: selection.layerIds.length > 0 || selection.artboardId ? undefined : "Nothing selected",
disabled: selection.layerIds.length === 0 && !selection.artboardId,
icon: <Cursor size={20} />,
run: () => dispatch(commandIds.selectionClear, undefined),
},
{
id: "debug-clear-candidates",
section: "Debug",
title: "Clear generation state",
disabled: !hasCandidates,
icon: <Trash size={20} />,
run: () => dispatch(commandIds.generationClearCandidates, undefined),
},
);
return items;
}
const generateModeItems: Array<{ mode: GenerateMode; title: string }> = [
{ mode: "text-to-image", title: "Text-to-image mode" },
{ mode: "image-to-image", title: "Image-to-image mode" },
{ mode: "inpaint", title: "Inpaint mode" },
{ mode: "outpaint", title: "Outpaint mode" },
];
const generationCompareItems: Array<{ mode: GenerationCompareMode; title: string }> = [
{ mode: "result", title: "Show result" },
{ mode: "before", title: "Show before" },
{ mode: "split", title: "Split compare" },
];
function toolIcon(tool: ToolId) {
switch (tool) {
case "select":
return <Cursor size={20} />;
case "generate":
return <Sparkle size={20} />;
case "brush":
return <PaintBrush size={20} />;
case "eraser":
return <Eraser size={20} />;
case "chromaKey":
return <DropHalf size={20} />;
case "magicWand":
return <MagicWand size={20} />;
case "pan":
return <Hand size={20} />;
}
}

View File

@@ -1,57 +1,15 @@
import { useCallback, useEffect, useRef } from "react";
import { commandIds } from "@commands/ids";
import type { AppStore } from "@editor/store";
import { importImageAsLayer } from "@operations/import/importImage";
import { decodeBrowserImageFile } from "@platform/browser/imageFiles";
export function useImageImport(store: AppStore) {
const inputRef = useRef<HTMLInputElement | null>(null);
const importFile = useCallback(
async (file: File) => {
if (!file.type.startsWith("image/")) return;
const source = URL.createObjectURL(file);
const intrinsicSize = await loadImageSize(source);
const state = store.getState();
const artboard = state.editor.selection.artboardId
? state.document.artboards.find((candidate) => candidate.id === state.editor.selection.artboardId)
: state.document.artboards[0];
if (!artboard) {
URL.revokeObjectURL(source);
return;
}
const assetId = crypto.randomUUID();
const layerId = crypto.randomUUID();
const center = state.editor.viewport.center;
store.dispatch(commandIds.documentAddAsset, {
asset: {
id: assetId,
name: file.name,
mimeType: file.type,
source,
intrinsicSize,
},
});
store.dispatch(commandIds.documentAddImageLayer, {
artboardId: artboard.id,
layer: {
id: layerId,
type: "image",
name: file.name,
visible: true,
locked: false,
opacity: 1,
assetId,
transform: {
position: { x: center.x - intrinsicSize.w / 2, y: center.y - intrinsicSize.h / 2 },
scale: { x: 1, y: 1 },
rotation: 0,
},
},
});
store.dispatch(commandIds.selectionSet, { artboardId: artboard.id, layerIds: [layerId] });
const image = await decodeBrowserImageFile(file);
if (image) importImageAsLayer(store, image);
},
[store],
);
@@ -97,12 +55,3 @@ export function useImageImport(store: AppStore) {
return { input, openFilePicker, importFile };
}
function loadImageSize(source: string): Promise<{ w: number; h: number }> {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve({ w: image.naturalWidth, h: image.naturalHeight });
image.onerror = () => reject(new Error("Failed to load image"));
image.src = source;
});
}