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:
61
view/App.tsx
61
view/App.tsx
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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>
|
||||
|
||||
29
view/GenerationJobStatus.tsx
Normal file
29
view/GenerationJobStatus.tsx
Normal 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")}`;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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")}`;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -158,6 +158,7 @@ const visualEditorChanges: Array<[string, (state: AppState) => AppState]> = [
|
||||
editor: {
|
||||
...state.editor,
|
||||
generation: {
|
||||
...state.editor.generation,
|
||||
candidates: [
|
||||
{
|
||||
id: "candidate",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
84
view/layers/MaskControls.tsx
Normal file
84
view/layers/MaskControls.tsx
Normal 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)}%`; }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
358
view/paletteItems.tsx
Normal 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} />;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user