Compare commits

...

44 Commits

Author SHA1 Message Date
syntaxbullet
38565382c3 feat: add feather brush tool with adjustable settings and blending functionality
- Implemented feather brush tool in the brushRaster module, allowing for feathered edges in brush strokes.
- Added new FeatherControls component for UI adjustments of feather settings including size, radius, strength, and smoothing.
- Updated brush preview logic to accommodate feather tool alongside existing brush and eraser tools.
- Enhanced layer rendering to support feather mask previews and interactions.
- Introduced blending logic for feathered strokes to mix blurred mask values with original pixels.
- Added unit tests for feather blending functionality and tool keybindings.
- Updated cursor handling to reflect feather tool usage.
2026-07-11 22:08:25 +02:00
syntaxbullet
95043dfbdd feat: enhance ControlNet integration with Xinsir-compatible pose rendering and update model handling 2026-07-11 19:46:02 +02:00
syntaxbullet
ff762b8f17 feat: add inpaint region functionality and related tools
- Enhanced cursor behavior for new tools: semantic select, mask lasso, and mask rectangle.
- Updated mask edit state to include mask asset ID and kind.
- Implemented inpaint region commands for adding, applying, and removing inpaint regions.
- Introduced new operations for lasso and semantic selection tools.
- Created UI components for candidate review and inpaint region management.
- Added tests for inpaint region commands to ensure functionality.
- Updated various components to support new inpaint features and improve user experience.
2026-07-11 16:41:22 +02:00
syntaxbullet
f4e13b80e7 feat: add script to launch Image Studio with local ComfyUI server 2026-07-11 15:11:22 +02:00
syntaxbullet
5915c62a9a refactor: Update bottom controls for improved styling and functionality
- Adjusted button styles across various components for consistency and better UX.
- Enhanced layout of action controls to utilize whitespace more effectively.
- Updated slider styles for a more modern appearance and improved usability.
- Refined input fields and labels for better accessibility and readability.
- Introduced new app surface styles for a cohesive design across the application.
- Added tests for canvas cursor behavior to ensure correct cursor display during operations.
2026-07-11 14:55:32 +02:00
syntaxbullet
37aa719047 feat: add first-class text layers 2026-07-11 12:38:34 +02:00
syntaxbullet
606426c885 feat: add non-destructive adjustment layers 2026-07-11 12:31:06 +02:00
syntaxbullet
b928fb599b feat: add layer and mask thumbnails 2026-07-11 12:20:26 +02:00
syntaxbullet
9fb3a19912 feat: add crop and canvas resize workflows 2026-07-11 12:16:33 +02:00
syntaxbullet
4d7358af4b feat: add contextual layer inspector 2026-07-11 12:07:36 +02:00
syntaxbullet
bef2fb3051 feat: unify AI edit workflow 2026-07-11 11:58:16 +02:00
syntaxbullet
49b029d60a feat: update project name to 'image-studio' and enhance document title in App component 2026-07-11 11:38:59 +02:00
syntaxbullet
03493a1c32 feat: add generation cancel job functionality and improve job handling
- Introduced `generationCancelJob` command ID in `commands/ids.ts`.
- Added `GenerationCancelJobPayload` type in `commands/payloads.ts`.
- Enhanced job status to include "cancelled" in `editor/state.ts`.
- Updated `runGenerationJob` to accept an `AbortSignal` and handle cancellation.
- Implemented cancellation logic in `runGenerate` and related functions.
- Added tests for job cancellation in `operations/generation/workflow.test.ts`.
- Improved layer rendering logic to prevent stack overflow in `editor/document-indexes.ts`.
- Added raster size assertions in `platform/browser/rasterLimits.ts` for image processing limits.
- Enhanced image file handling to check for size limits in `platform/browser/imageFiles.ts`.
- Updated UI components to reflect job cancellation state in `view/GenerationJobStatus.tsx` and `view/bottom-controls/GenerateActionControls.tsx`.
2026-07-11 11:37:21 +02:00
syntaxbullet
d8fdd43416 feat: enhance accessibility by adding ARIA attributes and focus styles; improve canvas and button elements 2026-07-11 11:28:57 +02:00
syntaxbullet
53cf25c132 feat: implement document actions for managing artboards, zoom, and history; update UI components to utilize new actions 2026-07-11 11:26:27 +02:00
syntaxbullet
1236b6dd2f feat: add commands for clearing candidates and reusing candidate settings, update UI controls 2026-07-11 11:23:25 +02:00
syntaxbullet
556fd685a2 feat: implement chroma key operation and related keybinds, update workspace panel management 2026-07-11 00:09:41 +02:00
syntaxbullet
51a54dbdb2 feat: implement generation workflow with resource loading and candidate management 2026-07-10 23:54:52 +02:00
syntaxbullet
9697075d29 feat: add project management features including open/save functionality and recovery support 2026-07-10 23:46:01 +02:00
syntaxbullet
6d652c3264 feat: implement resolveGeneratedOutputPlacement function and add tests for output placement logic 2026-07-10 23:29:03 +02:00
syntaxbullet
06f2898080 feat: implement generation preconditions check and integrate with generation commands 2026-07-10 23:25:24 +02:00
syntaxbullet
678a0a40a7 refactor: update product audit with design recommendations and clarify candidate session handling 2026-07-10 23:23:07 +02:00
syntaxbullet
5e4b548ad4 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.
2026-07-10 23:15:02 +02:00
syntaxbullet
41bbd1e16b feat: refine candidate handling in generation commands and update UI interactions 2026-07-09 22:00:23 +02:00
syntaxbullet
317a7bbf5f feat: enhance Comfy API handling and add tests for branded non-Z diffusion models 2026-07-09 21:56:25 +02:00
syntaxbullet
c3a75cf0d6 feat: add command palette functionality and shortcuts
- Implemented command palette with keyboard shortcut (⌘K) for opening.
- Added commands for opening, closing, setting query, and selecting items in the command palette.
- Created tests for command palette commands and input handling.
- Enhanced layer actions with functions for adding, grouping, and deleting layers.
- Updated UI components to integrate command palette and shortcuts.
2026-07-05 17:15:20 +02:00
syntaxbullet
5eaf37ba28 feat: enhance layer mask handling and opacity management
- Introduced `getLayerMask` utility to streamline layer mask retrieval.
- Updated layer rendering logic to incorporate layer masks and opacity adjustments.
- Added functionality to prevent dropping a group into its descendants.
- Enhanced image texture rendering to support opacity parameters across various rendering functions.
- Implemented group layer bounds application for better scaling and positioning.
- Added tests to ensure correct behavior when handling layer masks and group layers.
- Created new types for asset generation provenance to track generated assets more effectively.
2026-07-05 16:06:23 +02:00
syntaxbullet
a83024c35c feat: add configurable history timeout and polling interval for ComfyUI API 2026-07-05 15:47:08 +02:00
syntaxbullet
0b6b064085 feat: enhance generate settings to support multiple architectures and their defaults 2026-07-05 11:21:56 +02:00
syntaxbullet
dd0c1df730 feat(App): enhance generate tool behavior and layer management 2026-07-05 09:45:15 +02:00
syntaxbullet
04a7275143 refactor(App): streamline generate tool state management 2026-07-05 09:38:59 +02:00
syntaxbullet
f5c610dac5 feat: implement inpainting functionality with mask handling
- Added `createMaskedPixelReplacementSource` function to handle pixel replacement using inpainting.
- Introduced `buildInpaintBundle` to prepare inpainting data including mask generation and validation.
- Created utility functions for mask operations such as `applyMaskedContentModeToRgba`, `expandRectWithinBounds`, and others for mask manipulation.
- Developed tests for inpainting preparation and mask raster utilities to ensure functionality and correctness.
- Implemented mask raster operations including inversion, feathering, blurring, and more.
2026-07-05 09:35:16 +02:00
syntaxbullet
6e5d58a638 perf(brush): reuse stroke session canvas 2026-07-04 16:09:44 +02:00
syntaxbullet
1a43d1f0e5 fix(commands): narrow deferred history policy 2026-07-04 15:59:30 +02:00
syntaxbullet
88208ea1ad perf(document): index layer read paths 2026-07-04 15:58:38 +02:00
syntaxbullet
1acfcbe4a5 perf(history): batch transform drag undo 2026-07-04 15:49:13 +02:00
syntaxbullet
a030f2065a perf(renderer): draw checkerboard with shader 2026-07-04 15:43:50 +02:00
syntaxbullet
ef0ce15bb5 perf(view): reduce shell rerenders 2026-07-04 15:37:54 +02:00
syntaxbullet
dfa2bffeda perf(canvas): coalesce renderer redraws 2026-07-04 15:27:20 +02:00
syntaxbullet
d9e5e05183 perf(commands): skip no-op dispatch emissions 2026-07-04 15:21:41 +02:00
syntaxbullet
7188569672 feat: add ComfyUI integration for image generation
- Implemented ComfyUI API for generating images with various modes (text-to-image, image-to-image, inpaint, outpaint).
- Created GenerateSheet and associated controls for user input on generation settings.
- Added subtle scrollbar styles for improved UI experience.
- Enhanced canvas input handling to ignore key events when focused on editable elements.
- Optimized canvas resizing logic to prevent unnecessary dispatches.
- Introduced error handling for generation failures and loading models.
- Added functionality to upload images and masks for inpainting.
2026-07-04 15:10:30 +02:00
syntaxbullet
af50a165da feat: add magic wand tool with settings and controls, including UI integration and functionality 2026-07-04 11:49:35 +02:00
syntaxbullet
6b6c2ebb80 feat: add chroma key tool and settings, including UI components and controls 2026-07-04 11:13:47 +02:00
syntaxbullet
e5c7f09cea feat: add shortcuts display 2026-07-03 23:44:24 +02:00
190 changed files with 14482 additions and 1679 deletions

View File

@@ -10,6 +10,9 @@ Follow these rules for the whole repository. More specific `AGENTS.md` files ove
- `input/`: keyboard, pointer, mouse, touch, pen, and wheel resolution; global consumer first, command fallback second.
- `renderer/`: renders the current `ImageDocument` using the graphics backend, e.g. WebGL.
- `view/`: React UI shell and controls only.
- `operations/`: asynchronous application use cases; may call platform ports and dispatch commands, but never mutate state directly.
- `platform/`: browser/runtime adapters such as raster canvases, downloads, and HTTP clients.
- `server/`: server routes and external backend integrations; contains no React or client application state.
## State Ownership
- All persistent document state is represented by `ImageDocument` and related `core/` models.

View File

@@ -1,22 +1,110 @@
# Image Studio
Minimal Bun + React starter with Tailwind CSS and shadcn/ui configured.
Image Studio is a Bun + React image editor prototype. It uses a command-driven architecture for document and editor state, a canvas/WebGL renderer layer, and a lightweight React UI for tools, layers, imports, exports, and shortcuts.
## Install
## Features
- Import images into an artboard
- Save and open versioned project files with embedded, project-owned assets
- Automatic local recovery snapshots after document changes
- Export the active artboard as PNG
- Layer selection and layer sheet
- Select, transform, pan, brush, eraser, chroma key, and magic wand tools
- Mask-edit workflow support
- Undo/redo through command history
- Keyboard shortcuts and pointer/wheel canvas input
## Tech stack
- [Bun](https://bun.sh/) for runtime, package management, tests, and builds
- React 19
- Tailwind CSS 4
- shadcn/ui-style component utilities
- TypeScript
## Getting started
Install dependencies:
```bash
bun install
```
## Development
Start the development server:
```bash
bun dev
```
## Production
To start Image Studio together with the local headless ComfyUI server:
```bash
bun run dev:full
```
The combined launcher reuses a ComfyUI server already listening at
`http://127.0.0.1:8188`. Otherwise, it starts the existing local ComfyUI
installation and stops that process when Image Studio exits. Its paths and
address can be overridden with the `COMFYUI_BASE_DIR`, `COMFYUI_SOURCE_DIR`,
`COMFYUI_PYTHON`, `COMFYUI_MODEL_PATHS_CONFIG`, `COMFYUI_HOST`, and
`COMFYUI_PORT` environment variables.
Build for production:
```bash
bun run build
```
Run the production server:
```bash
bun start
```
## Scripts
| Command | Description |
| --- | --- |
| `bun dev` | Start the app with Bun hot reload |
| `bun run dev:full` | Start the app and local headless ComfyUI server |
| `bun run build` | Build the production bundle |
| `bun start` | Run the production server |
| `bun test` | Run tests |
| `bun run lint` | Run ESLint |
## Keyboard shortcuts
| Shortcut | Action |
| --- | --- |
| `S` | Select tool |
| `B` | Brush tool |
| `E` | Eraser tool |
| `K` | Chroma key tool |
| `W` | Magic wand tool |
| `Shift` + click | Add to magic wand selection |
| `Alt` + click | Subtract from magic wand selection |
| `P` | Pan tool |
| `Space` | Hold to pan |
| `L` | Toggle layers |
| `Cmd/Ctrl` + `O` | Open image |
| `Shift` + `Cmd/Ctrl` + `O` | Open project |
| `Cmd/Ctrl` + `S` | Save project |
| `Cmd/Ctrl` + `Z` | Undo |
| `Shift` + `Cmd/Ctrl` + `Z` | Redo |
| `Delete` / `Backspace` | Delete selection |
## Project structure
```text
app/ Composition root for app wiring
commands/ Deterministic state changes and command history
core/ Pure document/domain models
editor/ Transient editor state, tools, viewport, selection, store
input/ Keyboard, pointer, wheel, and canvas input resolution
renderer/ Canvas/WebGL rendering and overlays
view/ React UI shell and controls
```
## Architecture notes
State changes flow through commands. React components present state and dispatch user intent, while document/editor mutations are handled by command modules. The renderer consumes document and editor snapshots to draw the canvas and overlays without owning application state.

View File

@@ -1,19 +1,29 @@
import { documentCommands } from "@commands/document";
import { generationCommands } from "@commands/generation";
import { historyCommands } from "@commands/history";
import { commandIds } from "@commands/ids";
import { commandPaletteCommands } from "@commands/palette";
import { createCommandRegistry } from "@commands/registry";
import { selectionCommands } from "@commands/selection";
import { toolCommands } from "@commands/tool";
import { transformCommands } from "@commands/transform";
import { viewportCommands } from "@commands/viewport";
import { workspaceCommands } from "@commands/workspace";
import { editorCommands } from "@commands/editor";
import { projectCommands } from "@commands/project";
import { inpaintRegionCommands } from "@commands/inpaint-region";
import { createInitialAppState } from "@editor/initial-state";
import { createAppStore } from "@editor/store";
import { createGenerationWorkflow } from "@operations/generation/workflow";
import { createDocumentActions } from "./document-actions";
export type ImageStudioApp = ReturnType<typeof createImageStudioApp>;
export function createImageStudioApp(options?: { documentName?: string; createDefaultArtboard?: boolean }) {
const registry = createCommandRegistry([...viewportCommands, ...selectionCommands, ...documentCommands, ...toolCommands, ...transformCommands, ...historyCommands]);
const registry = createCommandRegistry([...projectCommands, ...viewportCommands, ...selectionCommands, ...documentCommands, ...inpaintRegionCommands, ...toolCommands, ...generationCommands, ...transformCommands, ...historyCommands, ...commandPaletteCommands, ...workspaceCommands, ...editorCommands]);
const store = createAppStore(createInitialAppState(options?.documentName), registry);
const generation = createGenerationWorkflow(store);
const documentActions = createDocumentActions(store, generation);
if (options?.createDefaultArtboard !== false) {
const artboardId = crypto.randomUUID();
@@ -22,11 +32,13 @@ export function createImageStudioApp(options?: { documentName?: string; createDe
name: "Artboard 1",
bounds: { x: -400, y: -300, w: 800, h: 600 },
});
store.dispatch(commandIds.viewportFitArtboard, { artboardId });
documentActions.fitArtboard(artboardId);
}
return {
registry,
store,
actions: { document: documentActions },
workflows: { generation },
};
}

View File

@@ -0,0 +1,34 @@
import { describe, expect, test } from "bun:test";
import { commandIds } from "@commands/ids";
import { createImageStudioApp } from "./app";
describe("document actions", () => {
test("share zoom, fit, and history behavior across UI entry points", () => {
const app = createImageStudioApp();
const artboard = app.store.getState().document.artboards[0];
if (!artboard) throw new Error("Expected the default artboard.");
app.actions.document.zoomTo(2);
app.actions.document.zoomBy(0.5);
expect(app.store.getState().editor.viewport.zoom).toBe(1);
app.actions.document.fitArtboard(artboard.id);
expect(app.store.getState().editor.viewport.center).toEqual({ x: 0, y: 0 });
expect(app.actions.document.canUndo()).toBe(true);
app.actions.document.undo();
expect(app.store.getState().document.artboards).toHaveLength(0);
expect(app.actions.document.canRedo()).toBe(true);
app.actions.document.redo();
expect(app.store.getState().document.artboards).toHaveLength(1);
});
test("opens Generate through the canonical workspace command", () => {
const app = createImageStudioApp({ createDefaultArtboard: false });
app.actions.document.openGenerate();
expect(app.store.getState().editor.workspace.panel).toBe("generate");
app.store.dispatch(commandIds.workspaceSetPanel, { panel: "none" });
expect(app.store.getState().editor.workspace.panel).toBe("none");
});
});

58
app/document-actions.ts Normal file
View File

@@ -0,0 +1,58 @@
import { commandIds } from "@commands/ids";
import type { ArtboardId } from "@core/id";
import type { AppStore } from "@editor/store";
import { downloadArtboardPng } from "@operations/export/downloadArtboard";
import type { GenerationWorkflow } from "@operations/generation/workflow";
export type DocumentActions = ReturnType<typeof createDocumentActions>;
export function createDocumentActions(store: AppStore, generation: GenerationWorkflow) {
return {
openGenerate() {
store.dispatch(commandIds.workspaceSetPanel, { panel: "generate" });
},
generate() {
return generation.generate();
},
exportArtboard(artboardId?: ArtboardId) {
const state = store.getState();
const resolvedId = artboardId ?? state.editor.selection.artboardId;
const artboard = resolvedId
? state.document.artboards.find((candidate) => candidate.id === resolvedId)
: state.document.artboards[0];
if (!artboard) return Promise.resolve();
return downloadArtboardPng(artboard, state.document.assets);
},
fitArtboard(artboardId?: ArtboardId) {
store.dispatch(commandIds.viewportFitArtboard, artboardId ? { artboardId } : undefined);
},
zoomBy(factor: number) {
const zoom = store.getState().editor.viewport.zoom;
store.dispatch(commandIds.viewportSetZoom, { zoom: zoom * factor });
},
zoomTo(zoom: number) {
store.dispatch(commandIds.viewportSetZoom, { zoom });
},
undo() {
store.dispatch(commandIds.historyUndo, undefined);
},
canUndo() {
return store.getState().history.past.length > 0;
},
redo() {
store.dispatch(commandIds.historyRedo, undefined);
},
canRedo() {
return store.getState().history.future.length > 0;
},
};
}

View File

@@ -1,2 +1,3 @@
export type { ImageStudioApp } from "./app";
export { createImageStudioApp } from "./app";
export type { DocumentActions } from "./document-actions";

View File

@@ -3,7 +3,7 @@
"configVersion": 1,
"workspaces": {
"": {
"name": "bun-react-template",
"name": "image-studio",
"dependencies": {
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-slot": "^1.2.3",

View File

@@ -0,0 +1,39 @@
import { describe, expect, test } from "bun:test";
import { createInitialAppState } from "@editor/initial-state";
import { neutralColorAdjustment } from "@core/adjustment-layer";
import { documentAddArtboardCommand, documentAddAdjustmentLayerCommand, documentSetAdjustmentCommand, documentDuplicateLayerCommand, documentMoveLayerCommand } from "./document";
const transform = { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 };
function stateWithAdjustment() {
let state = documentAddArtboardCommand.execute({ state: createInitialAppState("test") }, { id: "board", name: "Board", bounds: { x: 0, y: 0, w: 10, h: 10 } });
state = documentAddAdjustmentLayerCommand.execute({ state }, { artboardId: "board", layer: { id: "adjust", type: "adjustment", name: "Color", visible: true, locked: false, opacity: 1, transform, adjustment: neutralColorAdjustment } });
return state;
}
describe("adjustment commands", () => {
test("creates and selects a persistent adjustment node", () => {
const state = stateWithAdjustment();
expect(state.document.artboards[0]?.layers[0]?.type).toBe("adjustment");
expect(state.editor.selection.layerIds).toEqual(["adjust"]);
});
test("validates edits and preserves locked layers", () => {
const state = stateWithAdjustment();
const changed = documentSetAdjustmentCommand.execute({ state }, { layerId: "adjust", adjustment: { ...neutralColorAdjustment, brightness: .25 } });
const invalid = documentSetAdjustmentCommand.execute({ state: changed }, { layerId: "adjust", adjustment: { ...neutralColorAdjustment, contrast: 2 } });
expect((changed.document.artboards[0]?.layers[0] as { adjustment: { brightness: number } }).adjustment.brightness).toBe(.25);
expect(invalid).toBe(changed);
});
test("duplicates and moves adjustment nodes like tree siblings", () => {
let state = stateWithAdjustment();
state = documentDuplicateLayerCommand.execute({ state }, { layerId: "adjust", idByLayerId: { adjust: "copy" } });
state = documentMoveLayerCommand.execute({ state }, { layerId: "copy", toArtboardId: "board", toIndex: 0 });
expect(state.document.artboards[0]?.layers.map((layer) => [layer.id, layer.type])).toEqual([["copy", "adjustment"], ["adjust", "adjustment"]]);
});
test("rejects nested creation, movement, and grouping to preserve exact scope", () => {
const state = stateWithAdjustment();
const nested = documentAddAdjustmentLayerCommand.execute({ state }, { artboardId: "board", parentGroupId: "group", layer: { id: "nested", type: "adjustment", name: "Nested", visible: true, locked: false, opacity: 1, transform, adjustment: neutralColorAdjustment } });
const moved = documentMoveLayerCommand.execute({ state }, { layerId: "adjust", toArtboardId: "board", toParentGroupId: "group", toIndex: 0 });
expect(nested).toBe(state);
expect(moved).toBe(state);
});
});

View File

@@ -4,8 +4,14 @@ export type CommandContext = {
state: AppState;
};
export type CommandHistoryPolicy =
| { mode: "auto" }
| { mode: "ignore" }
| { mode: "deferred"; phase: "begin" | "update" | "commit" };
export type Command<TPayload = void> = {
id: string;
name: string;
history?: CommandHistoryPolicy;
execute(context: CommandContext, payload: TPayload): AppState;
};

View File

@@ -1,10 +1,17 @@
import { describe, expect, test } from "bun:test";
import type { Command } from "./command";
import { createInitialAppState } from "@editor/initial-state";
import { createAppStore } from "@editor/store";
import { commandIds } from "./ids";
import { createCommandRegistry } from "./registry";
import { viewportPanCommand } from "./viewport";
const noOpCommand: Command<unknown> = {
id: "test.noop",
name: "No-op",
execute: ({ state }) => state,
};
describe("command dispatcher", () => {
test("throws for unknown commands", () => {
const store = createAppStore(createInitialAppState("Test"), createCommandRegistry([]));
@@ -16,4 +23,23 @@ describe("command dispatcher", () => {
store.dispatch(commandIds.viewportPan, { delta: { x: 3, y: 7 } });
expect(store.getState().editor.viewport.center).toEqual({ x: 3, y: 7 });
});
test("repeated no-op commands do not emit or record history", () => {
const initialState = createInitialAppState("Test");
const store = createAppStore(initialState, createCommandRegistry([noOpCommand]));
let notificationCount = 0;
store.subscribe(() => {
notificationCount += 1;
});
const firstResult = store.dispatch("test.noop" as never, undefined as never);
const secondResult = store.dispatch("test.noop" as never, undefined as never);
expect(firstResult).toBe(initialState);
expect(secondResult).toBe(initialState);
expect(store.getState()).toBe(initialState);
expect(store.getState().history.past).toHaveLength(0);
expect(store.getState().history.future).toHaveLength(0);
expect(notificationCount).toBe(0);
});
});

View File

@@ -1,6 +1,5 @@
import type { AppState, HistorySnapshot } from "@editor/state";
import type { CommandContext } from "./command";
import { commandIds } from "./ids";
import type { CommandContext, CommandHistoryPolicy } from "./command";
import type { CommandId, CommandPayloads } from "./payloads";
import type { CommandRegistry } from "./registry";
@@ -15,6 +14,8 @@ export function createCommandDispatcher(options: {
getState: () => AppState;
setState: (state: AppState) => void;
}): CommandDispatcher {
let deferredHistory: { snapshot: HistorySnapshot; changed: boolean } | undefined;
return {
dispatch(commandId, payload) {
const command = options.registry.get(commandId);
@@ -23,25 +24,91 @@ export function createCommandDispatcher(options: {
}
const currentState = options.getState();
const historyPolicy = command.history ?? defaultHistoryPolicy;
const deferredSnapshot = historyPolicy.mode === "deferred" && historyPolicy.phase === "begin" ? snapshot(currentState) : undefined;
const context: CommandContext = { state: currentState };
const executedState = command.execute(context, payload);
const nextState = shouldRecordHistory(commandId, currentState, executedState) ? recordHistory(currentState, executedState) : executedState;
if (executedState === currentState) {
if (historyPolicy.mode === "deferred" && historyPolicy.phase === "commit") deferredHistory = undefined;
return currentState;
}
const nextState = applyHistoryPolicy({
currentState,
nextState: executedState,
historyPolicy,
deferredSnapshot,
getDeferredHistory: () => deferredHistory,
setDeferredHistory: (nextDeferredHistory) => {
deferredHistory = nextDeferredHistory;
},
});
options.setState(nextState);
return nextState;
},
};
}
function shouldRecordHistory(commandId: CommandId, currentState: AppState, nextState: AppState) {
if (commandId === commandIds.historyUndo || commandId === commandIds.historyRedo) return false;
const defaultHistoryPolicy: CommandHistoryPolicy = { mode: "auto" };
function applyHistoryPolicy(options: {
currentState: AppState;
nextState: AppState;
historyPolicy: CommandHistoryPolicy;
deferredSnapshot?: HistorySnapshot;
getDeferredHistory: () => { snapshot: HistorySnapshot; changed: boolean } | undefined;
setDeferredHistory: (nextDeferredHistory: { snapshot: HistorySnapshot; changed: boolean } | undefined) => void;
}): AppState {
const { historyPolicy } = options;
if (historyPolicy.mode === "ignore") {
options.setDeferredHistory(undefined);
return options.nextState;
}
if (historyPolicy.mode === "deferred") {
return applyDeferredHistoryPolicy({ ...options, historyPolicy });
}
return shouldRecordHistory(options.currentState, options.nextState) ? recordHistory(snapshot(options.currentState), options.nextState) : options.nextState;
}
function applyDeferredHistoryPolicy(options: {
currentState: AppState;
nextState: AppState;
historyPolicy: Extract<CommandHistoryPolicy, { mode: "deferred" }>;
deferredSnapshot?: HistorySnapshot;
getDeferredHistory: () => { snapshot: HistorySnapshot; changed: boolean } | undefined;
setDeferredHistory: (nextDeferredHistory: { snapshot: HistorySnapshot; changed: boolean } | undefined) => void;
}): AppState {
switch (options.historyPolicy.phase) {
case "begin":
options.setDeferredHistory(options.deferredSnapshot ? { snapshot: options.deferredSnapshot, changed: false } : undefined);
return options.nextState;
case "update": {
const deferredHistory = options.getDeferredHistory();
if (deferredHistory && options.currentState.document !== options.nextState.document) {
options.setDeferredHistory({ ...deferredHistory, changed: true });
}
return options.nextState;
}
case "commit": {
const deferredHistory = options.getDeferredHistory();
options.setDeferredHistory(undefined);
return deferredHistory?.changed ? recordHistory(deferredHistory.snapshot, options.nextState) : options.nextState;
}
}
}
function shouldRecordHistory(currentState: AppState, nextState: AppState) {
return currentState.document !== nextState.document;
}
function recordHistory(currentState: AppState, nextState: AppState): AppState {
function recordHistory(historySnapshot: HistorySnapshot, nextState: AppState): AppState {
return {
...nextState,
history: {
past: [...currentState.history.past, snapshot(currentState)].slice(-100),
past: [...nextState.history.past, historySnapshot].slice(-100),
future: [],
},
};
@@ -54,6 +121,11 @@ function snapshot(state: AppState): HistorySnapshot {
...state.editor,
brushPreview: undefined,
brushStrokePreview: undefined,
commandPalette: {
open: false,
query: "",
selectedIndex: 0,
},
},
};
}

331
commands/document-tree.ts Normal file
View File

@@ -0,0 +1,331 @@
import type { ImageDocument } from "@core/document";
import type { ArtboardId, LayerId } from "@core/id";
import type { Layer } from "@core/layer";
import type { LayerGroup } from "@core/layer-group";
import { getLayerMask } from "@core/layer-mask-utils";
import type { MaskEditState } from "@editor/state";
export type LayerLocation = {
artboardId: ArtboardId;
parentGroupId?: LayerId;
index: number;
layer: Layer;
siblings: readonly Layer[];
};
export function findLayerLocation(document: ImageDocument, layerId: LayerId): LayerLocation | undefined {
for (const artboard of document.artboards) {
const location = findLayerLocationInTree(artboard.layers, layerId, artboard.id);
if (location) return location;
}
return undefined;
}
export function isReferencedMaskLayer(document: ImageDocument, maskLayerId: LayerId): boolean {
return document.artboards.some((artboard) => isReferencedMaskLayerInTree(artboard.layers, maskLayerId));
}
export function isReferencedMaskLayerInTree(layers: readonly Layer[], maskLayerId: LayerId): boolean {
for (const layer of layers) {
if (getLayerMask(layer)?.maskLayerId === maskLayerId) return true;
if (layer.type === "group" && isReferencedMaskLayerInTree(layer.children, maskLayerId)) return true;
}
return false;
}
export function findLayerLocationInTree(layers: Layer[], layerId: LayerId, artboardId: ArtboardId, parentGroupId?: LayerId): LayerLocation | undefined {
for (let index = 0; index < layers.length; index++) {
const layer = layers[index];
if (!layer) continue;
if (layer.id === layerId) return { artboardId, parentGroupId, index, layer, siblings: layers };
if (layer.type === "group") {
const child = findLayerLocationInTree(layer.children, layerId, artboardId, layer.id);
if (child) return child;
}
}
return undefined;
}
export function mapLayerInDocument(document: ImageDocument, layerId: LayerId, mapLayer: (layer: Layer) => Layer): ImageDocument {
return {
...document,
artboards: document.artboards.map((artboard) => ({ ...artboard, layers: mapLayerInTree(artboard.layers, layerId, mapLayer) })),
};
}
export function mapLayerInTree(layers: Layer[], layerId: LayerId, mapLayer: (layer: Layer) => Layer): Layer[] {
return layers.map((layer) => {
if (layer.id === layerId) return mapLayer(layer);
if (layer.type === "group") return { ...layer, children: mapLayerInTree(layer.children, layerId, mapLayer) };
return layer;
});
}
export function insertLayer(document: ImageDocument, artboardId: ArtboardId, parentGroupId: LayerId | undefined, layer: Layer, index?: number): ImageDocument {
return {
...document,
artboards: document.artboards.map((artboard) => {
if (artboard.id !== artboardId) return artboard;
if (!parentGroupId) return { ...artboard, layers: insertAt(artboard.layers, layer, index) };
return { ...artboard, layers: insertLayerInGroup(artboard.layers, parentGroupId, layer, index) };
}),
};
}
export function insertLayerInGroup(layers: Layer[], groupId: LayerId, layer: Layer, index?: number): Layer[] {
return layers.map((candidate) => {
if (candidate.type === "group" && candidate.id === groupId) return { ...candidate, children: insertAt(candidate.children, layer, index) };
if (candidate.type === "group") return { ...candidate, children: insertLayerInGroup(candidate.children, groupId, layer, index) };
return candidate;
});
}
export function replaceLayerListInDocument(document: ImageDocument, artboardId: ArtboardId, parentGroupId: LayerId | undefined, layers: Layer[]): ImageDocument {
return {
...document,
artboards: document.artboards.map((artboard) => {
if (artboard.id !== artboardId) return artboard;
if (!parentGroupId) return { ...artboard, layers };
return { ...artboard, layers: replaceLayerListInGroup(artboard.layers, parentGroupId, layers) };
}),
};
}
export function replaceLayerListInGroup(layers: Layer[], groupId: LayerId, children: Layer[]): Layer[] {
return layers.map((layer) => {
if (layer.type === "group" && layer.id === groupId) return { ...layer, children };
if (layer.type === "group") return { ...layer, children: replaceLayerListInGroup(layer.children, groupId, children) };
return layer;
});
}
export function replaceSelectedLayersWithGroup(layers: readonly Layer[], selectedLayerIds: ReadonlySet<LayerId>, group: LayerGroup): Layer[] {
const next: Layer[] = [];
let inserted = false;
for (const layer of layers) {
if (!selectedLayerIds.has(layer.id)) {
next.push(layer);
continue;
}
if (!inserted) {
next.push(group);
inserted = true;
}
}
return next;
}
export function removeLayerFromDocument(document: ImageDocument, layerId: LayerId): { document: ImageDocument; layer?: Layer } {
let removed: Layer | undefined;
return {
document: {
...document,
artboards: document.artboards.map((artboard) => {
const result = removeLayerFromTree(artboard.layers, layerId);
if (result.layer) removed = result.layer;
return { ...artboard, layers: result.layers };
}),
},
layer: removed,
};
}
export function removeLayerFromTree(layers: Layer[], layerId: LayerId): { layers: Layer[]; layer?: Layer } {
let removed: Layer | undefined;
const next: Layer[] = [];
for (const layer of layers) {
if (layer.id === layerId) {
removed = layer;
continue;
}
if (layer.type === "group") {
const result = removeLayerFromTree(layer.children, layerId);
if (result.layer) removed = result.layer;
next.push({ ...layer, children: result.layers });
} else {
next.push(layer);
}
}
return { layers: next, layer: removed };
}
export function ungroupLayerInDocument(document: ImageDocument, groupId: LayerId): { document: ImageDocument; changed: boolean; artboardId?: ArtboardId; children: Layer[] } {
let changed = false;
let artboardId: ArtboardId | undefined;
let children: Layer[] = [];
const next = {
...document,
artboards: document.artboards.map((artboard) => {
const result = ungroupLayerInTree(artboard.layers, groupId);
if (result.changed) {
changed = true;
artboardId = artboard.id;
children = result.children;
}
return { ...artboard, layers: result.layers };
}),
};
return { document: next, changed, artboardId, children };
}
export function ungroupLayerInTree(layers: Layer[], groupId: LayerId): { layers: Layer[]; changed: boolean; children: Layer[] } {
const next: Layer[] = [];
for (const layer of layers) {
if (layer.type === "group" && layer.id === groupId) return { layers: [...next, ...layer.children, ...layers.slice(next.length + 1)], changed: true, children: layer.children };
if (layer.type === "group") {
const result = ungroupLayerInTree(layer.children, groupId);
if (result.changed) return { layers: [...next, { ...layer, children: result.layers }, ...layers.slice(next.length + 1)], changed: true, children: result.children };
}
next.push(layer);
}
return { layers, changed: false, children: [] };
}
export function insertAt(layers: Layer[], layer: Layer, index = layers.length) {
const clamped = Math.max(0, Math.min(index, layers.length));
return [...layers.slice(0, clamped), layer, ...layers.slice(clamped)];
}
export function findGroup(document: ImageDocument, groupId: LayerId): LayerGroup | undefined {
for (const artboard of document.artboards) {
const group = findGroupInTree(artboard.layers, groupId);
if (group) return group;
}
return undefined;
}
export function findGroupInTree(layers: Layer[], groupId: LayerId): LayerGroup | undefined {
for (const layer of layers) {
if (layer.type === "group" && layer.id === groupId) return layer;
if (layer.type === "group") {
const child = findGroupInTree(layer.children, groupId);
if (child) return child;
}
}
return undefined;
}
export function removeLayerMaskReference(layer: Layer): Layer {
const next = { ...layer };
delete next.layerMask;
delete next.clippingMask;
return next;
}
export function withLayerMask(layer: Layer, maskLayerId: LayerId): Layer {
return {
...layer,
layerMask: {
kind: "raster",
maskLayerId,
enabled: true,
inverted: false,
},
clippingMask: { maskLayerId },
};
}
export function removeUnreferencedMaskLayer(document: ImageDocument, maskLayerId: LayerId): ImageDocument {
if (isMaskLayerReferenced(document, maskLayerId)) return document;
return removeLayerFromDocument(document, maskLayerId).document;
}
export function isMaskLayerReferenced(document: ImageDocument, maskLayerId: LayerId): boolean {
return collectClippingMaskIds(document.artboards.flatMap((artboard) => artboard.layers)).has(maskLayerId);
}
export function removeMissingMaskReferences(document: ImageDocument): ImageDocument {
const existingLayerIds = collectDocumentLayerIds(document);
return mapAllLayersInDocument(document, (layer) => {
const mask = getLayerMask(layer);
if (!mask || existingLayerIds.has(mask.maskLayerId)) return layer;
return removeLayerMaskReference(layer);
});
}
export function isMaskEditFor(maskEdit: MaskEditState | undefined, targetLayerId: LayerId, maskLayerId: LayerId) {
return maskEdit?.targetLayerId === targetLayerId && maskEdit.maskLayerId === maskLayerId;
}
export function isMaskEditValid(maskEdit: MaskEditState | undefined, document: ImageDocument) {
if (!maskEdit) return false;
const target = findLayerLocation(document, maskEdit.targetLayerId)?.layer;
if (maskEdit.kind === "inpaintRegion") {
const region = document.inpaintRegions.find((candidate) => candidate.id === maskEdit.inpaintRegionId && candidate.targetLayerId === maskEdit.targetLayerId && candidate.maskAssetId === maskEdit.maskAssetId);
return Boolean(target && region && document.assets.some((asset) => asset.id === region.maskAssetId));
}
if (!maskEdit.maskLayerId) return false;
const mask = findLayerLocation(document, maskEdit.maskLayerId)?.layer;
return Boolean(target && getLayerMask(target)?.maskLayerId === maskEdit.maskLayerId && mask && mask.type !== "group");
}
export function collectDocumentLayerIds(document: ImageDocument): Set<LayerId> {
const ids = new Set<LayerId>();
for (const artboard of document.artboards) collectLayerIdsFromTree(artboard.layers, ids);
return ids;
}
export function collectLayerIds(layer: Layer, ids = new Set<LayerId>()): Set<LayerId> {
ids.add(layer.id);
if (layer.type === "group") collectLayerIdsFromTree(layer.children, ids);
return ids;
}
export function collectLayerIdsFromTree(layers: readonly Layer[], ids = new Set<LayerId>()): Set<LayerId> {
for (const layer of layers) collectLayerIds(layer, ids);
return ids;
}
export function collectClippingMaskIds(layers: readonly Layer[], ids = new Set<LayerId>()): Set<LayerId> {
for (const layer of layers) {
const mask = getLayerMask(layer);
if (mask) ids.add(mask.maskLayerId);
if (layer.type === "group") collectClippingMaskIds(layer.children, ids);
}
return ids;
}
export function collectAttachedMaskIds(layers: readonly Layer[], layerIds: readonly LayerId[]): LayerId[] {
const layerIdSet = new Set(layerIds);
return layers.flatMap((layer) => {
const mask = getLayerMask(layer);
return layerIdSet.has(layer.id) && mask ? [mask.maskLayerId] : [];
});
}
export function mapAllLayersInDocument(document: ImageDocument, mapLayer: (layer: Layer) => Layer): ImageDocument {
return {
...document,
artboards: document.artboards.map((artboard) => ({ ...artboard, layers: mapAllLayersInTree(artboard.layers, mapLayer) })),
};
}
export function mapAllLayersInTree(layers: Layer[], mapLayer: (layer: Layer) => Layer): Layer[] {
return layers.map((layer) => {
const mapped = layer.type === "group" ? { ...layer, children: mapAllLayersInTree(layer.children, mapLayer) } : layer;
return mapLayer(mapped);
});
}
export function removeInpaintRegionsForTargets(document: ImageDocument, targetLayerIds: ReadonlySet<LayerId>): ImageDocument {
const removed = document.inpaintRegions.filter((region) => targetLayerIds.has(region.targetLayerId));
if (removed.length === 0) return document;
const inpaintRegions = document.inpaintRegions.filter((region) => !targetLayerIds.has(region.targetLayerId));
const removedMaskAssetIds = new Set(removed.map((region) => region.maskAssetId));
const retainedRegionAssetIds = new Set(inpaintRegions.map((region) => region.maskAssetId));
const layerAssetIds = new Set<string>();
for (const artboard of document.artboards) {
const stack = [...artboard.layers];
while (stack.length > 0) {
const layer = stack.pop();
if (!layer) continue;
if (layer.type === "group") stack.push(...layer.children);
else if (layer.type === "image" || layer.type === "raster") layerAssetIds.add(layer.assetId);
}
}
return {
...document,
inpaintRegions,
assets: document.assets.filter((asset) => !removedMaskAssetIds.has(asset.id) || retainedRegionAssetIds.has(asset.id) || layerAssetIds.has(asset.id)),
};
}

View File

@@ -8,7 +8,9 @@ import {
documentAddImageLayerCommand,
documentAddLayerMaskCommand,
documentAddRasterLayerCommand,
documentApplyLayerMaskOperationCommand,
documentGroupLayersCommand,
documentDuplicateLayerCommand,
documentMoveLayerCommand,
documentRemoveArtboardCommand,
documentRemoveLayerCommand,
@@ -16,10 +18,13 @@ import {
documentRenameArtboardCommand,
documentRenameLayerCommand,
documentSetArtboardBoundsCommand,
documentResizeArtboardCommand,
documentSetArtboardLockedCommand,
documentSetArtboardVisibleCommand,
documentSetLayerClippingMaskCommand,
documentSetLayerLockedCommand,
documentSetLayerOpacityCommand,
documentSetLayerSourceRectCommand,
documentSetLayerVisibleCommand,
documentUpdateAssetSourceCommand,
documentUngroupLayerCommand,
@@ -56,6 +61,32 @@ describe("document commands", () => {
]);
});
test("crops a raster layer non-destructively and clamps the crop to its asset", () => {
const state = documentWithRaster();
const next = documentSetLayerSourceRectCommand.execute({ state }, { layerId: "r1", sourceRect: { x: 20, y: 10, w: 200, h: 100 } });
expect(next.document.artboards[0]?.layers[0]).toMatchObject({ sourceRect: { x: 20, y: 10, w: 80, h: 40 } });
expect(next.document.assets[0]?.intrinsicSize).toEqual({ w: 100, h: 50 });
const reset = documentSetLayerSourceRectCommand.execute({ state: next }, { layerId: "r1" });
expect(reset.document.artboards[0]?.layers[0]).not.toHaveProperty("sourceRect");
});
test("resizes artboard bounds without scaling contents", () => {
const state = documentWithRaster();
const next = documentResizeArtboardCommand.execute({ state }, { id: "a1", bounds: { x: 5, y: 10, w: 640, h: 480 }, scaleContents: false });
expect(next.document.artboards[0]?.bounds).toEqual({ x: 5, y: 10, w: 640, h: 480 });
expect(next.document.artboards[0]?.layers[0]?.transform).toEqual(state.document.artboards[0]?.layers[0]?.transform);
});
test("resizes an artboard and scales nested contents including masks", () => {
const state = documentWithRaster();
const leaf = state.document.artboards[0]!.layers[0]!;
state.document.artboards[0]!.layers = [{ ...group("g", "Group"), children: [leaf] }];
const next = documentResizeArtboardCommand.execute({ state }, { id: "a1", bounds: { x: 10, y: 20, w: 640, h: 120 }, scaleContents: true });
const nested = next.document.artboards[0]?.layers[0];
expect(nested?.type === "group" ? nested.children[0]?.transform : undefined).toEqual({ position: { x: 30, y: 30 }, scale: { x: 2, y: 0.5 }, rotation: 0 });
});
test("updates asset sources", () => {
const state = documentAddAssetCommand.execute(
{ state: createInitialAppState("Test") },
@@ -153,6 +184,35 @@ describe("document commands", () => {
expect(ungrouped.editor.selection.layerIds).toEqual(["a", "b"]);
});
test("groups nested sibling layers in place", () => {
const state = documentWithLayers([{ ...group("parent", "Parent"), children: [group("a", "A"), group("b", "B"), group("c", "C")] }]);
const grouped = documentGroupLayersCommand.execute({ state }, { artboardId: "a1", layerIds: ["a", "b"], group: group("g", "Group") });
const parent = grouped.document.artboards[0]?.layers[0];
const nestedGroup = parent?.type === "group" ? parent.children[0] : undefined;
expect(parent?.type).toBe("group");
expect(parent?.type === "group" ? parent.children.map((layer) => layer.id) : []).toEqual(["g", "c"]);
expect(nestedGroup?.type === "group" ? nestedGroup.children.map((layer) => layer.id) : []).toEqual(["a", "b"]);
expect(grouped.editor.selection).toEqual({ artboardId: "a1", layerIds: ["g"] });
});
test("does not group layers from different parents", () => {
const state = documentWithLayers([group("a", "A"), { ...group("parent", "Parent"), children: [group("b", "B")] }]);
const grouped = documentGroupLayersCommand.execute({ state }, { artboardId: "a1", layerIds: ["a", "b"], group: group("g", "Group") });
expect(grouped).toBe(state);
});
test("does not move groups into their own descendants", () => {
const state = documentWithLayers([{ ...group("parent", "Parent"), children: [group("child", "Child")] }]);
const moved = documentMoveLayerCommand.execute({ state }, { layerId: "parent", toArtboardId: "a1", toParentGroupId: "child", toIndex: 0 });
expect(moved).toBe(state);
});
test("removes layers", () => {
const state = documentWithLayers([group("a", "A"), group("b", "B")]);
const selectedState = { ...state, editor: { ...state.editor, selection: { artboardId: "a1", layerIds: ["a"] } } };
@@ -218,8 +278,14 @@ describe("document commands", () => {
expect(masked.document.assets).toContainEqual(maskAsset());
expect(masked.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["mask", "target"]);
expect(masked.document.artboards[0]?.layers[1]?.layerMask).toEqual({
kind: "raster",
maskLayerId: "mask",
enabled: true,
inverted: false,
});
expect(masked.document.artboards[0]?.layers[1]?.clippingMask).toEqual({ maskLayerId: "mask" });
expect(masked.editor.maskEdit).toEqual({ targetLayerId: "target", maskLayerId: "mask" });
expect(masked.editor.maskEdit).toEqual({ kind: "layerMask", targetLayerId: "target", maskLayerId: "mask", maskAssetId: "mask-asset" });
expect(masked.editor.tools.activeTool).toBe("brush");
});
@@ -232,10 +298,35 @@ describe("document commands", () => {
const unmasked = documentRemoveLayerMaskCommand.execute({ state }, { layerId: "target" });
expect(unmasked.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["target"]);
expect(unmasked.document.artboards[0]?.layers[0]?.layerMask).toBeUndefined();
expect(unmasked.document.artboards[0]?.layers[0]?.clippingMask).toBeUndefined();
expect(unmasked.editor.maskEdit).toBeUndefined();
});
test("applies operations only to attached layer mask assets", () => {
const state = documentAddLayerMaskCommand.execute(
{ state: documentWithLayers([raster("target", "Target"), raster("loose", "Loose", "loose-asset")]) },
{ layerId: "target", asset: maskAsset(), maskLayer: raster("mask", "Target Mask", "mask-asset") },
);
const withLooseAsset = documentAddAssetCommand.execute(
{ state },
{ asset: { id: "loose-asset", name: "Loose", mimeType: "image/png", source: "loose-source", intrinsicSize: { w: 100, h: 100 } } },
);
const updated = documentApplyLayerMaskOperationCommand.execute(
{ state: withLooseAsset },
{ maskLayerId: "mask", source: "updated-mask", mimeType: "image/png", operation: { type: "invert" } },
);
const ignored = documentApplyLayerMaskOperationCommand.execute(
{ state: updated },
{ maskLayerId: "loose", source: "wrong", operation: { type: "invert" } },
);
expect(updated.document.assets.find((asset) => asset.id === "mask-asset")?.source).toBe("updated-mask");
expect(updated.document.assets.find((asset) => asset.id === "mask-asset")?.mimeType).toBe("image/png");
expect(ignored.document.assets.find((asset) => asset.id === "loose-asset")?.source).toBe("loose-source");
});
test("cleans mask references when deleting targets or mask layers", () => {
const state = documentAddLayerMaskCommand.execute(
{ state: documentWithLayers([raster("target", "Target")]) },
@@ -274,6 +365,46 @@ describe("document commands", () => {
expect(locked.document.artboards[0]?.layers[0]?.locked).toBe(true);
});
test("sets normalized opacity and refuses locked layer edits", () => {
const state = documentWithLayers([raster("a", "A")]);
const translucent = documentSetLayerOpacityCommand.execute({ state }, { layerId: "a", opacity: 0.35 });
const clamped = documentSetLayerOpacityCommand.execute({ state: translucent }, { layerId: "a", opacity: 2 });
const lockedState = documentSetLayerLockedCommand.execute({ state: clamped }, { layerId: "a", locked: true });
const ignored = documentSetLayerOpacityCommand.execute({ state: lockedState }, { layerId: "a", opacity: 0 });
expect(translucent.document.artboards[0]?.layers[0]?.opacity).toBe(0.35);
expect(clamped.document.artboards[0]?.layers[0]?.opacity).toBe(1);
expect(ignored).toBe(lockedState);
});
test("duplicates a nested group with remapped child mask references", () => {
const maskedTarget = { ...raster("target", "Target"), layerMask: { kind: "raster" as const, maskLayerId: "mask", enabled: true, inverted: false } };
const parent = { ...group("parent", "Parent"), children: [raster("mask", "Mask"), maskedTarget] };
const state = documentWithLayers([parent]);
const next = documentDuplicateLayerCommand.execute(
{ state },
{ layerId: "parent", idByLayerId: { parent: "parent-copy", mask: "mask-copy", target: "target-copy" } },
);
const duplicate = next.document.artboards[0]?.layers[1];
expect(next.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["parent", "parent-copy"]);
expect(duplicate?.type === "group" ? duplicate.children.map((layer) => layer.id) : []).toEqual(["mask-copy", "target-copy"]);
expect(duplicate?.type === "group" ? duplicate.children[1]?.layerMask?.maskLayerId : undefined).toBe("mask-copy");
expect(next.editor.selection.layerIds).toEqual(["parent-copy"]);
});
test("duplicates an attached top-level mask beside its target", () => {
const target = { ...raster("target", "Target"), layerMask: { kind: "raster" as const, maskLayerId: "mask", enabled: true, inverted: false } };
const state = documentWithLayers([raster("mask", "Mask"), target]);
const next = documentDuplicateLayerCommand.execute(
{ state },
{ layerId: "target", idByLayerId: { mask: "mask-copy", target: "target-copy" } },
);
expect(next.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["mask", "target", "mask-copy", "target-copy"]);
expect(next.document.artboards[0]?.layers[3]?.layerMask?.maskLayerId).toBe("mask-copy");
});
test("sets artboard bounds", () => {
const state = documentAddArtboardCommand.execute(
{ state: createInitialAppState("Test") },
@@ -300,6 +431,12 @@ function documentWithLayers(layers: Layer[]) {
};
}
function documentWithRaster() {
const state = documentWithLayers([{ ...raster("r1", "Raster", "asset-1"), transform: { position: { x: 10, y: 20 }, scale: { x: 1, y: 1 }, rotation: 0 } }]);
state.document.assets = [{ id: "asset-1", name: "Raster", mimeType: "image/png", source: "asset://raster", intrinsicSize: { w: 100, h: 50 } }];
return state;
}
function group(id: string, name: string) {
return {
id,

View File

@@ -1,11 +1,16 @@
import { findLayerLocation, isReferencedMaskLayer, mapLayerInDocument, insertLayer, replaceLayerListInDocument, replaceSelectedLayersWithGroup, removeLayerFromDocument, ungroupLayerInDocument, findGroup, removeLayerMaskReference, withLayerMask, removeUnreferencedMaskLayer, removeMissingMaskReferences, isMaskEditFor, isMaskEditValid, collectLayerIds, collectClippingMaskIds, collectAttachedMaskIds, removeInpaintRegionsForTargets } from "./document-tree";
import type { Asset } from "@core/asset";
import type { ImageDocument } from "@core/document";
import type { Rect } from "@core/geometry";
import type { ArtboardId, AssetId, LayerId } from "@core/id";
import type { ImageLayer } from "@core/image-layer";
import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
import type { RasterLayer } from "@core/raster-layer";
import type { Layer } from "@core/layer";
import type { LayerGroup } from "@core/layer-group";
import type { AdjustmentLayer, ColorAdjustment } from "@core/adjustment-layer";
import type { TextLayer, TextStyle } from "@core/text-layer";
import { isValidTextStyle } from "@core/text-layer";
import type { Command } from "./command";
import { commandIds } from "./ids";
@@ -20,6 +25,12 @@ export type DocumentSetArtboardBoundsPayload = {
bounds: Rect;
};
export type DocumentResizeArtboardPayload = {
id: ArtboardId;
bounds: Rect;
scaleContents: boolean;
};
export type DocumentRemoveArtboardPayload = {
id: ArtboardId;
};
@@ -65,6 +76,10 @@ export type DocumentAddGroupLayerPayload = {
parentGroupId?: LayerId;
group: LayerGroup;
};
export type DocumentAddAdjustmentLayerPayload = { artboardId: ArtboardId; parentGroupId?: LayerId; layer: AdjustmentLayer };
export type DocumentSetAdjustmentPayload = { layerId: LayerId; adjustment: ColorAdjustment };
export type DocumentAddTextLayerPayload = { artboardId: ArtboardId; parentGroupId?: LayerId; layer: TextLayer };
export type DocumentSetTextLayerPayload = { layerId: LayerId; content: string; style: TextStyle };
export type DocumentMoveLayerPayload = {
layerId: LayerId;
@@ -97,6 +112,21 @@ export type DocumentSetLayerLockedPayload = {
locked: boolean;
};
export type DocumentSetLayerOpacityPayload = {
layerId: LayerId;
opacity: number;
};
export type DocumentSetLayerSourceRectPayload = {
layerId: LayerId;
sourceRect?: Rect;
};
export type DocumentDuplicateLayerPayload = {
layerId: LayerId;
idByLayerId: Record<LayerId, LayerId>;
};
export type DocumentRenameLayerPayload = {
layerId: LayerId;
name: string;
@@ -111,6 +141,26 @@ export type DocumentAddLayerMaskPayload = {
layerId: LayerId;
asset: Asset;
maskLayer: RasterLayer;
activeTool?: "brush" | "feather";
};
export type LayerMaskOperation =
| { type: "paint" }
| { type: "magicWand" }
| { type: "chromaKey" }
| { type: "invert" }
| { type: "fill"; fill: "white" | "black" | "clear" }
| { type: "feather"; radius: number }
| { type: "expand"; radius: number }
| { type: "contract"; radius: number }
| { type: "blur"; radius: number }
| { type: "despeckle"; strength: number };
export type DocumentApplyLayerMaskOperationPayload = {
maskLayerId: LayerId;
source: string;
mimeType?: string;
operation: LayerMaskOperation;
};
export type DocumentRemoveLayerMaskPayload = {
@@ -158,15 +208,42 @@ export const documentSetArtboardBoundsCommand: Command<DocumentSetArtboardBounds
},
};
export const documentResizeArtboardCommand: Command<DocumentResizeArtboardPayload> = {
id: commandIds.documentResizeArtboard,
name: "Resize artboard",
execute({ state }, payload) {
const artboard = state.document.artboards.find((candidate) => candidate.id === payload.id);
const bounds = validRect(payload.bounds);
if (!artboard || artboard.locked || !bounds) return state;
const scaleX = bounds.w / artboard.bounds.w;
const scaleY = bounds.h / artboard.bounds.h;
return {
...state,
document: {
...state.document,
artboards: state.document.artboards.map((candidate) => candidate.id !== payload.id ? candidate : {
...candidate,
bounds,
layers: payload.scaleContents ? scaleLayerTree(candidate.layers, artboard.bounds, bounds, scaleX, scaleY) : candidate.layers,
}),
},
};
},
};
export const documentRemoveArtboardCommand: Command<DocumentRemoveArtboardPayload> = {
id: commandIds.documentRemoveArtboard,
name: "Remove artboard",
execute({ state }, payload) {
const removedSelectedArtboard = state.editor.selection.artboardId === payload.id;
const document = {
const removedArtboard = state.document.artboards.find((artboard) => artboard.id === payload.id);
const withoutArtboard = {
...state.document,
artboards: state.document.artboards.filter((artboard) => artboard.id !== payload.id),
};
const removedLayerIds = new Set<LayerId>();
for (const layer of removedArtboard?.layers ?? []) collectLayerIds(layer, removedLayerIds);
const document = removeInpaintRegionsForTargets(withoutArtboard, removedLayerIds);
return {
...state,
@@ -288,16 +365,57 @@ export const documentAddGroupLayerCommand: Command<DocumentAddGroupLayerPayload>
},
};
export const documentAddAdjustmentLayerCommand: Command<DocumentAddAdjustmentLayerPayload> = {
id: commandIds.documentAddAdjustmentLayer,
name: "Add adjustment layer",
execute({ state }, payload) {
if (payload.parentGroupId || !validAdjustment(payload.layer.adjustment)) return state;
return { ...state, document: insertLayer(state.document, payload.artboardId, undefined, payload.layer, 0), editor: { ...state.editor, selection: { artboardId: payload.artboardId, layerIds: [payload.layer.id] } } };
},
};
export const documentAddTextLayerCommand: Command<DocumentAddTextLayerPayload> = {
id: commandIds.documentAddTextLayer,
name: "Add text layer",
execute({ state }, payload) {
if (!payload.layer.content.trim() || !isValidTextStyle(payload.layer.style)) return state;
const document = insertLayer(state.document, payload.artboardId, payload.parentGroupId, payload.layer);
return { ...state, document, editor: { ...state.editor, selection: { artboardId: payload.artboardId, layerIds: [payload.layer.id] } } };
},
};
export const documentSetTextLayerCommand: Command<DocumentSetTextLayerPayload> = {
id: commandIds.documentSetTextLayer,
name: "Edit text layer",
execute({ state }, payload) {
const location = findLayerLocation(state.document, payload.layerId);
if (!location || location.layer.type !== "text" || location.layer.locked || !payload.content.trim() || !isValidTextStyle(payload.style)) return state;
return { ...state, document: mapLayerInDocument(state.document, payload.layerId, (layer) => layer.type === "text" ? { ...layer, content: payload.content, style: { ...payload.style } } : layer) };
},
};
export const documentSetAdjustmentCommand: Command<DocumentSetAdjustmentPayload> = {
id: commandIds.documentSetAdjustment,
name: "Edit adjustment layer",
execute({ state }, payload) {
const location = findLayerLocation(state.document, payload.layerId);
if (!location || location.layer.type !== "adjustment" || location.layer.locked || !validAdjustment(payload.adjustment)) return state;
return { ...state, document: mapLayerInDocument(state.document, payload.layerId, (layer) => layer.type === "adjustment" ? { ...layer, adjustment: payload.adjustment } : layer) };
},
};
export const documentMoveLayerCommand: Command<DocumentMoveLayerPayload> = {
id: commandIds.documentMoveLayer,
name: "Move layer",
execute({ state }, payload) {
const source = findLayerLocation(state.document, payload.layerId);
if (source?.layer.type === "adjustment" && payload.toParentGroupId) return state;
if (payload.toParentGroupId && !findGroup(state.document, payload.toParentGroupId)) return state;
const removed = removeLayerFromDocument(state.document, payload.layerId);
if (!removed.layer) return state;
const maskLayerId = removed.layer.clippingMask?.maskLayerId;
const maskLayerId = getLayerMask(removed.layer)?.maskLayerId;
const removedMask = maskLayerId ? removeLayerFromDocument(removed.document, maskLayerId) : undefined;
const documentAfterRemoval = removedMask?.document ?? removed.document;
if (payload.toParentGroupId && !findGroup(documentAfterRemoval, payload.toParentGroupId)) return state;
@@ -319,25 +437,30 @@ export const documentGroupLayersCommand: Command<DocumentGroupLayersPayload> = {
execute({ state }, payload) {
const requestedIds = [...new Set(payload.layerIds)];
if (requestedIds.length === 0) return state;
if (findLayerLocation(state.document, payload.group.id)) return state;
const artboard = state.document.artboards.find((candidate) => candidate.id === payload.artboardId);
if (!artboard) return state;
const requestedLocations = requestedIds.flatMap((layerId) => {
const location = findLayerLocation(state.document, layerId);
return location ? [location] : [];
});
if (requestedLocations.some((location) => location.layer.type === "adjustment")) return state;
if (requestedLocations.length !== requestedIds.length) return state;
const uniqueIds = [...new Set([...requestedIds, ...collectAttachedMaskIds(artboard.layers, requestedIds)])];
const firstLocation = requestedLocations[0];
if (!firstLocation || firstLocation.artboardId !== payload.artboardId) return state;
if (requestedLocations.some((location) => location.artboardId !== firstLocation.artboardId || location.parentGroupId !== firstLocation.parentGroupId)) return state;
const selected = artboard.layers.filter((layer) => uniqueIds.includes(layer.id));
const uniqueIds = new Set([...requestedIds, ...collectAttachedMaskIds(firstLocation.siblings, requestedIds)]);
const selected = firstLocation.siblings.filter((layer) => uniqueIds.has(layer.id));
if (selected.length === 0) return state;
const firstIndex = artboard.layers.findIndex((layer) => layer.id === selected[0]?.id);
const group: LayerGroup = { ...payload.group, children: selected };
const document = {
...state.document,
artboards: state.document.artboards.map((candidate) =>
candidate.id === payload.artboardId
? { ...candidate, layers: [...candidate.layers.filter((layer) => !uniqueIds.includes(layer.id)).slice(0, firstIndex), group, ...candidate.layers.filter((layer) => !uniqueIds.includes(layer.id)).slice(firstIndex)] }
: candidate,
),
};
const document = replaceLayerListInDocument(
state.document,
firstLocation.artboardId,
firstLocation.parentGroupId,
replaceSelectedLayersWithGroup(firstLocation.siblings, uniqueIds, group),
);
return {
...state,
@@ -378,6 +501,77 @@ export const documentSetLayerLockedCommand: Command<DocumentSetLayerLockedPayloa
},
};
export const documentSetLayerOpacityCommand: Command<DocumentSetLayerOpacityPayload> = {
id: commandIds.documentSetLayerOpacity,
name: "Set layer opacity",
execute({ state }, payload) {
const location = findLayerLocation(state.document, payload.layerId);
if (!location || location.layer.locked || !Number.isFinite(payload.opacity)) return state;
const opacity = Math.min(1, Math.max(0, payload.opacity));
return { ...state, document: mapLayerInDocument(state.document, payload.layerId, (layer) => ({ ...layer, opacity })) };
},
};
export const documentSetLayerSourceRectCommand: Command<DocumentSetLayerSourceRectPayload> = {
id: commandIds.documentSetLayerSourceRect,
name: "Crop layer",
execute({ state }, payload) {
const location = findLayerLocation(state.document, payload.layerId);
if (!location || (location.layer.type !== "image" && location.layer.type !== "raster") || location.layer.locked || location.layer.transform.rotation !== 0) return state;
const leaf = location.layer;
const asset = state.document.assets.find((candidate) => candidate.id === leaf.assetId);
if (!asset) return state;
const sourceRect = payload.sourceRect ? clampSourceRect(payload.sourceRect, asset.intrinsicSize.w, asset.intrinsicSize.h) : undefined;
if (payload.sourceRect && !sourceRect) return state;
return { ...state, document: mapLayerInDocument(state.document, payload.layerId, (layer) => {
if (layer.type !== "image" && layer.type !== "raster") return layer;
if (sourceRect) return { ...layer, sourceRect };
const uncropped = { ...layer };
delete uncropped.sourceRect;
return uncropped;
}) };
},
};
export const documentDuplicateLayerCommand: Command<DocumentDuplicateLayerPayload> = {
id: commandIds.documentDuplicateLayer,
name: "Duplicate layer",
execute({ state }, payload) {
const location = findLayerLocation(state.document, payload.layerId);
if (!location || location.layer.locked) return state;
const maskId = getLayerMask(location.layer)?.maskLayerId;
const mask = maskId ? location.siblings.find((layer) => layer.id === maskId) : undefined;
const sourceLayers = mask ? [mask, location.layer] : [location.layer];
const sourceIds = new Set(sourceLayers.flatMap((layer) => [...collectLayerIds(layer)]));
const mappedIds = [...sourceIds].map((id) => payload.idByLayerId[id]);
if (mappedIds.some((id) => !id) || new Set(mappedIds).size !== mappedIds.length || mappedIds.some((id) => findLayerLocation(state.document, id!))) return state;
const duplicates = sourceLayers.map((layer) => duplicateLayerTree(layer, payload.idByLayerId));
const insertionIndex = Math.max(...sourceLayers.map((layer) => location.siblings.findIndex((candidate) => candidate.id === layer.id))) + 1;
const siblings = [...location.siblings.slice(0, insertionIndex), ...duplicates, ...location.siblings.slice(insertionIndex)];
const duplicatedLayerId = payload.idByLayerId[payload.layerId];
if (!duplicatedLayerId) return state;
return {
...state,
document: replaceLayerListInDocument(state.document, location.artboardId, location.parentGroupId, siblings),
editor: { ...state.editor, selection: { artboardId: location.artboardId, layerIds: [duplicatedLayerId] } },
};
},
};
function duplicateLayerTree(layer: Layer, idByLayerId: Record<LayerId, LayerId>): Layer {
const layerMask = getLayerMask(layer);
const duplicatedMaskId = layerMask ? idByLayerId[layerMask.maskLayerId] : undefined;
const duplicated = {
...layer,
id: idByLayerId[layer.id]!,
name: `${layer.name} copy`,
transform: { ...layer.transform, position: { ...layer.transform.position }, scale: { ...layer.transform.scale } },
...(layerMask && duplicatedMaskId ? { layerMask: { ...layerMask, maskLayerId: duplicatedMaskId }, clippingMask: layer.clippingMask ? { maskLayerId: duplicatedMaskId } : undefined } : {}),
};
return layer.type === "group" ? { ...duplicated, type: "group", children: layer.children.map((child) => duplicateLayerTree(child, idByLayerId)) } : duplicated;
}
export const documentRenameLayerCommand: Command<DocumentRenameLayerPayload> = {
id: commandIds.documentRenameLayer,
name: "Rename layer",
@@ -395,7 +589,7 @@ export const documentSetLayerClippingMaskCommand: Command<DocumentSetLayerClippi
if (payload.maskLayerId === payload.layerId) return state;
if (!payload.maskLayerId) {
const previousMaskId = findLayerLocation(state.document, payload.layerId)?.layer.clippingMask?.maskLayerId;
const previousMaskId = getLayerMask(findLayerLocation(state.document, payload.layerId)?.layer)?.maskLayerId;
return {
...state,
document: mapLayerInDocument(state.document, payload.layerId, (layer) => removeLayerMaskReference(layer)),
@@ -409,6 +603,7 @@ export const documentSetLayerClippingMaskCommand: Command<DocumentSetLayerClippi
const targetLocation = findLayerLocation(state.document, payload.layerId);
const maskLocation = findLayerLocation(state.document, payload.maskLayerId);
if (!targetLocation || !maskLocation) return state;
if (targetLocation.layer.type === "adjustment" || targetLocation.layer.type === "text") return state;
if (targetLocation.artboardId !== maskLocation.artboardId || targetLocation.parentGroupId !== maskLocation.parentGroupId) return state;
const removed = removeLayerFromDocument(state.document, payload.layerId);
@@ -423,7 +618,7 @@ export const documentSetLayerClippingMaskCommand: Command<DocumentSetLayerClippi
removed.document,
maskLocationAfterRemoval.artboardId,
maskLocationAfterRemoval.parentGroupId,
{ ...removed.layer, clippingMask: { maskLayerId: payload.maskLayerId } },
withLayerMask(removed.layer, payload.maskLayerId),
maskLocationAfterRemoval.index + 1,
),
};
@@ -435,23 +630,24 @@ export const documentAddLayerMaskCommand: Command<DocumentAddLayerMaskPayload> =
name: "Add layer mask",
execute({ state }, payload) {
const targetLocation = findLayerLocation(state.document, payload.layerId);
if (!targetLocation || targetLocation.layer.type === "group") return state;
if (!targetLocation || (targetLocation.layer.type !== "image" && targetLocation.layer.type !== "raster")) return state;
const existingMaskId = targetLocation.layer.clippingMask?.maskLayerId;
const existingMaskId = getLayerMask(targetLocation.layer)?.maskLayerId;
if (existingMaskId) {
const existingMaskLocation = findLayerLocation(state.document, existingMaskId);
if (existingMaskLocation?.layer.type === "group") return state;
if (existingMaskLocation) {
const existingMaskLayer = existingMaskLocation?.layer;
if (existingMaskLayer && existingMaskLayer.type !== "image" && existingMaskLayer.type !== "raster") return state;
if (existingMaskLocation && existingMaskLayer) {
return {
...state,
editor: {
...state.editor,
selection: { artboardId: targetLocation.artboardId, layerIds: [payload.layerId] },
maskEdit: { targetLayerId: payload.layerId, maskLayerId: existingMaskId },
maskEdit: { kind: "layerMask", targetLayerId: payload.layerId, maskLayerId: existingMaskId, maskAssetId: existingMaskLayer.assetId },
tools: {
...state.editor.tools,
activeTool: "brush",
interactionMode: { type: "tool", tool: "brush" },
activeTool: payload.activeTool ?? "brush",
interactionMode: { type: "tool", tool: payload.activeTool ?? "brush" },
},
},
};
@@ -467,11 +663,12 @@ export const documentAddLayerMaskCommand: Command<DocumentAddLayerMaskPayload> =
visible: true,
locked: false,
opacity: 1,
layerMask: undefined,
clippingMask: undefined,
};
const withAsset: ImageDocument = { ...state.document, assets: [...state.document.assets, payload.asset] };
const withMaskLayer = insertLayer(withAsset, targetLocation.artboardId, targetLocation.parentGroupId, maskLayer, targetLocation.index);
const document = mapLayerInDocument(withMaskLayer, payload.layerId, (layer) => ({ ...layer, clippingMask: { maskLayerId: maskLayer.id } }));
const document = mapLayerInDocument(withMaskLayer, payload.layerId, (layer) => withLayerMask(layer, maskLayer.id));
return {
...state,
@@ -479,23 +676,56 @@ export const documentAddLayerMaskCommand: Command<DocumentAddLayerMaskPayload> =
editor: {
...state.editor,
selection: { artboardId: targetLocation.artboardId, layerIds: [payload.layerId] },
maskEdit: { targetLayerId: payload.layerId, maskLayerId: maskLayer.id },
maskEdit: { kind: "layerMask", targetLayerId: payload.layerId, maskLayerId: maskLayer.id, maskAssetId: payload.asset.id },
tools: {
...state.editor.tools,
activeTool: "brush",
interactionMode: { type: "tool", tool: "brush" },
activeTool: payload.activeTool ?? "brush",
interactionMode: { type: "tool", tool: payload.activeTool ?? "brush" },
},
},
};
},
};
export const documentApplyLayerMaskOperationCommand: Command<DocumentApplyLayerMaskOperationPayload> = {
id: commandIds.documentApplyLayerMaskOperation,
name: "Apply layer mask operation",
execute({ state }, payload) {
if (!payload.source.trim()) return state;
const maskLocation = findLayerLocation(state.document, payload.maskLayerId);
if (!maskLocation || (maskLocation.layer.type !== "image" && maskLocation.layer.type !== "raster")) return state;
if (!isReferencedMaskLayer(state.document, payload.maskLayerId)) return state;
const maskAssetId = maskLocation.layer.assetId;
return {
...state,
document: {
...state.document,
assets: state.document.assets.map((asset) =>
asset.id === maskAssetId
? {
...asset,
source: payload.source,
mimeType: payload.mimeType ?? asset.mimeType,
}
: asset,
),
},
editor: {
...state.editor,
brushStrokePreview: state.editor.brushStrokePreview?.assetId === maskAssetId ? undefined : state.editor.brushStrokePreview,
},
};
},
};
export const documentRemoveLayerMaskCommand: Command<DocumentRemoveLayerMaskPayload> = {
id: commandIds.documentRemoveLayerMask,
name: "Remove layer mask",
execute({ state }, payload) {
const targetLocation = findLayerLocation(state.document, payload.layerId);
const maskLayerId = targetLocation?.layer.clippingMask?.maskLayerId;
const maskLayerId = targetLocation?.layer ? getLayerMask(targetLocation.layer)?.maskLayerId : undefined;
if (!targetLocation || !maskLayerId) {
return state.editor.maskEdit?.targetLayerId === payload.layerId ? { ...state, editor: { ...state.editor, maskEdit: undefined } } : state;
}
@@ -525,7 +755,8 @@ export const documentRemoveLayerCommand: Command<DocumentRemoveLayerPayload> = {
const removedLayerIds = collectLayerIds(removed.layer);
const removedMaskLayerIds = collectClippingMaskIds([removed.layer]);
const cleanedReferences = removeMissingMaskReferences(removed.document);
const document = [...removedMaskLayerIds].reduce((nextDocument, maskLayerId) => removeUnreferencedMaskLayer(nextDocument, maskLayerId), cleanedReferences);
const withoutMasks = [...removedMaskLayerIds].reduce((nextDocument, maskLayerId) => removeUnreferencedMaskLayer(nextDocument, maskLayerId), cleanedReferences);
const document = removeInpaintRegionsForTargets(withoutMasks, removedLayerIds);
const selection = {
...state.editor.selection,
layerIds: state.editor.selection.layerIds.filter((id) => !removedLayerIds.has(id)),
@@ -546,6 +777,7 @@ export const documentRemoveLayerCommand: Command<DocumentRemoveLayerPayload> = {
export const documentCommands = [
documentAddArtboardCommand,
documentSetArtboardBoundsCommand,
documentResizeArtboardCommand,
documentRemoveArtboardCommand,
documentSetArtboardVisibleCommand,
documentSetArtboardLockedCommand,
@@ -555,244 +787,56 @@ export const documentCommands = [
documentAddImageLayerCommand,
documentAddRasterLayerCommand,
documentAddGroupLayerCommand,
documentAddAdjustmentLayerCommand,
documentAddTextLayerCommand,
documentSetTextLayerCommand,
documentSetAdjustmentCommand,
documentMoveLayerCommand,
documentGroupLayersCommand,
documentUngroupLayerCommand,
documentRemoveLayerCommand,
documentSetLayerVisibleCommand,
documentSetLayerLockedCommand,
documentSetLayerOpacityCommand,
documentSetLayerSourceRectCommand,
documentDuplicateLayerCommand,
documentRenameLayerCommand,
documentSetLayerClippingMaskCommand,
documentAddLayerMaskCommand,
documentApplyLayerMaskOperationCommand,
documentRemoveLayerMaskCommand,
] satisfies Command<unknown>[];
type LayerLocation = {
artboardId: ArtboardId;
parentGroupId?: LayerId;
index: number;
layer: Layer;
};
function findLayerLocation(document: ImageDocument, layerId: LayerId): LayerLocation | undefined {
for (const artboard of document.artboards) {
const location = findLayerLocationInTree(artboard.layers, layerId, artboard.id);
if (location) return location;
}
return undefined;
function validRect(rect: Rect): Rect | undefined {
return [rect.x, rect.y, rect.w, rect.h].every(Number.isFinite) && rect.w >= 1 && rect.h >= 1 ? { ...rect } : undefined;
}
function findLayerLocationInTree(layers: Layer[], layerId: LayerId, artboardId: ArtboardId, parentGroupId?: LayerId): LayerLocation | undefined {
for (let index = 0; index < layers.length; index++) {
const layer = layers[index];
if (!layer) continue;
if (layer.id === layerId) return { artboardId, parentGroupId, index, layer };
if (layer.type === "group") {
const child = findLayerLocationInTree(layer.children, layerId, artboardId, layer.id);
if (child) return child;
}
}
return undefined;
function validAdjustment(value: ColorAdjustment): boolean {
return [value.brightness, value.contrast, value.saturation, value.colorBalance.red, value.colorBalance.green, value.colorBalance.blue].every((number) => Number.isFinite(number) && number >= -1 && number <= 1);
}
function mapLayerInDocument(document: ImageDocument, layerId: LayerId, mapLayer: (layer: Layer) => Layer): ImageDocument {
return {
...document,
artboards: document.artboards.map((artboard) => ({ ...artboard, layers: mapLayerInTree(artboard.layers, layerId, mapLayer) })),
};
function clampSourceRect(rect: Rect, width: number, height: number): Rect | undefined {
if (![rect.x, rect.y, rect.w, rect.h].every(Number.isFinite)) return undefined;
const x = Math.max(0, Math.min(width - 1, rect.x));
const y = Math.max(0, Math.min(height - 1, rect.y));
const w = Math.min(width - x, rect.w);
const h = Math.min(height - y, rect.h);
return w >= 1 && h >= 1 ? { x, y, w, h } : undefined;
}
function mapLayerInTree(layers: Layer[], layerId: LayerId, mapLayer: (layer: Layer) => Layer): Layer[] {
return layers.map((layer) => {
if (layer.id === layerId) return mapLayer(layer);
if (layer.type === "group") return { ...layer, children: mapLayerInTree(layer.children, layerId, mapLayer) };
return layer;
});
}
function insertLayer(document: ImageDocument, artboardId: ArtboardId, parentGroupId: LayerId | undefined, layer: Layer, index?: number): ImageDocument {
return {
...document,
artboards: document.artboards.map((artboard) => {
if (artboard.id !== artboardId) return artboard;
if (!parentGroupId) return { ...artboard, layers: insertAt(artboard.layers, layer, index) };
return { ...artboard, layers: insertLayerInGroup(artboard.layers, parentGroupId, layer, index) };
}),
};
}
function insertLayerInGroup(layers: Layer[], groupId: LayerId, layer: Layer, index?: number): Layer[] {
return layers.map((candidate) => {
if (candidate.type === "group" && candidate.id === groupId) return { ...candidate, children: insertAt(candidate.children, layer, index) };
if (candidate.type === "group") return { ...candidate, children: insertLayerInGroup(candidate.children, groupId, layer, index) };
return candidate;
});
}
function removeLayerFromDocument(document: ImageDocument, layerId: LayerId): { document: ImageDocument; layer?: Layer } {
let removed: Layer | undefined;
return {
document: {
...document,
artboards: document.artboards.map((artboard) => {
const result = removeLayerFromTree(artboard.layers, layerId);
if (result.layer) removed = result.layer;
return { ...artboard, layers: result.layers };
}),
function scaleLayerTree(layers: Layer[], before: Rect, after: Rect, scaleX: number, scaleY: number): Layer[] {
return layers.map((layer) => layer.type === "adjustment" ? layer : layer.type === "group" ? {
...layer,
children: scaleLayerTree(layer.children, before, after, scaleX, scaleY),
} : {
...layer,
transform: {
...layer.transform,
position: {
x: after.x + (layer.transform.position.x - before.x) * scaleX,
y: after.y + (layer.transform.position.y - before.y) * scaleY,
},
scale: { x: layer.transform.scale.x * scaleX, y: layer.transform.scale.y * scaleY },
},
layer: removed,
};
}
function removeLayerFromTree(layers: Layer[], layerId: LayerId): { layers: Layer[]; layer?: Layer } {
let removed: Layer | undefined;
const next: Layer[] = [];
for (const layer of layers) {
if (layer.id === layerId) {
removed = layer;
continue;
}
if (layer.type === "group") {
const result = removeLayerFromTree(layer.children, layerId);
if (result.layer) removed = result.layer;
next.push({ ...layer, children: result.layers });
} else {
next.push(layer);
}
}
return { layers: next, layer: removed };
}
function ungroupLayerInDocument(document: ImageDocument, groupId: LayerId): { document: ImageDocument; changed: boolean; artboardId?: ArtboardId; children: Layer[] } {
let changed = false;
let artboardId: ArtboardId | undefined;
let children: Layer[] = [];
const next = {
...document,
artboards: document.artboards.map((artboard) => {
const result = ungroupLayerInTree(artboard.layers, groupId);
if (result.changed) {
changed = true;
artboardId = artboard.id;
children = result.children;
}
return { ...artboard, layers: result.layers };
}),
};
return { document: next, changed, artboardId, children };
}
function ungroupLayerInTree(layers: Layer[], groupId: LayerId): { layers: Layer[]; changed: boolean; children: Layer[] } {
const next: Layer[] = [];
for (const layer of layers) {
if (layer.type === "group" && layer.id === groupId) return { layers: [...next, ...layer.children, ...layers.slice(next.length + 1)], changed: true, children: layer.children };
if (layer.type === "group") {
const result = ungroupLayerInTree(layer.children, groupId);
if (result.changed) return { layers: [...next, { ...layer, children: result.layers }, ...layers.slice(next.length + 1)], changed: true, children: result.children };
}
next.push(layer);
}
return { layers, changed: false, children: [] };
}
function insertAt(layers: Layer[], layer: Layer, index = layers.length) {
const clamped = Math.max(0, Math.min(index, layers.length));
return [...layers.slice(0, clamped), layer, ...layers.slice(clamped)];
}
function findGroup(document: ImageDocument, groupId: LayerId): LayerGroup | undefined {
for (const artboard of document.artboards) {
const group = findGroupInTree(artboard.layers, groupId);
if (group) return group;
}
return undefined;
}
function findGroupInTree(layers: Layer[], groupId: LayerId): LayerGroup | undefined {
for (const layer of layers) {
if (layer.type === "group" && layer.id === groupId) return layer;
if (layer.type === "group") {
const child = findGroupInTree(layer.children, groupId);
if (child) return child;
}
}
return undefined;
}
function removeLayerMaskReference(layer: Layer): Layer {
const next = { ...layer };
delete next.clippingMask;
return next;
}
function removeUnreferencedMaskLayer(document: ImageDocument, maskLayerId: LayerId): ImageDocument {
if (isMaskLayerReferenced(document, maskLayerId)) return document;
return removeLayerFromDocument(document, maskLayerId).document;
}
function isMaskLayerReferenced(document: ImageDocument, maskLayerId: LayerId): boolean {
return collectClippingMaskIds(document.artboards.flatMap((artboard) => artboard.layers)).has(maskLayerId);
}
function removeMissingMaskReferences(document: ImageDocument): ImageDocument {
const existingLayerIds = collectDocumentLayerIds(document);
return mapAllLayersInDocument(document, (layer) => {
if (!layer.clippingMask || existingLayerIds.has(layer.clippingMask.maskLayerId)) return layer;
return removeLayerMaskReference(layer);
});
}
function isMaskEditFor(maskEdit: { targetLayerId: LayerId; maskLayerId: LayerId } | undefined, targetLayerId: LayerId, maskLayerId: LayerId) {
return maskEdit?.targetLayerId === targetLayerId && maskEdit.maskLayerId === maskLayerId;
}
function isMaskEditValid(maskEdit: { targetLayerId: LayerId; maskLayerId: LayerId } | undefined, document: ImageDocument) {
if (!maskEdit) return false;
const target = findLayerLocation(document, maskEdit.targetLayerId)?.layer;
const mask = findLayerLocation(document, maskEdit.maskLayerId)?.layer;
return Boolean(target?.clippingMask?.maskLayerId === maskEdit.maskLayerId && mask && mask.type !== "group");
}
function collectDocumentLayerIds(document: ImageDocument): Set<LayerId> {
const ids = new Set<LayerId>();
for (const artboard of document.artboards) collectLayerIdsFromTree(artboard.layers, ids);
return ids;
}
function collectLayerIds(layer: Layer, ids = new Set<LayerId>()): Set<LayerId> {
ids.add(layer.id);
if (layer.type === "group") collectLayerIdsFromTree(layer.children, ids);
return ids;
}
function collectLayerIdsFromTree(layers: readonly Layer[], ids = new Set<LayerId>()): Set<LayerId> {
for (const layer of layers) collectLayerIds(layer, ids);
return ids;
}
function collectClippingMaskIds(layers: readonly Layer[], ids = new Set<LayerId>()): Set<LayerId> {
for (const layer of layers) {
if (layer.clippingMask) ids.add(layer.clippingMask.maskLayerId);
if (layer.type === "group") collectClippingMaskIds(layer.children, ids);
}
return ids;
}
function collectAttachedMaskIds(layers: readonly Layer[], layerIds: readonly LayerId[]): LayerId[] {
const layerIdSet = new Set(layerIds);
return layers.flatMap((layer) => (layerIdSet.has(layer.id) && layer.clippingMask ? [layer.clippingMask.maskLayerId] : []));
}
function mapAllLayersInDocument(document: ImageDocument, mapLayer: (layer: Layer) => Layer): ImageDocument {
return {
...document,
artboards: document.artboards.map((artboard) => ({ ...artboard, layers: mapAllLayersInTree(artboard.layers, mapLayer) })),
};
}
function mapAllLayersInTree(layers: Layer[], mapLayer: (layer: Layer) => Layer): Layer[] {
return layers.map((layer) => {
const mapped = layer.type === "group" ? { ...layer, children: mapAllLayersInTree(layer.children, mapLayer) } : layer;
return mapLayer(mapped);
});
}

16
commands/editor.ts Normal file
View File

@@ -0,0 +1,16 @@
import type { Command } from "./command";
import { commandIds } from "./ids";
export type EditorSetPointerSessionPayload = { type: "pan" } | undefined;
export const editorSetPointerSessionCommand: Command<EditorSetPointerSessionPayload> = {
id: commandIds.editorSetPointerSession,
name: "Set pointer session",
history: { mode: "ignore" },
execute({ state }, payload) {
if (state.editor.pointerSession?.type === payload?.type) return state;
return { ...state, editor: { ...state.editor, pointerSession: payload } };
},
};
export const editorCommands = [editorSetPointerSessionCommand] satisfies Command<unknown>[];

276
commands/generation.test.ts Normal file
View File

@@ -0,0 +1,276 @@
import { describe, expect, test } from "bun:test";
import type { GenerationCandidate } from "@editor/state";
import { createInitialAppState } from "@editor/initial-state";
import {
generationAddCandidateCommand,
generationApplyCandidateAsLayerCommand,
generationRemoveCandidateCommand,
generationClearCandidatesCommand,
generationReuseCandidateSettingsCommand,
generationReplaceCandidatePixelsCommand,
generationSetCompareModeCommand,
generationFailJobCommand,
generationStartJobCommand,
generationSucceedJobCommand,
generationCancelJobCommand,
} from "./generation";
describe("generation commands", () => {
test("adds, selects, and removes candidates", () => {
const state = createInitialAppState("Test");
const candidate = generationCandidate("candidate-1");
const added = generationAddCandidateCommand.execute({ state }, { candidate });
const removed = generationRemoveCandidateCommand.execute({ state: added }, { candidateId: candidate.id });
expect(added.editor.generation.candidates).toEqual([candidate]);
expect(added.editor.generation.selectedCandidateId).toBe(candidate.id);
expect(added.editor.generation.compareMode).toBe("result");
expect(removed.editor.generation.candidates).toEqual([]);
expect(removed.editor.generation.selectedCandidateId).toBeUndefined();
expect(removed.editor.generation.compareMode).toBe("result");
});
test("sets generation compare mode without touching candidates", () => {
const state = generationAddCandidateCommand.execute({ state: createInitialAppState("Test") }, { candidate: generationCandidate("candidate-1") });
const next = generationSetCompareModeCommand.execute({ state }, { mode: "split" });
expect(next.editor.generation.candidates).toEqual([generationCandidate("candidate-1")]);
expect(next.editor.generation.selectedCandidateId).toBe("candidate-1");
expect(next.editor.generation.compareMode).toBe("split");
});
test("bounds retained candidate source memory as well as candidate count", () => {
const largeSource = "x".repeat(34 * 1024 * 1024);
const first = { ...generationCandidate("candidate-1"), source: largeSource };
const second = { ...generationCandidate("candidate-2"), source: largeSource };
const withFirst = generationAddCandidateCommand.execute({ state: createInitialAppState("Test") }, { candidate: first });
const withSecond = generationAddCandidateCommand.execute({ state: withFirst }, { candidate: second });
expect(withSecond.editor.generation.candidates.map((candidate) => candidate.id)).toEqual(["candidate-2"]);
});
test("clears the candidate session and resets comparison", () => {
const withCandidate = generationAddCandidateCommand.execute(
{ state: createInitialAppState("Test") },
{ candidate: generationCandidate("candidate-1") },
);
const comparing = generationSetCompareModeCommand.execute({ state: withCandidate }, { mode: "split" });
const next = generationClearCandidatesCommand.execute({ state: comparing }, undefined);
expect(next.editor.generation.candidates).toEqual([]);
expect(next.editor.generation.selectedCandidateId).toBeUndefined();
expect(next.editor.generation.compareMode).toBe("result");
});
test("reuses a candidate's full settings with its resolved seed", () => {
const candidate = generationCandidate("candidate-1");
candidate.settings = {
...candidate.settings,
prompt: "Stored prompt",
model: "stored-model.safetensors",
seed: -1,
inpaint: { ...candidate.settings.inpaint, maskBlur: 7 },
};
candidate.seed = 987654;
const state = generationAddCandidateCommand.execute({ state: createInitialAppState("Test") }, { candidate });
const next = generationReuseCandidateSettingsCommand.execute({ state }, { candidateId: candidate.id });
expect(next.editor.tools.generate).toEqual({ ...candidate.settings, seed: 987654 });
expect(next.editor.generation).toBe(state.editor.generation);
});
test("applies candidates as top-level layers", () => {
const withRemainingCandidate = generationAddCandidateCommand.execute(
{ state: documentWithSourceLayer() },
{ candidate: generationCandidate("candidate-2") },
);
const withSelectedCandidate = generationAddCandidateCommand.execute(
{ state: withRemainingCandidate },
{ candidate: generationCandidate("candidate-1") },
);
const state = generationSetCompareModeCommand.execute({ state: withSelectedCandidate }, { mode: "split" });
const next = generationApplyCandidateAsLayerCommand.execute(
{ state },
{ candidateId: "candidate-1", assetId: "generated-asset", layerId: "generated-layer" },
);
expect(next.document.assets.find((asset) => asset.id === "generated-asset")?.source).toBe("generated-source");
expect(next.document.assets.find((asset) => asset.id === "generated-asset")?.provenance).toMatchObject({
kind: "generated",
candidateId: "candidate-1",
acceptance: "layer",
seed: 123,
});
expect(next.document.artboards[0]?.layers[0]?.id).toBe("generated-layer");
expect(next.editor.selection).toEqual({ artboardId: "a1", layerIds: ["generated-layer"] });
expect(next.editor.generation).toEqual({
candidates: [generationCandidate("candidate-2")],
selectedCandidateId: "candidate-2",
compareMode: "split",
jobs: [],
resources: { status: "idle" },
});
});
test("replaces source asset pixels for inpaint candidates", () => {
const withRemainingCandidate = generationAddCandidateCommand.execute(
{ state: documentWithSourceLayer() },
{ candidate: generationCandidate("candidate-2", true) },
);
const withSelectedCandidate = generationAddCandidateCommand.execute(
{ state: withRemainingCandidate },
{ candidate: generationCandidate("candidate-1", true) },
);
const state = generationSetCompareModeCommand.execute({ state: withSelectedCandidate }, { mode: "before" });
const next = generationReplaceCandidatePixelsCommand.execute(
{ state },
{ candidateId: "candidate-1", source: "composited-source", mimeType: "image/png" },
);
expect(next.document.assets.find((asset) => asset.id === "source-asset")?.source).toBe("composited-source");
expect(next.document.assets.find((asset) => asset.id === "source-asset")?.mimeType).toBe("image/png");
expect(next.document.assets.find((asset) => asset.id === "source-asset")?.provenance).toMatchObject({
kind: "generated",
candidateId: "candidate-1",
acceptance: "replacement",
inpaint: {
sourceAssetId: "source-asset",
maskAssetId: "mask-asset",
},
});
expect(next.editor.selection).toEqual({ artboardId: "a1", layerIds: ["source-layer"] });
expect(next.editor.generation).toEqual({
candidates: [generationCandidate("candidate-2", true)],
selectedCandidateId: "candidate-2",
compareMode: "before",
jobs: [],
resources: { status: "idle" },
});
});
test("tracks one durable generation job through completion", () => {
const state = createInitialAppState("Test");
const running = generationStartJobCommand.execute({ state }, { jobId: "job-1", kind: "generate", label: "Generating", startedAt: 100 });
const duplicate = generationStartJobCommand.execute({ state: running }, { jobId: "job-2", kind: "regenerate", label: "Regenerate", startedAt: 101 });
const completed = generationSucceedJobCommand.execute({ state: duplicate }, { jobId: "job-1", finishedAt: 150 });
expect(duplicate).toBe(running);
expect(completed.editor.generation.jobs[0]).toEqual({ id: "job-1", kind: "generate", label: "Generating", status: "succeeded", startedAt: 100, finishedAt: 150, error: undefined });
});
test("preserves generation errors in authoritative state", () => {
const running = generationStartJobCommand.execute({ state: createInitialAppState("Test") }, { jobId: "job-1", kind: "replace", label: "Replacing pixels", startedAt: 100 });
const failed = generationFailJobCommand.execute({ state: running }, { jobId: "job-1", finishedAt: 125, error: "Backend unavailable" });
expect(failed.editor.generation.jobs[0]).toMatchObject({ id: "job-1", status: "failed", error: "Backend unavailable", finishedAt: 125 });
});
test("records explicit cancellation separately from failure", () => {
const running = generationStartJobCommand.execute({ state: createInitialAppState("Test") }, { jobId: "job-1", kind: "generate", label: "Generating", startedAt: 100 });
const cancelled = generationCancelJobCommand.execute({ state: running }, { jobId: "job-1", finishedAt: 110 });
expect(cancelled.editor.generation.jobs[0]).toMatchObject({ id: "job-1", status: "cancelled", finishedAt: 110 });
});
});
function documentWithSourceLayer() {
return {
...createInitialAppState("Test"),
document: {
...createInitialAppState("Test").document,
assets: [
{ id: "source-asset", name: "Source", mimeType: "image/png", source: "source", intrinsicSize: { w: 100, h: 100 } },
{ id: "mask-asset", name: "Mask", mimeType: "image/png", source: "mask", intrinsicSize: { w: 100, h: 100 } },
],
artboards: [
{
id: "a1",
name: "Artboard 1",
bounds: { x: 0, y: 0, w: 100, h: 100 },
backgroundColor: "transparent",
visible: true,
locked: false,
layers: [
raster("mask-layer", "Mask", "mask-asset"),
{ ...raster("source-layer", "Source", "source-asset"), clippingMask: { maskLayerId: "mask-layer" } },
],
},
],
},
};
}
function generationCandidate(id: string, inpaint = false): GenerationCandidate {
const candidate: GenerationCandidate = {
id,
source: "generated-source",
mimeType: "image/png",
intrinsicSize: { w: 64, h: 64 },
mode: inpaint ? "inpaint" : "text-to-image",
settings: createInitialAppState("Test").editor.tools.generate,
seed: 123,
width: 64,
height: 64,
placement: {
artboardId: "a1",
layerName: "Generated",
transform: { position: { x: 5, y: 6 }, scale: { x: 1, y: 1 }, rotation: 0 },
},
};
return inpaint
? {
...candidate,
inputImage: "input",
maskImage: "mask",
inpaint: {
targetLayerId: "source-layer",
regionId: "region",
sourceAssetId: "source-asset",
maskAssetId: "mask-asset",
inputImage: "input",
maskImage: "mask",
editMaskImage: "edit-mask",
blendMaskImage: "blend-mask",
revision: { source: "source-revision", mask: "mask-revision" },
crop: {
assetBounds: { x: 0, y: 0, w: 64, h: 64 },
documentBounds: { x: 0, y: 0, w: 64, h: 64 },
padding: 12,
maskedAreaOnly: true,
},
mask: {
polarity: "hidden",
activeBounds: { x: 10, y: 10, w: 20, h: 20 },
},
backend: {
growMaskBy: 6,
maskedContent: "neutral",
maskBlur: 0,
maskFeather: 0,
maskExpand: 0,
cropPadding: 12,
},
},
}
: candidate;
}
function raster(id: string, name: string, assetId: string) {
return {
id,
type: "raster" as const,
name,
visible: true,
locked: false,
opacity: 1,
assetId,
transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 },
};
}

515
commands/generation.ts Normal file
View File

@@ -0,0 +1,515 @@
import type { Asset } from "@core/asset";
import type { AssetGenerationProvenance, GeneratedAssetAcceptance } from "@core/asset-provenance";
import type { ImageDocument } from "@core/document";
import type { ArtboardId, AssetId, GenerationCandidateId, GenerationJobId, LayerId } from "@core/id";
import type { ImageLayer } from "@core/image-layer";
import type { Layer } from "@core/layer";
import type { AppState, GenerationCandidate, GenerationCompareMode, GenerationJobKind, GenerationOptions, GenerationState } from "@editor/state";
import type { Command } from "./command";
import { commandIds } from "./ids";
export type GenerationAddCandidatePayload = {
candidate: GenerationCandidate;
};
export type GenerationSelectCandidatePayload = {
candidateId?: GenerationCandidateId;
};
export type GenerationSetCompareModePayload = {
mode: GenerationCompareMode;
};
export type GenerationRemoveCandidatePayload = {
candidateId: GenerationCandidateId;
};
export type GenerationToggleCandidateFavoritePayload = { candidateId: GenerationCandidateId };
export type GenerationReuseCandidateSettingsPayload = {
candidateId: GenerationCandidateId;
};
export type GenerationApplyCandidateAsLayerPayload = {
candidateId: GenerationCandidateId;
assetId: AssetId;
layerId: LayerId;
};
export type GenerationReplaceCandidatePixelsPayload = {
candidateId: GenerationCandidateId;
source: string;
mimeType?: string;
};
export type GenerationStartJobPayload = { jobId: GenerationJobId; kind: GenerationJobKind; label: string; startedAt: number };
export type GenerationUpdateJobPayload = { jobId: GenerationJobId; progress: number; detail: string };
export type GenerationSucceedJobPayload = { jobId: GenerationJobId; finishedAt: number };
export type GenerationFailJobPayload = { jobId: GenerationJobId; finishedAt: number; error: string };
export type GenerationCancelJobPayload = { jobId: GenerationJobId; finishedAt: number };
export type GenerationSetResourcesPayload = { options: GenerationOptions };
export type GenerationFailResourcesPayload = { error: string };
const maxCandidates = 12;
const maxCandidateSourceBytes = 96 * 1024 * 1024;
const maxJobs = 20;
const generationCompareModes = new Set<GenerationCompareMode>(["result", "before", "split"]);
export const generationAddCandidateCommand: Command<GenerationAddCandidatePayload> = {
id: commandIds.generationAddCandidate,
name: "Add generation candidate",
history: { mode: "ignore" },
execute({ state }, payload) {
const existing = state.editor.generation.candidates.filter((candidate) => candidate.id !== payload.candidate.id);
const candidates = retainCandidateBudget([payload.candidate, ...existing.filter((candidate) => candidate.favorite), ...existing.filter((candidate) => !candidate.favorite)]);
return {
...state,
editor: {
...state.editor,
generation: {
...state.editor.generation,
candidates,
selectedCandidateId: payload.candidate.id,
compareMode: "result",
},
},
};
},
};
export const generationSelectCandidateCommand: Command<GenerationSelectCandidatePayload> = {
id: commandIds.generationSelectCandidate,
name: "Select generation candidate",
history: { mode: "ignore" },
execute({ state }, payload) {
const selectedCandidateId = payload.candidateId && state.editor.generation.candidates.some((candidate) => candidate.id === payload.candidateId) ? payload.candidateId : undefined;
if (state.editor.generation.selectedCandidateId === selectedCandidateId) return state;
return {
...state,
editor: {
...state.editor,
generation: {
...state.editor.generation,
selectedCandidateId,
},
},
};
},
};
export const generationSetCompareModeCommand: Command<GenerationSetCompareModePayload> = {
id: commandIds.generationSetCompareMode,
name: "Set generation compare mode",
history: { mode: "ignore" },
execute({ state }, payload) {
if (!generationCompareModes.has(payload.mode)) return state;
if (state.editor.generation.compareMode === payload.mode) return state;
return {
...state,
editor: {
...state.editor,
generation: {
...state.editor.generation,
compareMode: payload.mode,
},
},
};
},
};
export const generationRemoveCandidateCommand: Command<GenerationRemoveCandidatePayload> = {
id: commandIds.generationRemoveCandidate,
name: "Remove generation candidate",
history: { mode: "ignore" },
execute({ state }, payload) {
const generation = removeGenerationCandidate(state.editor.generation, payload.candidateId);
if (generation === state.editor.generation) return state;
return {
...state,
editor: {
...state.editor,
generation,
},
};
},
};
export const generationToggleCandidateFavoriteCommand: Command<GenerationToggleCandidateFavoritePayload> = {
id: commandIds.generationToggleCandidateFavorite,
name: "Favorite generation candidate",
history: { mode: "ignore" },
execute({ state }, payload) {
if (!state.editor.generation.candidates.some((candidate) => candidate.id === payload.candidateId)) return state;
return {
...state,
editor: {
...state.editor,
generation: {
...state.editor.generation,
candidates: state.editor.generation.candidates.map((candidate) => candidate.id === payload.candidateId ? { ...candidate, favorite: !candidate.favorite } : candidate),
},
},
};
},
};
export const generationClearCandidatesCommand: Command = {
id: commandIds.generationClearCandidates,
name: "Clear generation candidates",
history: { mode: "ignore" },
execute({ state }) {
if (state.editor.generation.candidates.length === 0 && !state.editor.generation.selectedCandidateId && state.editor.generation.compareMode === "result") return state;
return {
...state,
editor: {
...state.editor,
generation: { ...state.editor.generation, candidates: [], selectedCandidateId: undefined, compareMode: "result" },
},
};
},
};
export const generationReuseCandidateSettingsCommand: Command<GenerationReuseCandidateSettingsPayload> = {
id: commandIds.generationReuseCandidateSettings,
name: "Reuse generation candidate settings",
execute({ state }, payload) {
const candidate = state.editor.generation.candidates.find((item) => item.id === payload.candidateId);
if (!candidate) return state;
return {
...state,
editor: {
...state.editor,
tools: {
...state.editor.tools,
generate: {
...candidate.settings,
seed: candidate.seed,
outpaint: { ...candidate.settings.outpaint },
inpaint: { ...candidate.settings.inpaint },
},
},
},
};
},
};
export const generationApplyCandidateAsLayerCommand: Command<GenerationApplyCandidateAsLayerPayload> = {
id: commandIds.generationApplyCandidateAsLayer,
name: "Apply generation candidate as layer",
execute({ state }, payload) {
const candidate = state.editor.generation.candidates.find((item) => item.id === payload.candidateId);
if (!candidate) return state;
if (state.document.assets.some((asset) => asset.id === payload.assetId) || findLayerLocation(state.document, payload.layerId)) return state;
if (!state.document.artboards.some((artboard) => artboard.id === candidate.placement.artboardId)) return state;
const asset: Asset = {
id: payload.assetId,
name: candidate.placement.layerName,
mimeType: candidate.mimeType,
source: candidate.source,
intrinsicSize: { ...candidate.intrinsicSize },
provenance: generationProvenance(candidate, "layer"),
};
const layer: ImageLayer = {
id: payload.layerId,
type: "image",
name: candidate.placement.layerName,
visible: true,
locked: false,
opacity: 1,
assetId: asset.id,
transform: {
position: { ...candidate.placement.transform.position },
scale: { ...candidate.placement.transform.scale },
rotation: candidate.placement.transform.rotation,
},
};
return {
...state,
document: insertLayerAtTop({ ...state.document, assets: [...state.document.assets, asset] }, candidate.placement.artboardId, layer),
editor: {
...state.editor,
generation: removeGenerationCandidate(state.editor.generation, candidate.id),
selection: { artboardId: candidate.placement.artboardId, layerIds: [layer.id] },
},
};
},
};
export const generationReplaceCandidatePixelsCommand: Command<GenerationReplaceCandidatePixelsPayload> = {
id: commandIds.generationReplaceCandidatePixels,
name: "Replace masked pixels with generation candidate",
execute({ state }, payload) {
const candidate = state.editor.generation.candidates.find((item) => item.id === payload.candidateId);
if (!candidate?.inpaint || !payload.source.trim()) return state;
const targetAsset = state.document.assets.find((asset) => asset.id === candidate.inpaint?.sourceAssetId);
const targetLayerLocation = findLayerLocation(state.document, candidate.inpaint.targetLayerId);
if (!targetAsset || !targetLayerLocation) return state;
return {
...state,
document: {
...state.document,
assets: state.document.assets.map((asset) =>
asset.id === targetAsset.id
? {
...asset,
source: payload.source,
mimeType: payload.mimeType ?? asset.mimeType,
provenance: generationProvenance(candidate, "replacement"),
}
: asset,
),
},
editor: {
...state.editor,
generation: removeGenerationCandidate(state.editor.generation, candidate.id),
selection: { artboardId: targetLayerLocation.artboardId, layerIds: [candidate.inpaint.targetLayerId] },
},
};
},
};
export const generationStartJobCommand: Command<GenerationStartJobPayload> = {
id: commandIds.generationStartJob,
name: "Start generation job",
history: { mode: "ignore" },
execute({ state }, payload) {
if (!payload.jobId || !payload.label.trim() || !Number.isFinite(payload.startedAt)) return state;
if (state.editor.generation.jobs.some((job) => job.status === "running" || job.id === payload.jobId)) return state;
const job: GenerationState["jobs"][number] = { id: payload.jobId, kind: payload.kind, label: payload.label.trim(), status: "running", startedAt: payload.startedAt };
return updateJobs(state, [job, ...state.editor.generation.jobs].slice(0, maxJobs));
},
};
export const generationUpdateJobCommand: Command<GenerationUpdateJobPayload> = {
id: commandIds.generationUpdateJob,
name: "Update generation progress",
history: { mode: "ignore" },
execute({ state }, payload) {
const job = state.editor.generation.jobs.find((candidate) => candidate.id === payload.jobId);
if (!job || job.status !== "running" || !Number.isFinite(payload.progress) || !payload.detail.trim()) return state;
return updateJobs(state, state.editor.generation.jobs.map((candidate) => candidate.id === payload.jobId ? { ...candidate, progress: Math.max(0, Math.min(1, payload.progress)), detail: payload.detail.trim() } : candidate));
},
};
export const generationSucceedJobCommand: Command<GenerationSucceedJobPayload> = {
id: commandIds.generationSucceedJob,
name: "Complete generation job",
history: { mode: "ignore" },
execute({ state }, payload) {
return settleJob(state, payload.jobId, payload.finishedAt, "succeeded");
},
};
export const generationFailJobCommand: Command<GenerationFailJobPayload> = {
id: commandIds.generationFailJob,
name: "Fail generation job",
history: { mode: "ignore" },
execute({ state }, payload) {
if (!payload.error.trim()) return state;
return settleJob(state, payload.jobId, payload.finishedAt, "failed", payload.error.trim());
},
};
export const generationCancelJobCommand: Command<GenerationCancelJobPayload> = {
id: commandIds.generationCancelJob,
name: "Cancel generation job",
history: { mode: "ignore" },
execute({ state }, payload) {
return settleJob(state, payload.jobId, payload.finishedAt, "cancelled");
},
};
export const generationLoadResourcesCommand: Command = {
id: commandIds.generationLoadResources,
name: "Load generation resources",
history: { mode: "ignore" },
execute({ state }) {
if (state.editor.generation.resources.status === "loading") return state;
return updateResources(state, { status: "loading" });
},
};
export const generationSetResourcesCommand: Command<GenerationSetResourcesPayload> = {
id: commandIds.generationSetResources,
name: "Set generation resources",
history: { mode: "ignore" },
execute({ state }, payload) {
return updateResources(state, { status: "ready", options: payload.options });
},
};
export const generationFailResourcesCommand: Command<GenerationFailResourcesPayload> = {
id: commandIds.generationFailResources,
name: "Fail generation resources",
history: { mode: "ignore" },
execute({ state }, payload) {
if (!payload.error.trim()) return state;
return updateResources(state, { status: "failed", error: payload.error.trim() });
},
};
export const generationCommands = [
generationAddCandidateCommand,
generationSelectCandidateCommand,
generationSetCompareModeCommand,
generationRemoveCandidateCommand,
generationToggleCandidateFavoriteCommand,
generationClearCandidatesCommand,
generationReuseCandidateSettingsCommand,
generationApplyCandidateAsLayerCommand,
generationReplaceCandidatePixelsCommand,
generationStartJobCommand,
generationUpdateJobCommand,
generationSucceedJobCommand,
generationFailJobCommand,
generationCancelJobCommand,
generationLoadResourcesCommand,
generationSetResourcesCommand,
generationFailResourcesCommand,
] satisfies Command<unknown>[];
function updateJobs(state: AppState, jobs: GenerationState["jobs"]): AppState {
return { ...state, editor: { ...state.editor, generation: { ...state.editor.generation, jobs } } };
}
function updateResources(state: AppState, resources: GenerationState["resources"]): AppState {
return { ...state, editor: { ...state.editor, generation: { ...state.editor.generation, resources } } };
}
function settleJob(state: AppState, jobId: GenerationJobId, finishedAt: number, status: "succeeded" | "failed" | "cancelled", error?: string): AppState {
if (!Number.isFinite(finishedAt)) return state;
const job = state.editor.generation.jobs.find((candidate) => candidate.id === jobId);
if (!job || job.status !== "running" || finishedAt < job.startedAt) return state;
return updateJobs(state, state.editor.generation.jobs.map((candidate) => candidate.id === jobId ? { ...candidate, status, finishedAt, error } : candidate));
}
function retainCandidateBudget(candidates: GenerationCandidate[]): GenerationCandidate[] {
const retained: GenerationCandidate[] = [];
let bytes = 0;
for (const candidate of candidates) {
const candidateBytes = candidateRetainedBytes(candidate);
if (retained.length > 0 && bytes + candidateBytes > maxCandidateSourceBytes) continue;
retained.push(candidate);
bytes += candidateBytes;
if (retained.length >= maxCandidates) break;
}
return retained;
}
function candidateRetainedBytes(candidate: GenerationCandidate): number {
return [candidate.source, candidate.inputImage, candidate.maskImage, candidate.blendMaskImage, candidate.inpaint?.inputImage, candidate.inpaint?.maskImage, candidate.inpaint?.editMaskImage, candidate.inpaint?.blendMaskImage]
.reduce((total, source) => total + (source?.length ?? 0) * 2, 0);
}
type LayerLocation = {
artboardId: ArtboardId;
layer: Layer;
};
function insertLayerAtTop(document: ImageDocument, artboardId: ArtboardId, layer: Layer): ImageDocument {
return {
...document,
artboards: document.artboards.map((artboard) => artboard.id === artboardId ? { ...artboard, layers: [layer, ...artboard.layers] } : artboard),
};
}
function findLayerLocation(document: ImageDocument, layerId: LayerId): LayerLocation | undefined {
for (const artboard of document.artboards) {
const layer = findLayerInTree(artboard.layers, layerId);
if (layer) return { artboardId: artboard.id, layer };
}
return undefined;
}
function findLayerInTree(layers: readonly Layer[], layerId: LayerId): Layer | undefined {
for (const layer of layers) {
if (layer.id === layerId) return layer;
if (layer.type === "group") {
const child = findLayerInTree(layer.children, layerId);
if (child) return child;
}
}
return undefined;
}
function removeGenerationCandidate(generation: GenerationState, candidateId: GenerationCandidateId): GenerationState {
const removedIndex = generation.candidates.findIndex((candidate) => candidate.id === candidateId);
if (removedIndex < 0) return generation;
const candidates = generation.candidates.filter((candidate) => candidate.id !== candidateId);
const selectionStillExists = generation.selectedCandidateId
? candidates.some((candidate) => candidate.id === generation.selectedCandidateId)
: false;
const selectedCandidateId = selectionStillExists
? generation.selectedCandidateId
: candidates[Math.min(removedIndex, candidates.length - 1)]?.id;
return {
...generation,
candidates,
selectedCandidateId,
compareMode: candidates.length > 0 ? generation.compareMode : "result",
};
}
function generationProvenance(candidate: GenerationCandidate, acceptance: GeneratedAssetAcceptance): AssetGenerationProvenance {
return {
kind: "generated",
candidateId: candidate.id,
mode: candidate.mode,
acceptance,
prompt: candidate.settings.prompt,
negativePrompt: candidate.settings.negativePrompt,
seed: candidate.seed,
outputSize: { ...candidate.intrinsicSize },
settings: {
architecture: candidate.settings.architecture,
model: candidate.settings.model,
textEncoder: candidate.settings.textEncoder,
vae: candidate.settings.vae,
strength: candidate.settings.strength,
steps: candidate.settings.steps,
cfg: candidate.settings.cfg,
sampler: candidate.settings.sampler,
scheduler: candidate.settings.scheduler,
width: candidate.settings.width,
height: candidate.settings.height,
batchSize: candidate.settings.batchSize,
refinePass: candidate.settings.refinePass,
refineStrength: candidate.settings.refineStrength,
},
inpaint: candidate.inpaint
? {
targetLayerId: candidate.inpaint.targetLayerId,
regionId: candidate.inpaint.regionId,
sourceAssetId: candidate.inpaint.sourceAssetId,
maskAssetId: candidate.inpaint.maskAssetId,
crop: {
assetBounds: { ...candidate.inpaint.crop.assetBounds },
documentBounds: { ...candidate.inpaint.crop.documentBounds },
padding: candidate.inpaint.crop.padding,
maskedAreaOnly: candidate.inpaint.crop.maskedAreaOnly,
},
mask: {
polarity: candidate.inpaint.mask.polarity,
activeBounds: { ...candidate.inpaint.mask.activeBounds },
},
backend: {
growMaskBy: candidate.inpaint.backend.growMaskBy,
maskedContent: candidate.inpaint.backend.maskedContent,
maskBlur: candidate.inpaint.backend.maskBlur,
maskFeather: candidate.inpaint.backend.maskFeather,
maskExpand: candidate.inpaint.backend.maskExpand,
cropPadding: candidate.inpaint.backend.cropPadding,
profile: candidate.settings.inpaint.profile,
structureControl: candidate.settings.inpaint.structureControl,
controlStrength: candidate.settings.inpaint.controlStrength,
controlModel: candidate.settings.inpaint.controlModel,
colorMatch: candidate.settings.inpaint.colorMatch,
},
}
: undefined,
};
}

View File

@@ -1,12 +1,14 @@
import { describe, expect, test } from "bun:test";
import { createInitialAppState } from "@editor/initial-state";
import { generationStartJobCommand, generationSucceedJobCommand } from "./generation";
import { createAppStore } from "@editor/store";
import { documentAddArtboardCommand } from "./document";
import { historyCommands } from "./history";
import { commandIds } from "./ids";
import { createCommandRegistry } from "./registry";
import { transformCommands } from "./transform";
const registry = createCommandRegistry([documentAddArtboardCommand, ...historyCommands]);
const registry = createCommandRegistry([documentAddArtboardCommand, generationStartJobCommand, generationSucceedJobCommand, ...historyCommands, ...transformCommands]);
describe("history commands", () => {
test("records document changes and undoes/redoes them", () => {
@@ -26,4 +28,92 @@ describe("history commands", () => {
expect(store.getState().document.artboards.map((artboard) => artboard.id)).toEqual(["a1"]);
});
test("records one history entry for a transform drag", () => {
const store = createAppStore(artboardState(), registry);
store.dispatch(commandIds.transformBegin, {
target: { type: "artboard", id: "a1" },
handle: "body",
point: { x: 0, y: 0 },
initialBounds: { x: 0, y: 0, w: 100, h: 80 },
});
store.dispatch(commandIds.transformUpdate, { point: { x: 5, y: 10 } });
store.dispatch(commandIds.transformUpdate, { point: { x: 10, y: 20 } });
store.dispatch(commandIds.transformUpdate, { point: { x: 15, y: 25 } });
expect(store.getState().document.artboards[0]?.bounds).toEqual({ x: 15, y: 25, w: 100, h: 80 });
expect(store.getState().history.past).toHaveLength(0);
store.dispatch(commandIds.transformEnd, undefined);
expect(store.getState().editor.transformSession).toBeUndefined();
expect(store.getState().history.past).toHaveLength(1);
expect(store.getState().history.past[0]?.document.artboards[0]?.bounds).toEqual({ x: 0, y: 0, w: 100, h: 80 });
expect(store.getState().history.past[0]?.editor.transformSession).toBeUndefined();
store.dispatch(commandIds.historyUndo, undefined);
expect(store.getState().document.artboards[0]?.bounds).toEqual({ x: 0, y: 0, w: 100, h: 80 });
expect(store.getState().editor.transformSession).toBeUndefined();
expect(store.getState().history.future).toHaveLength(1);
});
test("keeps direct transform bounds edits normally undoable", () => {
const store = createAppStore(artboardState(), registry);
store.dispatch(commandIds.transformSetBounds, { target: { type: "artboard", id: "a1" }, bounds: { x: 12, y: 24, w: 120, h: 90 } });
expect(store.getState().document.artboards[0]?.bounds).toEqual({ x: 12, y: 24, w: 120, h: 90 });
expect(store.getState().history.past).toHaveLength(1);
store.dispatch(commandIds.historyUndo, undefined);
expect(store.getState().document.artboards[0]?.bounds).toEqual({ x: 0, y: 0, w: 100, h: 80 });
});
test("does not record history for transform update or end without a session", () => {
const store = createAppStore(artboardState(), registry);
store.dispatch(commandIds.transformUpdate, { point: { x: 10, y: 20 } });
store.dispatch(commandIds.transformEnd, undefined);
expect(store.getState().document.artboards[0]?.bounds).toEqual({ x: 0, y: 0, w: 100, h: 80 });
expect(store.getState().history.past).toHaveLength(0);
expect(store.getState().history.future).toHaveLength(0);
});
test("does not record history for a transform session with no document update", () => {
const store = createAppStore(artboardState(), registry);
store.dispatch(commandIds.transformBegin, {
target: { type: "artboard", id: "a1" },
handle: "body",
point: { x: 0, y: 0 },
initialBounds: { x: 0, y: 0, w: 100, h: 80 },
});
store.dispatch(commandIds.transformEnd, undefined);
expect(store.getState().editor.transformSession).toBeUndefined();
expect(store.getState().document.artboards[0]?.bounds).toEqual({ x: 0, y: 0, w: 100, h: 80 });
expect(store.getState().history.past).toHaveLength(0);
});
test("does not rewind generation job lifecycle during document undo", () => {
const store = createAppStore(createInitialAppState("Test"), registry);
store.dispatch(commandIds.documentAddArtboard, { id: "a1", name: "Artboard", bounds: { x: 0, y: 0, w: 100, h: 100 } });
store.dispatch(commandIds.generationStartJob, { jobId: "job-1", kind: "generate", label: "Generating", startedAt: 100 });
store.dispatch(commandIds.generationSucceedJob, { jobId: "job-1", finishedAt: 150 });
store.dispatch(commandIds.historyUndo, undefined);
expect(store.getState().editor.generation.jobs[0]?.status).toBe("succeeded");
});
});
function artboardState() {
return documentAddArtboardCommand.execute(
{ state: createInitialAppState("Test") },
{ id: "a1", name: "Artboard", bounds: { x: 0, y: 0, w: 100, h: 80 } },
);
}

View File

@@ -1,9 +1,11 @@
import type { Command } from "./command";
import type { EditorState } from "@editor/state";
import { commandIds } from "./ids";
export const historyUndoCommand: Command = {
id: commandIds.historyUndo,
name: "Undo",
history: { mode: "ignore" },
execute({ state }) {
const previous = state.history.past.at(-1);
if (!previous) return state;
@@ -11,7 +13,7 @@ export const historyUndoCommand: Command = {
return {
...state,
document: previous.document,
editor: previous.editor,
editor: preserveGenerationJobs(previous.editor, state.editor),
history: {
past: state.history.past.slice(0, -1),
future: [{ document: state.document, editor: state.editor }, ...state.history.future],
@@ -23,6 +25,7 @@ export const historyUndoCommand: Command = {
export const historyRedoCommand: Command = {
id: commandIds.historyRedo,
name: "Redo",
history: { mode: "ignore" },
execute({ state }) {
const next = state.history.future[0];
if (!next) return state;
@@ -30,7 +33,7 @@ export const historyRedoCommand: Command = {
return {
...state,
document: next.document,
editor: next.editor,
editor: preserveGenerationJobs(next.editor, state.editor),
history: {
past: [...state.history.past, { document: state.document, editor: state.editor }],
future: state.history.future.slice(1),
@@ -40,3 +43,13 @@ export const historyRedoCommand: Command = {
};
export const historyCommands = [historyUndoCommand, historyRedoCommand] satisfies Command<unknown>[];
function preserveGenerationJobs(target: EditorState, current: EditorState): EditorState {
return {
...target,
generation: {
...target.generation,
jobs: current.generation.jobs,
},
};
}

View File

@@ -1,6 +1,8 @@
export const commandIds = {
projectOpen: "project.open",
documentAddArtboard: "document.addArtboard",
documentSetArtboardBounds: "document.setArtboardBounds",
documentResizeArtboard: "document.resizeArtboard",
documentRemoveArtboard: "document.removeArtboard",
documentSetArtboardVisible: "document.setArtboardVisible",
documentSetArtboardLocked: "document.setArtboardLocked",
@@ -10,31 +12,69 @@ export const commandIds = {
documentAddImageLayer: "document.addImageLayer",
documentAddRasterLayer: "document.addRasterLayer",
documentAddGroupLayer: "document.addGroupLayer",
documentAddAdjustmentLayer: "document.addAdjustmentLayer",
documentAddTextLayer: "document.addTextLayer",
documentSetTextLayer: "document.setTextLayer",
documentSetAdjustment: "document.setAdjustment",
documentMoveLayer: "document.moveLayer",
documentGroupLayers: "document.groupLayers",
documentUngroupLayer: "document.ungroupLayer",
documentRemoveLayer: "document.removeLayer",
documentSetLayerVisible: "document.setLayerVisible",
documentSetLayerLocked: "document.setLayerLocked",
documentSetLayerOpacity: "document.setLayerOpacity",
documentSetLayerSourceRect: "document.setLayerSourceRect",
documentDuplicateLayer: "document.duplicateLayer",
documentRenameLayer: "document.renameLayer",
documentSetLayerClippingMask: "document.setLayerClippingMask",
documentAddLayerMask: "document.addLayerMask",
documentApplyLayerMaskOperation: "document.applyLayerMaskOperation",
documentRemoveLayerMask: "document.removeLayerMask",
documentAddInpaintRegion: "document.addInpaintRegion",
documentApplyInpaintRegionMaskOperation: "document.applyInpaintRegionMaskOperation",
documentRemoveInpaintRegion: "document.removeInpaintRegion",
selectionSet: "selection.set",
selectionClear: "selection.clear",
selectionAddLayer: "selection.addLayer",
toolSetActive: "tool.setActive",
toolSetGenerateSettings: "tool.setGenerateSettings",
toolChooseGenerateIntent: "tool.chooseGenerateIntent",
toolSetBrushSettings: "tool.setBrushSettings",
toolSetFeatherSettings: "tool.setFeatherSettings",
toolSetChromaKeySettings: "tool.setChromaKeySettings",
toolSetMagicWandSettings: "tool.setMagicWandSettings",
toolSetBrushPreview: "tool.setBrushPreview",
toolSetBrushStrokePreview: "tool.setBrushStrokePreview",
toolBeginMaskShape: "tool.beginMaskShape",
toolAppendMaskShape: "tool.appendMaskShape",
toolClearMaskShape: "tool.clearMaskShape",
toolSetMaskViewMode: "tool.setMaskViewMode",
toolEnterMaskEdit: "tool.enterMaskEdit",
toolEnterInpaintRegionEdit: "tool.enterInpaintRegionEdit",
toolExitMaskEdit: "tool.exitMaskEdit",
toolEnterTemporaryPan: "tool.enterTemporaryPan",
toolExitTemporaryPan: "tool.exitTemporaryPan",
generationAddCandidate: "generation.addCandidate",
generationSelectCandidate: "generation.selectCandidate",
generationSetCompareMode: "generation.setCompareMode",
generationRemoveCandidate: "generation.removeCandidate",
generationToggleCandidateFavorite: "generation.toggleCandidateFavorite",
generationClearCandidates: "generation.clearCandidates",
generationReuseCandidateSettings: "generation.reuseCandidateSettings",
generationApplyCandidateAsLayer: "generation.applyCandidateAsLayer",
generationReplaceCandidatePixels: "generation.replaceCandidatePixels",
generationStartJob: "generation.startJob",
generationUpdateJob: "generation.updateJob",
generationSucceedJob: "generation.succeedJob",
generationFailJob: "generation.failJob",
generationCancelJob: "generation.cancelJob",
generationLoadResources: "generation.loadResources",
generationSetResources: "generation.setResources",
generationFailResources: "generation.failResources",
transformBegin: "transform.begin",
transformUpdate: "transform.update",
transformSetBounds: "transform.setBounds",
transformSetRotation: "transform.setRotation",
transformEnd: "transform.end",
viewportPan: "viewport.pan",
viewportSetZoom: "viewport.setZoom",
@@ -44,4 +84,10 @@ export const commandIds = {
viewportFitArtboard: "viewport.fitArtboard",
historyUndo: "history.undo",
historyRedo: "history.redo",
commandPaletteOpen: "commandPalette.open",
commandPaletteClose: "commandPalette.close",
commandPaletteSetQuery: "commandPalette.setQuery",
commandPaletteSetSelectedIndex: "commandPalette.setSelectedIndex",
workspaceSetPanel: "workspace.setPanel",
editorSetPointerSession: "editor.setPointerSession",
} as const;

View File

@@ -1,13 +1,21 @@
export type { Command, CommandContext } from "./command";
export { projectCommands, projectOpenCommand } from "./project";
export type { ProjectOpenPayload } from "./project";
export {
documentAddArtboardCommand,
documentAddAssetCommand,
documentAddGroupLayerCommand,
documentAddAdjustmentLayerCommand,
documentSetAdjustmentCommand,
documentAddTextLayerCommand,
documentSetTextLayerCommand,
documentAddImageLayerCommand,
documentAddLayerMaskCommand,
documentAddRasterLayerCommand,
documentApplyLayerMaskOperationCommand,
documentCommands,
documentGroupLayersCommand,
documentDuplicateLayerCommand,
documentMoveLayerCommand,
documentRemoveArtboardCommand,
documentRemoveLayerCommand,
@@ -19,6 +27,7 @@ export {
documentSetArtboardVisibleCommand,
documentSetLayerClippingMaskCommand,
documentSetLayerLockedCommand,
documentSetLayerOpacityCommand,
documentSetLayerVisibleCommand,
documentUpdateAssetSourceCommand,
documentUngroupLayerCommand,
@@ -27,10 +36,16 @@ export type {
DocumentAddArtboardPayload,
DocumentAddAssetPayload,
DocumentAddGroupLayerPayload,
DocumentAddAdjustmentLayerPayload,
DocumentSetAdjustmentPayload,
DocumentAddTextLayerPayload,
DocumentSetTextLayerPayload,
DocumentAddImageLayerPayload,
DocumentAddLayerMaskPayload,
DocumentAddRasterLayerPayload,
DocumentApplyLayerMaskOperationPayload,
DocumentGroupLayersPayload,
DocumentDuplicateLayerPayload,
DocumentMoveLayerPayload,
DocumentRemoveArtboardPayload,
DocumentRemoveLayerPayload,
@@ -42,22 +57,56 @@ export type {
DocumentSetArtboardVisiblePayload,
DocumentSetLayerClippingMaskPayload,
DocumentSetLayerLockedPayload,
DocumentSetLayerOpacityPayload,
DocumentSetLayerVisiblePayload,
DocumentUpdateAssetSourcePayload,
DocumentUngroupLayerPayload,
LayerMaskOperation,
} from "./document";
export { historyCommands, historyRedoCommand, historyUndoCommand } from "./history";
export {
generationAddCandidateCommand,
generationApplyCandidateAsLayerCommand,
generationClearCandidatesCommand,
generationCommands,
generationRemoveCandidateCommand,
generationReplaceCandidatePixelsCommand,
generationSelectCandidateCommand,
generationSetCompareModeCommand,
} from "./generation";
export type {
GenerationAddCandidatePayload,
GenerationApplyCandidateAsLayerPayload,
GenerationRemoveCandidatePayload,
GenerationReplaceCandidatePixelsPayload,
GenerationSelectCandidatePayload,
GenerationSetCompareModePayload,
} from "./generation";
export type { CommandDispatcher, Dispatch } from "./dispatcher";
export type { CommandId, CommandPayloads } from "./payloads";
export { createCommandDispatcher } from "./dispatcher";
export {
commandPaletteCloseCommand,
commandPaletteCommands,
commandPaletteOpenCommand,
commandPaletteSetQueryCommand,
commandPaletteSetSelectedIndexCommand,
} from "./palette";
export type {
CommandPaletteOpenPayload,
CommandPaletteSetQueryPayload,
CommandPaletteSetSelectedIndexPayload,
} from "./palette";
export type { CommandRegistry } from "./registry";
export { createCommandRegistry } from "./registry";
export { selectionAddLayerCommand, selectionClearCommand, selectionCommands, selectionSetCommand } from "./selection";
export type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
export { toolCommands, toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetMaskViewModeCommand } from "./tool";
export { transformBeginCommand, transformCommands, transformEndCommand, transformSetBoundsCommand, transformUpdateCommand } from "./transform";
export type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform";
export type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetMaskViewModePayload } from "./tool";
export { toolChooseGenerateIntentCommand, toolCommands, toolEnterInpaintRegionEditCommand, toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetChromaKeySettingsCommand, toolSetGenerateSettingsCommand, toolSetMagicWandSettingsCommand, toolSetMaskViewModeCommand } from "./tool";
export { transformBeginCommand, transformCommands, transformEndCommand, transformSetBoundsCommand, transformSetRotationCommand, transformUpdateCommand } from "./transform";
export type { TransformBeginPayload, TransformSetBoundsPayload, TransformSetRotationPayload, TransformUpdatePayload } from "./transform";
export type { ToolChooseGenerateIntentPayload, ToolEnterInpaintRegionEditPayload, ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool";
export { inpaintRegionCommands } from "./inpaint-region";
export type { DocumentAddInpaintRegionPayload, DocumentApplyInpaintRegionMaskOperationPayload, DocumentRemoveInpaintRegionPayload } from "./inpaint-region";
export {
viewportCommands,
viewportPanCommand,

View File

@@ -0,0 +1,32 @@
import { describe, expect, test } from "bun:test";
import { createInitialAppState } from "@editor/initial-state";
import { documentAddInpaintRegionCommand, documentApplyInpaintRegionMaskOperationCommand, documentRemoveInpaintRegionCommand } from "./inpaint-region";
describe("inpaint region commands", () => {
test("persists an AI edit mask independently from layer visibility", () => {
const state = stateWithTarget();
const added = documentAddInpaintRegionCommand.execute({ state }, {
region: { id: "region", name: "Target AI edit", targetLayerId: "target", maskAssetId: "mask", enabled: true },
maskAsset: { id: "mask", name: "Mask", mimeType: "image/png", source: "black", intrinsicSize: { w: 64, h: 32 } },
});
expect(added.document.inpaintRegions).toHaveLength(1);
expect(added.document.artboards[0]?.layers[0]).not.toHaveProperty("layerMask");
expect(added.document.artboards[0]?.layers[0]?.visible).toBe(true);
const painted = documentApplyInpaintRegionMaskOperationCommand.execute({ state: added }, { regionId: "region", source: "painted", operation: { type: "paint" } });
expect(painted.document.assets.find((asset) => asset.id === "mask")?.source).toBe("painted");
const removed = documentRemoveInpaintRegionCommand.execute({ state: painted }, { regionId: "region" });
expect(removed.document.inpaintRegions).toHaveLength(0);
expect(removed.document.assets.some((asset) => asset.id === "mask")).toBe(false);
});
});
function stateWithTarget() {
const state = createInitialAppState("Test");
state.document.assets.push({ id: "source", name: "Source", mimeType: "image/png", source: "pixels", intrinsicSize: { w: 64, h: 32 } });
state.document.artboards.push({ id: "board", name: "Board", bounds: { x: 0, y: 0, w: 64, h: 32 }, backgroundColor: "transparent", visible: true, locked: false, layers: [
{ id: "target", type: "raster", name: "Target", visible: true, locked: false, opacity: 1, assetId: "source", transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 } },
] });
return state;
}

100
commands/inpaint-region.ts Normal file
View File

@@ -0,0 +1,100 @@
import type { Asset } from "@core/asset";
import type { InpaintRegion } from "@core/inpaint-region";
import type { InpaintRegionId } from "@core/id";
import type { Command } from "./command";
import { commandIds } from "./ids";
import type { LayerMaskOperation } from "./document";
import { findLayerLocation } from "./document-tree";
export type DocumentAddInpaintRegionPayload = {
region: InpaintRegion;
maskAsset: Asset;
};
export type DocumentApplyInpaintRegionMaskOperationPayload = {
regionId: InpaintRegionId;
source: string;
mimeType?: string;
operation: LayerMaskOperation;
};
export type DocumentRemoveInpaintRegionPayload = {
regionId: InpaintRegionId;
};
export const documentAddInpaintRegionCommand: Command<DocumentAddInpaintRegionPayload> = {
id: commandIds.documentAddInpaintRegion,
name: "Add inpaint region",
execute({ state }, payload) {
const target = findLayerLocation(state.document, payload.region.targetLayerId)?.layer;
if (!target || (target.type !== "image" && target.type !== "raster")) return state;
const targetAsset = state.document.assets.find((asset) => asset.id === target.assetId);
if (!targetAsset || state.document.inpaintRegions.some((region) => region.id === payload.region.id)) return state;
if (state.document.assets.some((asset) => asset.id === payload.maskAsset.id)) return state;
if (payload.region.maskAssetId !== payload.maskAsset.id || !payload.region.name.trim()) return state;
if (Math.round(payload.maskAsset.intrinsicSize.w) !== Math.round(targetAsset.intrinsicSize.w) || Math.round(payload.maskAsset.intrinsicSize.h) !== Math.round(targetAsset.intrinsicSize.h)) return state;
return {
...state,
document: {
...state.document,
assets: [...state.document.assets, payload.maskAsset],
inpaintRegions: [...state.document.inpaintRegions, { ...payload.region, name: payload.region.name.trim() }],
},
};
},
};
export const documentApplyInpaintRegionMaskOperationCommand: Command<DocumentApplyInpaintRegionMaskOperationPayload> = {
id: commandIds.documentApplyInpaintRegionMaskOperation,
name: "Apply inpaint region mask operation",
execute({ state }, payload) {
const region = state.document.inpaintRegions.find((candidate) => candidate.id === payload.regionId);
if (!region || !payload.source.trim()) return state;
return {
...state,
document: {
...state.document,
assets: state.document.assets.map((asset) => asset.id === region.maskAssetId ? { ...asset, source: payload.source, mimeType: payload.mimeType ?? asset.mimeType } : asset),
},
editor: {
...state.editor,
brushStrokePreview: state.editor.brushStrokePreview?.assetId === region.maskAssetId ? undefined : state.editor.brushStrokePreview,
},
};
},
};
export const documentRemoveInpaintRegionCommand: Command<DocumentRemoveInpaintRegionPayload> = {
id: commandIds.documentRemoveInpaintRegion,
name: "Remove inpaint region",
execute({ state }, payload) {
const region = state.document.inpaintRegions.find((candidate) => candidate.id === payload.regionId);
if (!region) return state;
const inpaintRegions = state.document.inpaintRegions.filter((candidate) => candidate.id !== payload.regionId);
const maskStillUsed = inpaintRegions.some((candidate) => candidate.maskAssetId === region.maskAssetId);
const exitingEdit = state.editor.maskEdit?.inpaintRegionId === region.id;
const contextualTool = state.editor.tools.activeTool === "semanticSelect" || state.editor.tools.activeTool === "maskLasso" || state.editor.tools.activeTool === "maskRectangle";
return {
...state,
document: {
...state.document,
inpaintRegions,
assets: maskStillUsed ? state.document.assets : state.document.assets.filter((asset) => asset.id !== region.maskAssetId),
},
editor: {
...state.editor,
maskEdit: exitingEdit ? undefined : state.editor.maskEdit,
brushStrokePreview: state.editor.brushStrokePreview?.assetId === region.maskAssetId ? undefined : state.editor.brushStrokePreview,
maskShapeSession: exitingEdit ? undefined : state.editor.maskShapeSession,
tools: exitingEdit && contextualTool ? { ...state.editor.tools, activeTool: "select", interactionMode: { type: "tool", tool: "select" } } : state.editor.tools,
},
};
},
};
export const inpaintRegionCommands = [
documentAddInpaintRegionCommand,
documentApplyInpaintRegionMaskOperationCommand,
documentRemoveInpaintRegionCommand,
] satisfies Command<unknown>[];

40
commands/palette.test.ts Normal file
View File

@@ -0,0 +1,40 @@
import { describe, expect, test } from "bun:test";
import { createInitialAppState } from "@editor/initial-state";
import {
commandPaletteCloseCommand,
commandPaletteOpenCommand,
commandPaletteSetQueryCommand,
commandPaletteSetSelectedIndexCommand,
} from "./palette";
describe("command palette commands", () => {
test("opens with a reset query and selection", () => {
const initial = commandPaletteSetQueryCommand.execute({ state: createInitialAppState("Test") }, { query: "brush" });
const next = commandPaletteOpenCommand.execute({ state: initial }, undefined);
expect(next.editor.commandPalette).toEqual({ open: true, query: "", selectedIndex: 0 });
});
test("sets query and returns selected item to the first result", () => {
const opened = commandPaletteOpenCommand.execute({ state: createInitialAppState("Test") }, { query: "layer", selectedIndex: 4 });
const next = commandPaletteSetQueryCommand.execute({ state: opened }, { query: "zoom" });
expect(next.editor.commandPalette).toEqual({ open: true, query: "zoom", selectedIndex: 0 });
});
test("clamps selected index to a non-negative integer", () => {
const opened = commandPaletteOpenCommand.execute({ state: createInitialAppState("Test") }, undefined);
const fractional = commandPaletteSetSelectedIndexCommand.execute({ state: opened }, { selectedIndex: 3.8 });
const negative = commandPaletteSetSelectedIndexCommand.execute({ state: opened }, { selectedIndex: -1 });
expect(fractional.editor.commandPalette.selectedIndex).toBe(3);
expect(negative.editor.commandPalette.selectedIndex).toBe(0);
});
test("closes and clears transient palette text", () => {
const opened = commandPaletteOpenCommand.execute({ state: createInitialAppState("Test") }, { query: "debug", selectedIndex: 2 });
const next = commandPaletteCloseCommand.execute({ state: opened }, undefined);
expect(next.editor.commandPalette).toEqual({ open: false, query: "", selectedIndex: 0 });
});
});

121
commands/palette.ts Normal file
View File

@@ -0,0 +1,121 @@
import type { Command } from "./command";
import { commandIds } from "./ids";
export type CommandPaletteOpenPayload = {
query?: string;
selectedIndex?: number;
} | undefined;
export type CommandPaletteSetQueryPayload = {
query: string;
};
export type CommandPaletteSetSelectedIndexPayload = {
selectedIndex: number;
};
export const commandPaletteOpenCommand: Command<CommandPaletteOpenPayload> = {
id: commandIds.commandPaletteOpen,
name: "Open command palette",
history: { mode: "ignore" },
execute({ state }, payload) {
const commandPalette = {
open: true,
query: payload?.query ?? "",
selectedIndex: normalizeSelectedIndex(payload?.selectedIndex ?? 0),
};
if (
state.editor.commandPalette.open === commandPalette.open &&
state.editor.commandPalette.query === commandPalette.query &&
state.editor.commandPalette.selectedIndex === commandPalette.selectedIndex
) {
return state;
}
return {
...state,
editor: {
...state.editor,
commandPalette,
},
};
},
};
export const commandPaletteCloseCommand: Command = {
id: commandIds.commandPaletteClose,
name: "Close command palette",
history: { mode: "ignore" },
execute({ state }) {
const commandPalette = { open: false, query: "", selectedIndex: 0 };
if (
state.editor.commandPalette.open === commandPalette.open &&
state.editor.commandPalette.query === commandPalette.query &&
state.editor.commandPalette.selectedIndex === commandPalette.selectedIndex
) {
return state;
}
return {
...state,
editor: {
...state.editor,
commandPalette,
},
};
},
};
export const commandPaletteSetQueryCommand: Command<CommandPaletteSetQueryPayload> = {
id: commandIds.commandPaletteSetQuery,
name: "Set command palette query",
history: { mode: "ignore" },
execute({ state }, payload) {
if (state.editor.commandPalette.query === payload.query && state.editor.commandPalette.selectedIndex === 0) return state;
return {
...state,
editor: {
...state.editor,
commandPalette: {
...state.editor.commandPalette,
query: payload.query,
selectedIndex: 0,
},
},
};
},
};
export const commandPaletteSetSelectedIndexCommand: Command<CommandPaletteSetSelectedIndexPayload> = {
id: commandIds.commandPaletteSetSelectedIndex,
name: "Set command palette selected index",
history: { mode: "ignore" },
execute({ state }, payload) {
const selectedIndex = normalizeSelectedIndex(payload.selectedIndex);
if (state.editor.commandPalette.selectedIndex === selectedIndex) return state;
return {
...state,
editor: {
...state.editor,
commandPalette: {
...state.editor.commandPalette,
selectedIndex,
},
},
};
},
};
export const commandPaletteCommands = [
commandPaletteOpenCommand,
commandPaletteCloseCommand,
commandPaletteSetQueryCommand,
commandPaletteSetSelectedIndexCommand,
] satisfies Command<unknown>[];
function normalizeSelectedIndex(value: number) {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.floor(value));
}

View File

@@ -3,10 +3,14 @@ import type {
DocumentAddArtboardPayload,
DocumentAddAssetPayload,
DocumentAddGroupLayerPayload,
DocumentAddAdjustmentLayerPayload,
DocumentSetAdjustmentPayload,
DocumentAddImageLayerPayload,
DocumentAddLayerMaskPayload,
DocumentAddRasterLayerPayload,
DocumentApplyLayerMaskOperationPayload,
DocumentGroupLayersPayload,
DocumentDuplicateLayerPayload,
DocumentMoveLayerPayload,
DocumentRemoveArtboardPayload,
DocumentRemoveLayerPayload,
@@ -14,17 +18,48 @@ import type {
DocumentRenameArtboardPayload,
DocumentRenameLayerPayload,
DocumentSetArtboardBoundsPayload,
DocumentResizeArtboardPayload,
DocumentSetArtboardLockedPayload,
DocumentSetArtboardVisiblePayload,
DocumentSetLayerClippingMaskPayload,
DocumentSetLayerLockedPayload,
DocumentSetLayerOpacityPayload,
DocumentSetLayerSourceRectPayload,
DocumentSetLayerVisiblePayload,
DocumentUpdateAssetSourcePayload,
DocumentUngroupLayerPayload,
DocumentAddTextLayerPayload,
DocumentSetTextLayerPayload,
} from "./document";
import type {
GenerationAddCandidatePayload,
GenerationApplyCandidateAsLayerPayload,
GenerationRemoveCandidatePayload,
GenerationToggleCandidateFavoritePayload,
GenerationReuseCandidateSettingsPayload,
GenerationReplaceCandidatePixelsPayload,
GenerationSelectCandidatePayload,
GenerationSetCompareModePayload,
GenerationStartJobPayload,
GenerationUpdateJobPayload,
GenerationSucceedJobPayload,
GenerationFailJobPayload,
GenerationCancelJobPayload,
GenerationSetResourcesPayload,
GenerationFailResourcesPayload,
} from "./generation";
import type {
CommandPaletteOpenPayload,
CommandPaletteSetQueryPayload,
CommandPaletteSetSelectedIndexPayload,
} from "./palette";
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
import type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetMaskViewModePayload } from "./tool";
import type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform";
import type { ToolAppendMaskShapePayload, ToolBeginMaskShapePayload, ToolChooseGenerateIntentPayload, ToolEnterInpaintRegionEditPayload, ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetFeatherSettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool";
import type { TransformBeginPayload, TransformSetBoundsPayload, TransformSetRotationPayload, TransformUpdatePayload } from "./transform";
import type { WorkspaceSetPanelPayload } from "./workspace";
import type { EditorSetPointerSessionPayload } from "./editor";
import type { ProjectOpenPayload } from "./project";
import type { DocumentAddInpaintRegionPayload, DocumentApplyInpaintRegionMaskOperationPayload, DocumentRemoveInpaintRegionPayload } from "./inpaint-region";
import type {
ViewportFitArtboardPayload,
ViewportPanPayload,
@@ -34,8 +69,10 @@ import type {
} from "./viewport";
export type CommandPayloads = {
[commandIds.projectOpen]: ProjectOpenPayload;
[commandIds.documentAddArtboard]: DocumentAddArtboardPayload;
[commandIds.documentSetArtboardBounds]: DocumentSetArtboardBoundsPayload;
[commandIds.documentResizeArtboard]: DocumentResizeArtboardPayload;
[commandIds.documentRemoveArtboard]: DocumentRemoveArtboardPayload;
[commandIds.documentSetArtboardVisible]: DocumentSetArtboardVisiblePayload;
[commandIds.documentSetArtboardLocked]: DocumentSetArtboardLockedPayload;
@@ -45,31 +82,69 @@ export type CommandPayloads = {
[commandIds.documentAddImageLayer]: DocumentAddImageLayerPayload;
[commandIds.documentAddRasterLayer]: DocumentAddRasterLayerPayload;
[commandIds.documentAddGroupLayer]: DocumentAddGroupLayerPayload;
[commandIds.documentAddAdjustmentLayer]: DocumentAddAdjustmentLayerPayload;
[commandIds.documentSetAdjustment]: DocumentSetAdjustmentPayload;
[commandIds.documentAddTextLayer]: DocumentAddTextLayerPayload;
[commandIds.documentSetTextLayer]: DocumentSetTextLayerPayload;
[commandIds.documentAddLayerMask]: DocumentAddLayerMaskPayload;
[commandIds.documentApplyLayerMaskOperation]: DocumentApplyLayerMaskOperationPayload;
[commandIds.documentRemoveLayerMask]: DocumentRemoveLayerMaskPayload;
[commandIds.documentAddInpaintRegion]: DocumentAddInpaintRegionPayload;
[commandIds.documentApplyInpaintRegionMaskOperation]: DocumentApplyInpaintRegionMaskOperationPayload;
[commandIds.documentRemoveInpaintRegion]: DocumentRemoveInpaintRegionPayload;
[commandIds.documentMoveLayer]: DocumentMoveLayerPayload;
[commandIds.documentGroupLayers]: DocumentGroupLayersPayload;
[commandIds.documentUngroupLayer]: DocumentUngroupLayerPayload;
[commandIds.documentRemoveLayer]: DocumentRemoveLayerPayload;
[commandIds.documentSetLayerVisible]: DocumentSetLayerVisiblePayload;
[commandIds.documentSetLayerLocked]: DocumentSetLayerLockedPayload;
[commandIds.documentSetLayerOpacity]: DocumentSetLayerOpacityPayload;
[commandIds.documentSetLayerSourceRect]: DocumentSetLayerSourceRectPayload;
[commandIds.documentDuplicateLayer]: DocumentDuplicateLayerPayload;
[commandIds.documentRenameLayer]: DocumentRenameLayerPayload;
[commandIds.documentSetLayerClippingMask]: DocumentSetLayerClippingMaskPayload;
[commandIds.selectionSet]: SelectionSetPayload;
[commandIds.selectionClear]: void;
[commandIds.selectionAddLayer]: SelectionAddLayerPayload;
[commandIds.toolSetActive]: ToolSetActivePayload;
[commandIds.toolSetGenerateSettings]: ToolSetGenerateSettingsPayload;
[commandIds.toolChooseGenerateIntent]: ToolChooseGenerateIntentPayload;
[commandIds.toolSetBrushSettings]: ToolSetBrushSettingsPayload;
[commandIds.toolSetFeatherSettings]: ToolSetFeatherSettingsPayload;
[commandIds.toolSetChromaKeySettings]: ToolSetChromaKeySettingsPayload;
[commandIds.toolSetMagicWandSettings]: ToolSetMagicWandSettingsPayload;
[commandIds.toolSetBrushPreview]: ToolSetBrushPreviewPayload;
[commandIds.toolSetBrushStrokePreview]: ToolSetBrushStrokePreviewPayload;
[commandIds.toolBeginMaskShape]: ToolBeginMaskShapePayload;
[commandIds.toolAppendMaskShape]: ToolAppendMaskShapePayload;
[commandIds.toolClearMaskShape]: void;
[commandIds.toolSetMaskViewMode]: ToolSetMaskViewModePayload;
[commandIds.toolEnterMaskEdit]: ToolEnterMaskEditPayload;
[commandIds.toolEnterInpaintRegionEdit]: ToolEnterInpaintRegionEditPayload;
[commandIds.toolExitMaskEdit]: void;
[commandIds.toolEnterTemporaryPan]: void;
[commandIds.toolExitTemporaryPan]: void;
[commandIds.generationAddCandidate]: GenerationAddCandidatePayload;
[commandIds.generationSelectCandidate]: GenerationSelectCandidatePayload;
[commandIds.generationSetCompareMode]: GenerationSetCompareModePayload;
[commandIds.generationRemoveCandidate]: GenerationRemoveCandidatePayload;
[commandIds.generationToggleCandidateFavorite]: GenerationToggleCandidateFavoritePayload;
[commandIds.generationClearCandidates]: void;
[commandIds.generationReuseCandidateSettings]: GenerationReuseCandidateSettingsPayload;
[commandIds.generationApplyCandidateAsLayer]: GenerationApplyCandidateAsLayerPayload;
[commandIds.generationReplaceCandidatePixels]: GenerationReplaceCandidatePixelsPayload;
[commandIds.generationStartJob]: GenerationStartJobPayload;
[commandIds.generationUpdateJob]: GenerationUpdateJobPayload;
[commandIds.generationSucceedJob]: GenerationSucceedJobPayload;
[commandIds.generationFailJob]: GenerationFailJobPayload;
[commandIds.generationCancelJob]: GenerationCancelJobPayload;
[commandIds.generationLoadResources]: void;
[commandIds.generationSetResources]: GenerationSetResourcesPayload;
[commandIds.generationFailResources]: GenerationFailResourcesPayload;
[commandIds.transformBegin]: TransformBeginPayload;
[commandIds.transformUpdate]: TransformUpdatePayload;
[commandIds.transformSetBounds]: TransformSetBoundsPayload;
[commandIds.transformSetRotation]: TransformSetRotationPayload;
[commandIds.transformEnd]: void;
[commandIds.viewportPan]: ViewportPanPayload;
[commandIds.viewportSetZoom]: ViewportSetZoomPayload;
@@ -79,6 +154,12 @@ export type CommandPayloads = {
[commandIds.viewportFitArtboard]: ViewportFitArtboardPayload | undefined;
[commandIds.historyUndo]: void;
[commandIds.historyRedo]: void;
[commandIds.commandPaletteOpen]: CommandPaletteOpenPayload;
[commandIds.commandPaletteClose]: void;
[commandIds.commandPaletteSetQuery]: CommandPaletteSetQueryPayload;
[commandIds.commandPaletteSetSelectedIndex]: CommandPaletteSetSelectedIndexPayload;
[commandIds.workspaceSetPanel]: WorkspaceSetPanelPayload;
[commandIds.editorSetPointerSession]: EditorSetPointerSessionPayload;
};
export type CommandId = keyof CommandPayloads;

31
commands/project.test.ts Normal file
View File

@@ -0,0 +1,31 @@
import { describe, expect, test } from "bun:test";
import { createInitialAppState } from "@editor/initial-state";
import { createAppStore } from "@editor/store";
import { createCommandRegistry } from "./registry";
import { documentAddArtboardCommand, documentRenameArtboardCommand } from "./document";
import { projectOpenCommand } from "./project";
import { commandIds } from "./ids";
describe("open project command", () => {
test("replaces the document and resets transient sessions and history", () => {
const app = createTestApp("Original");
const replacement = createTestApp("Opened").store.getState().document;
const originalArtboardId = app.store.getState().document.artboards[0]!.id;
app.store.dispatch(commandIds.documentRenameArtboard, { id: originalArtboardId, name: "Changed" });
expect(app.store.getState().history.past).toHaveLength(2);
app.store.dispatch(commandIds.projectOpen, { document: replacement });
const state = app.store.getState();
expect(state.document).toBe(replacement);
expect(state.editor.selection).toEqual({ artboardId: replacement.artboards[0]!.id, layerIds: [] });
expect(state.editor.generation.candidates).toEqual([]);
expect(state.history).toEqual({ past: [], future: [] });
});
});
function createTestApp(name: string) {
const store = createAppStore(createInitialAppState(name), createCommandRegistry([projectOpenCommand, documentAddArtboardCommand, documentRenameArtboardCommand]));
store.dispatch(commandIds.documentAddArtboard, { id: `${name}-artboard`, name: "Board", bounds: { x: 0, y: 0, w: 100, h: 100 } });
return { store };
}

37
commands/project.ts Normal file
View File

@@ -0,0 +1,37 @@
import type { ImageDocument } from "@core/document";
import { initialEditorState } from "@editor/initial-state";
import type { Command } from "./command";
import { commandIds } from "./ids";
export type ProjectOpenPayload = { document: ImageDocument };
export const projectOpenCommand: Command<ProjectOpenPayload> = {
id: commandIds.projectOpen,
name: "Open project",
history: { mode: "ignore" },
execute({ state }, payload) {
const firstArtboard = payload.document.artboards[0];
const viewport = state.editor.viewport;
const padding = 48;
const availableWidth = Math.max(0, viewport.size.w - padding * 2);
const availableHeight = Math.max(0, viewport.size.h - padding * 2);
const canFit = Boolean(firstArtboard && availableWidth > 0 && availableHeight > 0 && firstArtboard.bounds.w > 0 && firstArtboard.bounds.h > 0);
return {
document: payload.document,
editor: {
...initialEditorState,
viewport: {
...viewport,
center: firstArtboard
? { x: firstArtboard.bounds.x + firstArtboard.bounds.w / 2, y: firstArtboard.bounds.y + firstArtboard.bounds.h / 2 }
: viewport.center,
zoom: canFit && firstArtboard ? Math.max(0.01, Math.min(availableWidth / firstArtboard.bounds.w, availableHeight / firstArtboard.bounds.h)) : viewport.zoom,
},
selection: { artboardId: firstArtboard?.id, layerIds: [] },
},
history: { past: [], future: [] },
};
},
};
export const projectCommands = [projectOpenCommand] satisfies Command<unknown>[];

View File

@@ -0,0 +1,23 @@
import { describe, expect, test } from "bun:test";
import { createInitialAppState } from "@editor/initial-state";
import { documentAddArtboardCommand, documentAddTextLayerCommand, documentGroupLayersCommand, documentSetTextLayerCommand } from "./document";
import type { TextLayer } from "@core/text-layer";
const text: TextLayer = { id: "text", type: "text", name: "Text", visible: true, locked: false, opacity: 1, transform: { position: { x: 5, y: 6 }, scale: { x: 1, y: 1 }, rotation: 0 }, content: "Hello", style: { fontFamily: "Arial", fontSize: 24, fontWeight: 400, fontStyle: "normal", color: "#112233", alignment: "left", lineHeight: 1.2 } };
describe("text layer commands", () => {
test("creates, selects and edits authoritative text", () => {
const base = documentAddArtboardCommand.execute({ state: createInitialAppState("Test") }, { id: "board", name: "Board", bounds: { x: 0, y: 0, w: 100, h: 100 } });
const added = documentAddTextLayerCommand.execute({ state: base }, { artboardId: "board", layer: text });
expect(added.editor.selection.layerIds).toEqual(["text"]);
const edited = documentSetTextLayerCommand.execute({ state: added }, { layerId: "text", content: "Changed", style: { ...text.style, fontWeight: 700 } });
expect(edited.document.artboards[0]?.layers[0]).toMatchObject({ type: "text", content: "Changed", style: { fontWeight: 700 } });
});
test("can be grouped as a real tree node", () => {
const base = documentAddArtboardCommand.execute({ state: createInitialAppState("Test") }, { id: "board", name: "Board", bounds: { x: 0, y: 0, w: 100, h: 100 } });
const added = documentAddTextLayerCommand.execute({ state: base }, { artboardId: "board", layer: text });
const grouped = documentGroupLayersCommand.execute({ state: added }, { artboardId: "board", layerIds: ["text"], group: { id: "group", type: "group", name: "Group", visible: true, locked: false, opacity: 1, transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 }, children: [] } });
expect(grouped.document.artboards[0]?.layers[0]).toMatchObject({ type: "group", children: [{ id: "text", type: "text" }] });
});
});

View File

@@ -1,19 +1,90 @@
import { describe, expect, test } from "bun:test";
import { createInitialAppState } from "@editor/initial-state";
import { toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetMaskViewModeCommand } from "./tool";
import { initialToolState } from "@editor/tools";
import { toolAppendMaskShapeCommand, toolBeginMaskShapeCommand, toolChooseGenerateIntentCommand, toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetChromaKeySettingsCommand, toolSetFeatherSettingsCommand, toolSetGenerateSettingsCommand, toolSetMaskViewModeCommand } from "./tool";
const defaultBrush = { color: "#111827", size: 8, hardness: 100 };
const defaultBrush = { color: "#111827", size: 8, hardness: 100, opacity: 100, flow: 100, smoothing: 20, pressureSize: true };
const defaultFeather = { size: 96, radius: 16, strength: 65, smoothing: 25, pressureSize: true };
const defaultChromaKey = { color: "#00ff00", tolerance: 32, softness: 24, feather: 0, choke: 0, despeckle: 0, spill: 50 };
const defaultMagicWand = { tolerance: 32, feather: 0, choke: 0, despeckle: 0, contiguous: true, mode: "replace" as const };
const defaultGenerate = initialToolState.generate;
describe("tool commands", () => {
test("sets active tool", () => {
const next = toolSetActiveCommand.execute({ state: createInitialAppState("Test") }, { tool: "brush" });
expect(next.editor.tools).toEqual({ activeTool: "brush", interactionMode: { type: "tool", tool: "brush" }, brush: defaultBrush });
expect(next.editor.tools).toEqual({ activeTool: "brush", interactionMode: { type: "tool", tool: "brush" }, brush: defaultBrush, feather: defaultFeather, generate: defaultGenerate, chromaKey: defaultChromaKey, magicWand: defaultMagicWand });
});
test("selecting a persistent tool closes an open operation", () => {
const state = createInitialAppState("Test");
state.editor.workspace = { panel: "chromaKey" };
const next = toolSetActiveCommand.execute({ state }, { tool: "brush" });
expect(next.editor.tools.activeTool).toBe("brush");
expect(next.editor.workspace.panel).toBe("none");
});
test("sets brush settings", () => {
const next = toolSetBrushSettingsCommand.execute({ state: createInitialAppState("Test") }, { color: "#ff0000", size: 24, hardness: 50 });
expect(next.editor.tools.brush).toEqual({ color: "#ff0000", size: 24, hardness: 50 });
expect(next.editor.tools.brush).toEqual({ ...defaultBrush, color: "#ff0000", size: 24, hardness: 50 });
});
test("sets and clamps feather settings", () => {
const next = toolSetFeatherSettingsCommand.execute({ state: createInitialAppState("Test") }, { size: 800, radius: 0, strength: 45 });
expect(next.editor.tools.feather).toEqual({ ...defaultFeather, size: 400, radius: 1, strength: 45 });
});
test("sets chroma key settings", () => {
const next = toolSetChromaKeySettingsCommand.execute({ state: createInitialAppState("Test") }, { color: "#123456", tolerance: 300 });
expect(next.editor.tools.chromaKey).toEqual({ color: "#123456", tolerance: 255, softness: 24, feather: 0, choke: 0, despeckle: 0, spill: 50 });
});
test("sets inpaint generation settings", () => {
const next = toolSetGenerateSettingsCommand.execute(
{ state: createInitialAppState("Test") },
{ mode: "inpaint", inpaint: { ...defaultGenerate.inpaint, cropPadding: 5000, growMaskBy: -4, maskExpand: -500, maskBlur: 300, maskPolarity: "revealed", maskedContent: "original" } },
);
expect(next.editor.tools.generate.mode).toBe("inpaint");
expect(next.editor.tools.generate.inpaint).toEqual({ ...defaultGenerate.inpaint, cropPadding: 2048, growMaskBy: 0, maskExpand: -256, maskBlur: 256, maskPolarity: "revealed", maskedContent: "original" });
});
test("applies outcome-oriented inpaint profile defaults", () => {
const next = toolSetGenerateSettingsCommand.execute({ state: createInitialAppState("Test") }, { mode: "inpaint", inpaint: { profile: "material" } });
expect(next.editor.tools.generate).toMatchObject({ strength: 65, inpaint: { profile: "material", maskedContent: "edges", structureControl: "canny", controlStrength: 0.55, colorMatch: false } });
});
test("applies architecture defaults and filters unsupported modes", () => {
const initial = toolSetGenerateSettingsCommand.execute({ state: createInitialAppState("Test") }, { mode: "inpaint" });
const next = toolSetGenerateSettingsCommand.execute({ state: initial }, { architecture: "z-image-turbo" });
expect(next.editor.tools.generate).toMatchObject({
architecture: "z-image-turbo",
mode: "text-to-image",
model: "auto",
textEncoder: "qwen_3_4b.safetensors",
vae: "ae.safetensors",
steps: 8,
cfg: 1,
sampler: "res_multistep",
scheduler: "simple",
});
});
test("maps user intents to supported generation modes and a compatible architecture", () => {
const zImage = toolSetGenerateSettingsCommand.execute({ state: createInitialAppState("Test") }, { architecture: "z-image" });
const replace = toolChooseGenerateIntentCommand.execute({ state: zImage }, { intent: "replace" });
const variations = toolChooseGenerateIntentCommand.execute({ state: replace }, { intent: "variations" });
expect(replace.editor.tools.generate).toMatchObject({ architecture: "sdxl", mode: "inpaint" });
expect(variations.editor.tools.generate).toMatchObject({ architecture: "sdxl", mode: "image-to-image" });
});
test("maps object removal to the dedicated inpaint profile", () => {
const removed = toolChooseGenerateIntentCommand.execute({ state: createInitialAppState("Test") }, { intent: "remove" });
expect(removed.editor.tools.generate).toMatchObject({ architecture: "sdxl", mode: "inpaint", strength: 100, inpaint: { profile: "remove", maskedContent: "neutral", maskExpand: 12 } });
});
test("sets and clears brush preview", () => {
@@ -32,11 +103,25 @@ describe("tool commands", () => {
expect(cleared.editor.brushStrokePreview).toBeUndefined();
});
test("keeps rectangle mask gestures as two command-owned corner points", () => {
const state = createInitialAppState("Test");
state.editor = {
...state.editor,
tools: { ...state.editor.tools, activeTool: "maskRectangle", interactionMode: { type: "tool", tool: "maskRectangle" } },
maskEdit: { kind: "inpaintRegion", targetLayerId: "target", inpaintRegionId: "region", maskAssetId: "mask" },
};
const begun = toolBeginMaskShapeCommand.execute({ state }, { point: { x: 10, y: 20 }, mode: "add" });
const moved = toolAppendMaskShapeCommand.execute({ state: begun }, { point: { x: 40, y: 60 } });
const movedAgain = toolAppendMaskShapeCommand.execute({ state: moved }, { point: { x: 50, y: 70 } });
expect(movedAgain.editor.maskShapeSession).toEqual({ shape: "rectangle", mode: "add", points: [{ x: 10, y: 20 }, { x: 50, y: 70 }] });
});
test("enters and exits mask edit", () => {
const editing = toolEnterMaskEditCommand.execute({ state: stateWithMask() }, { targetLayerId: "target", maskLayerId: "mask" });
const exited = toolExitMaskEditCommand.execute({ state: editing }, undefined);
expect(editing.editor.maskEdit).toEqual({ targetLayerId: "target", maskLayerId: "mask" });
expect(editing.editor.maskEdit).toEqual({ kind: "layerMask", targetLayerId: "target", maskLayerId: "mask", maskAssetId: "mask-asset" });
expect(editing.editor.selection).toEqual({ artboardId: "a1", layerIds: ["target"] });
expect(editing.editor.tools.activeTool).toBe("brush");
expect(exited.editor.maskEdit).toBeUndefined();
@@ -47,7 +132,7 @@ describe("tool commands", () => {
const alpha = toolSetMaskViewModeCommand.execute({ state: editing }, { mode: "alpha" });
const ignored = toolSetMaskViewModeCommand.execute({ state: createInitialAppState("Test") }, { mode: "blackWhite" });
expect(alpha.editor.maskEdit).toEqual({ targetLayerId: "target", maskLayerId: "mask", viewMode: "alpha" });
expect(alpha.editor.maskEdit).toEqual({ kind: "layerMask", targetLayerId: "target", maskLayerId: "mask", maskAssetId: "mask-asset", viewMode: "alpha" });
expect(ignored.editor.maskEdit).toBeUndefined();
});
@@ -56,7 +141,7 @@ describe("tool commands", () => {
const panning = toolEnterTemporaryPanCommand.execute({ state: initial }, undefined);
const restored = toolExitTemporaryPanCommand.execute({ state: panning }, undefined);
expect(panning.editor.tools).toEqual({ activeTool: "select", interactionMode: { type: "temporary-pan", previousTool: "select" }, brush: defaultBrush });
expect(panning.editor.tools).toEqual({ activeTool: "select", interactionMode: { type: "temporary-pan", previousTool: "select" }, brush: defaultBrush, feather: defaultFeather, generate: defaultGenerate, chromaKey: defaultChromaKey, magicWand: defaultMagicWand });
expect(restored.editor.tools).toEqual(initial.editor.tools);
});
});

View File

@@ -1,9 +1,11 @@
import type { ImageDocument } from "@core/document";
import type { Vec2D } from "@core/geometry";
import type { LayerId, ArtboardId, AssetId } from "@core/id";
import type { LayerId, ArtboardId, AssetId, InpaintRegionId } from "@core/id";
import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
import type { MaskViewMode } from "@editor/state";
import type { BrushSettings, ToolId } from "@editor/tools";
import { generateArchitectureDefaults, inpaintProfileDefaults } from "@editor/tools";
import type { BrushSettings, ChromaKeySettings, FeatherSettings, GenerateIntent, GenerateSettings, MagicWandSettings, ToolId } from "@editor/tools";
import type { Command } from "./command";
import { commandIds } from "./ids";
@@ -12,6 +14,17 @@ export type ToolSetActivePayload = {
};
export type ToolSetBrushSettingsPayload = Partial<BrushSettings>;
export type ToolSetFeatherSettingsPayload = Partial<FeatherSettings>;
export type ToolSetGenerateSettingsPayload = Omit<Partial<GenerateSettings>, "inpaint" | "outpaint"> & {
inpaint?: Partial<GenerateSettings["inpaint"]>;
outpaint?: Partial<GenerateSettings["outpaint"]>;
};
export type ToolChooseGenerateIntentPayload = { intent: GenerateIntent };
export type ToolSetChromaKeySettingsPayload = Partial<ChromaKeySettings>;
export type ToolSetMagicWandSettingsPayload = Partial<MagicWandSettings>;
export type ToolSetBrushPreviewPayload = { position: Vec2D } | undefined;
@@ -20,18 +33,27 @@ export type ToolSetBrushStrokePreviewPayload =
layerId: LayerId;
assetId: AssetId;
source: string;
pendingTargetLayerId?: LayerId;
intrinsicSize?: { w: number; h: number };
}
| undefined;
export type ToolSetMaskViewModePayload = {
mode: MaskViewMode;
};
export type ToolBeginMaskShapePayload = { point: Vec2D; mode: "replace" | "add" | "subtract" };
export type ToolAppendMaskShapePayload = { point: Vec2D };
export type ToolEnterMaskEditPayload = {
targetLayerId: LayerId;
maskLayerId: LayerId;
};
export type ToolEnterInpaintRegionEditPayload = {
targetLayerId: LayerId;
regionId: InpaintRegionId;
};
export const toolSetActiveCommand: Command<ToolSetActivePayload> = {
id: commandIds.toolSetActive,
name: "Set active tool",
@@ -47,11 +69,102 @@ export const toolSetActiveCommand: Command<ToolSetActivePayload> = {
},
brushPreview: undefined,
brushStrokePreview: undefined,
maskShapeSession: undefined,
workspace: state.editor.workspace.panel === "generate" || state.editor.workspace.panel === "chromaKey"
? { panel: "none" }
: state.editor.workspace,
},
};
},
};
export const toolSetGenerateSettingsCommand: Command<ToolSetGenerateSettingsPayload> = {
id: commandIds.toolSetGenerateSettings,
name: "Set generate settings",
execute({ state }, payload) {
const current = state.editor.tools.generate;
const architecture = payload.architecture ?? current.architecture;
const architectureChanged = architecture !== current.architecture;
const defaults = generateArchitectureDefaults[architecture];
const requestedMode = payload.mode ?? current.mode;
const mode = defaults.supportedModes.includes(requestedMode) ? requestedMode : defaults.supportedModes[0] ?? "text-to-image";
const requestedProfile = payload.inpaint?.profile ?? current.inpaint.profile;
const profileDefaults = requestedProfile !== current.inpaint.profile && requestedProfile !== "custom" ? inpaintProfileDefaults[requestedProfile] : undefined;
return {
...state,
editor: {
...state.editor,
tools: {
...state.editor.tools,
generate: {
architecture,
mode,
model: payload.model ?? (architectureChanged ? defaults.model : current.model),
textEncoder: payload.textEncoder ?? (architectureChanged ? defaults.textEncoder : current.textEncoder),
vae: payload.vae ?? (architectureChanged ? defaults.vae : current.vae),
prompt: payload.prompt ?? current.prompt,
negativePrompt: payload.negativePrompt ?? current.negativePrompt,
strength: clampNumber(payload.strength ?? profileDefaults?.strength ?? current.strength, 0, 100),
steps: Math.round(clampNumber(payload.steps ?? (architectureChanged ? defaults.steps : current.steps), 1, 150)),
cfg: clampNumber(payload.cfg ?? (architectureChanged ? defaults.cfg : current.cfg), 0, 30),
seed: Math.round(clampNumber(payload.seed ?? current.seed, -1, Number.MAX_SAFE_INTEGER)),
sampler: payload.sampler ?? (architectureChanged ? defaults.sampler : current.sampler),
scheduler: payload.scheduler ?? (architectureChanged ? defaults.scheduler : current.scheduler),
width: Math.round(clampNumber(payload.width ?? current.width, 64, 4096)),
height: Math.round(clampNumber(payload.height ?? current.height, 64, 4096)),
batchSize: Math.round(clampNumber(payload.batchSize ?? current.batchSize, 1, 8)),
refinePass: payload.refinePass ?? profileDefaults?.refinePass ?? current.refinePass,
refineStrength: clampNumber(payload.refineStrength ?? profileDefaults?.refineStrength ?? current.refineStrength, 0, 100),
outpaint: {
left: Math.round(clampNumber(payload.outpaint?.left ?? current.outpaint.left, 0, 2048)),
top: Math.round(clampNumber(payload.outpaint?.top ?? current.outpaint.top, 0, 2048)),
right: Math.round(clampNumber(payload.outpaint?.right ?? current.outpaint.right, 0, 2048)),
bottom: Math.round(clampNumber(payload.outpaint?.bottom ?? current.outpaint.bottom, 0, 2048)),
feathering: Math.round(clampNumber(payload.outpaint?.feathering ?? current.outpaint.feathering, 0, 512)),
},
inpaint: {
profile: requestedProfile,
maskedAreaOnly: payload.inpaint?.maskedAreaOnly ?? current.inpaint.maskedAreaOnly,
cropPadding: Math.round(clampNumber(payload.inpaint?.cropPadding ?? profileDefaults?.inpaint.cropPadding ?? current.inpaint.cropPadding, 0, 2048)),
maskPolarity: payload.inpaint?.maskPolarity ?? current.inpaint.maskPolarity,
maskedContent: payload.inpaint?.maskedContent ?? profileDefaults?.inpaint.maskedContent ?? current.inpaint.maskedContent,
growMaskBy: Math.round(clampNumber(payload.inpaint?.growMaskBy ?? current.inpaint.growMaskBy, 0, 256)),
maskExpand: Math.round(clampNumber(payload.inpaint?.maskExpand ?? profileDefaults?.inpaint.maskExpand ?? current.inpaint.maskExpand, -256, 256)),
maskFeather: Math.round(clampNumber(payload.inpaint?.maskFeather ?? profileDefaults?.inpaint.maskFeather ?? current.inpaint.maskFeather, 0, 256)),
maskBlur: Math.round(clampNumber(payload.inpaint?.maskBlur ?? current.inpaint.maskBlur, 0, 256)),
maskDespeckle: Math.round(clampNumber(payload.inpaint?.maskDespeckle ?? current.inpaint.maskDespeckle, 0, 64)),
structureControl: payload.inpaint?.structureControl ?? profileDefaults?.inpaint.structureControl ?? current.inpaint.structureControl,
controlStrength: clampNumber(payload.inpaint?.controlStrength ?? profileDefaults?.inpaint.controlStrength ?? current.inpaint.controlStrength, 0, 1),
controlModel: payload.inpaint?.controlModel ?? current.inpaint.controlModel,
colorMatch: payload.inpaint?.colorMatch ?? profileDefaults?.inpaint.colorMatch ?? current.inpaint.colorMatch,
},
},
},
},
};
},
};
const modeByGenerateIntent: Record<GenerateIntent, GenerateSettings["mode"]> = {
create: "text-to-image",
replace: "inpaint",
remove: "inpaint",
extend: "outpaint",
variations: "image-to-image",
};
export const toolChooseGenerateIntentCommand: Command<ToolChooseGenerateIntentPayload> = {
id: commandIds.toolChooseGenerateIntent,
name: "Choose AI edit intent",
execute({ state }, payload) {
const mode = modeByGenerateIntent[payload.intent];
const architecture = generateArchitectureDefaults[state.editor.tools.generate.architecture].supportedModes.includes(mode)
? state.editor.tools.generate.architecture
: "sdxl";
return toolSetGenerateSettingsCommand.execute({ state }, { architecture, mode, inpaint: payload.intent === "remove" ? { profile: "remove" } : payload.intent === "replace" && state.editor.tools.generate.inpaint.profile === "remove" ? { profile: "replace" } : undefined });
},
};
export const toolSetBrushSettingsCommand: Command<ToolSetBrushSettingsPayload> = {
id: commandIds.toolSetBrushSettings,
name: "Set brush settings",
@@ -66,6 +179,82 @@ export const toolSetBrushSettingsCommand: Command<ToolSetBrushSettingsPayload> =
color: payload.color ?? state.editor.tools.brush.color,
size: clampNumber(payload.size ?? state.editor.tools.brush.size, 1, 200),
hardness: clampNumber(payload.hardness ?? state.editor.tools.brush.hardness, 0, 100),
opacity: clampNumber(payload.opacity ?? state.editor.tools.brush.opacity, 0, 100),
flow: clampNumber(payload.flow ?? state.editor.tools.brush.flow, 1, 100),
smoothing: clampNumber(payload.smoothing ?? state.editor.tools.brush.smoothing, 0, 100),
pressureSize: payload.pressureSize ?? state.editor.tools.brush.pressureSize,
},
},
},
};
},
};
export const toolSetFeatherSettingsCommand: Command<ToolSetFeatherSettingsPayload> = {
id: commandIds.toolSetFeatherSettings,
name: "Set feather settings",
execute({ state }, payload) {
return {
...state,
editor: {
...state.editor,
tools: {
...state.editor.tools,
feather: {
size: clampNumber(payload.size ?? state.editor.tools.feather.size, 1, 400),
radius: clampNumber(payload.radius ?? state.editor.tools.feather.radius, 1, 128),
strength: clampNumber(payload.strength ?? state.editor.tools.feather.strength, 1, 100),
smoothing: clampNumber(payload.smoothing ?? state.editor.tools.feather.smoothing, 0, 100),
pressureSize: payload.pressureSize ?? state.editor.tools.feather.pressureSize,
},
},
},
};
},
};
export const toolSetChromaKeySettingsCommand: Command<ToolSetChromaKeySettingsPayload> = {
id: commandIds.toolSetChromaKeySettings,
name: "Set chroma key settings",
execute({ state }, payload) {
return {
...state,
editor: {
...state.editor,
tools: {
...state.editor.tools,
chromaKey: {
color: payload.color ?? state.editor.tools.chromaKey.color,
tolerance: clampNumber(payload.tolerance ?? state.editor.tools.chromaKey.tolerance, 0, 255),
softness: clampNumber(payload.softness ?? state.editor.tools.chromaKey.softness, 0, 255),
feather: clampNumber(payload.feather ?? state.editor.tools.chromaKey.feather, 0, 20),
choke: clampNumber(payload.choke ?? state.editor.tools.chromaKey.choke, -20, 20),
despeckle: clampNumber(payload.despeckle ?? state.editor.tools.chromaKey.despeckle, 0, 20),
spill: clampNumber(payload.spill ?? state.editor.tools.chromaKey.spill, 0, 100),
},
},
},
};
},
};
export const toolSetMagicWandSettingsCommand: Command<ToolSetMagicWandSettingsPayload> = {
id: commandIds.toolSetMagicWandSettings,
name: "Set magic wand settings",
execute({ state }, payload) {
return {
...state,
editor: {
...state.editor,
tools: {
...state.editor.tools,
magicWand: {
tolerance: clampNumber(payload.tolerance ?? state.editor.tools.magicWand.tolerance, 0, 255),
feather: clampNumber(payload.feather ?? state.editor.tools.magicWand.feather, 0, 20),
choke: clampNumber(payload.choke ?? state.editor.tools.magicWand.choke, -20, 20),
despeckle: clampNumber(payload.despeckle ?? state.editor.tools.magicWand.despeckle, 0, 20),
contiguous: payload.contiguous ?? state.editor.tools.magicWand.contiguous,
mode: payload.mode ?? state.editor.tools.magicWand.mode,
},
},
},
@@ -111,7 +300,13 @@ export const toolSetBrushStrokePreviewCommand: Command<ToolSetBrushStrokePreview
...state,
editor: {
...state.editor,
brushStrokePreview: { layerId: payload.layerId, assetId: payload.assetId, source: payload.source },
brushStrokePreview: {
layerId: payload.layerId,
assetId: payload.assetId,
source: payload.source,
pendingTargetLayerId: payload.pendingTargetLayerId,
intrinsicSize: payload.intrinsicSize,
},
},
};
},
@@ -133,6 +328,43 @@ export const toolSetMaskViewModeCommand: Command<ToolSetMaskViewModePayload> = {
},
};
export const toolBeginMaskShapeCommand: Command<ToolBeginMaskShapePayload> = {
id: commandIds.toolBeginMaskShape,
name: "Begin mask lasso",
execute({ state }, payload) {
const tool = state.editor.tools.activeTool;
if ((tool !== "maskLasso" && tool !== "maskRectangle") || state.editor.maskEdit?.kind !== "inpaintRegion") return state;
return { ...state, editor: { ...state.editor, maskShapeSession: { shape: tool === "maskRectangle" ? "rectangle" : "lasso", points: [{ ...payload.point }], mode: payload.mode } } };
},
};
export const toolAppendMaskShapeCommand: Command<ToolAppendMaskShapePayload> = {
id: commandIds.toolAppendMaskShape,
name: "Append mask lasso point",
history: { mode: "ignore" },
execute({ state }, payload) {
const session = state.editor.maskShapeSession;
if (!session) return state;
if (session.shape === "rectangle") {
const start = session.points[0];
if (!start) return state;
return { ...state, editor: { ...state.editor, maskShapeSession: { ...session, points: [start, { ...payload.point }] } } };
}
const previous = session.points[session.points.length - 1];
if (previous && Math.hypot(payload.point.x - previous.x, payload.point.y - previous.y) < 1) return state;
return { ...state, editor: { ...state.editor, maskShapeSession: { ...session, points: [...session.points, { ...payload.point }] } } };
},
};
export const toolClearMaskShapeCommand: Command = {
id: commandIds.toolClearMaskShape,
name: "Clear mask lasso",
history: { mode: "ignore" },
execute({ state }) {
return state.editor.maskShapeSession ? { ...state, editor: { ...state.editor, maskShapeSession: undefined } } : state;
},
};
export const toolEnterMaskEditCommand: Command<ToolEnterMaskEditPayload> = {
id: commandIds.toolEnterMaskEdit,
name: "Enter mask edit",
@@ -140,15 +372,42 @@ export const toolEnterMaskEditCommand: Command<ToolEnterMaskEditPayload> = {
const targetLocation = findLayerLocation(state.document, payload.targetLayerId);
const maskLocation = findLayerLocation(state.document, payload.maskLayerId);
if (!targetLocation || !maskLocation) return state;
if (targetLocation.layer.clippingMask?.maskLayerId !== payload.maskLayerId) return state;
if (maskLocation.layer.type === "group") return state;
if (getLayerMask(targetLocation.layer)?.maskLayerId !== payload.maskLayerId) return state;
if (maskLocation.layer.type !== "image" && maskLocation.layer.type !== "raster") return state;
return {
...state,
editor: {
...state.editor,
selection: { artboardId: targetLocation.artboardId, layerIds: [payload.targetLayerId] },
maskEdit: { targetLayerId: payload.targetLayerId, maskLayerId: payload.maskLayerId },
maskEdit: { kind: "layerMask", targetLayerId: payload.targetLayerId, maskLayerId: payload.maskLayerId, maskAssetId: maskLocation.layer.assetId },
brushPreview: undefined,
brushStrokePreview: undefined,
tools: {
...state.editor.tools,
activeTool: "brush",
interactionMode: { type: "tool", tool: "brush" },
},
},
};
},
};
export const toolEnterInpaintRegionEditCommand: Command<ToolEnterInpaintRegionEditPayload> = {
id: commandIds.toolEnterInpaintRegionEdit,
name: "Enter inpaint region edit",
execute({ state }, payload) {
const targetLocation = findLayerLocation(state.document, payload.targetLayerId);
const region = state.document.inpaintRegions.find((candidate) => candidate.id === payload.regionId && candidate.targetLayerId === payload.targetLayerId);
const maskAsset = region ? state.document.assets.find((asset) => asset.id === region.maskAssetId) : undefined;
if (!targetLocation || !region || !maskAsset) return state;
return {
...state,
editor: {
...state.editor,
selection: { artboardId: targetLocation.artboardId, layerIds: [payload.targetLayerId] },
maskEdit: { kind: "inpaintRegion", targetLayerId: payload.targetLayerId, inpaintRegionId: region.id, maskAssetId: region.maskAssetId, viewMode: "overlay" },
brushPreview: undefined,
brushStrokePreview: undefined,
tools: {
@@ -166,6 +425,7 @@ export const toolExitMaskEditCommand: Command = {
name: "Exit mask edit",
execute({ state }) {
if (!state.editor.maskEdit) return state;
const contextualTool = state.editor.tools.activeTool === "semanticSelect" || state.editor.tools.activeTool === "maskLasso" || state.editor.tools.activeTool === "maskRectangle";
return {
...state,
@@ -174,6 +434,8 @@ export const toolExitMaskEditCommand: Command = {
maskEdit: undefined,
brushPreview: undefined,
brushStrokePreview: undefined,
maskShapeSession: undefined,
tools: contextualTool ? { ...state.editor.tools, activeTool: "select", interactionMode: { type: "tool", tool: "select" } } : state.editor.tools,
},
};
},
@@ -221,11 +483,20 @@ export const toolExitTemporaryPanCommand: Command = {
export const toolCommands = [
toolSetActiveCommand,
toolSetGenerateSettingsCommand,
toolChooseGenerateIntentCommand,
toolSetBrushSettingsCommand,
toolSetFeatherSettingsCommand,
toolSetChromaKeySettingsCommand,
toolSetMagicWandSettingsCommand,
toolSetBrushPreviewCommand,
toolSetBrushStrokePreviewCommand,
toolSetMaskViewModeCommand,
toolBeginMaskShapeCommand,
toolAppendMaskShapeCommand,
toolClearMaskShapeCommand,
toolEnterMaskEditCommand,
toolEnterInpaintRegionEditCommand,
toolExitMaskEditCommand,
toolEnterTemporaryPanCommand,
toolExitTemporaryPanCommand,

View File

@@ -0,0 +1,69 @@
import type { ImageDocument } from "@core/document";
import type { Rect } from "@core/geometry";
import type { LayerId } from "@core/id";
import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
import { resolveTransformTargetBounds } from "@editor/transform-targets";
import type { TransformTarget } from "@editor/transform";
import { measureTextLayer } from "@core/text-layer";
export function applyTransformTargetBounds(document: ImageDocument, target: TransformTarget, bounds: Rect): ImageDocument {
if (target.type === "artboard") return { ...document, artboards: document.artboards.map((artboard) => artboard.id === target.id ? { ...artboard, bounds: { ...bounds } } : artboard) };
const layer = findLayer(document, target.id);
if (!layer) return document;
return layer.type === "group" ? applyGroupBounds(document, layer.id, bounds) : layer.type === "adjustment" ? document : applyLeafBounds(document, layer.id, bounds);
}
function applyLeafBounds(document: ImageDocument, layerId: LayerId, bounds: Rect): ImageDocument {
const layer = findLayer(document, layerId);
if (!layer) return document;
const mask = getLayerMask(layer);
return [layerId, ...(mask ? [mask.maskLayerId] : [])].reduce((next, id) => ({
...next,
artboards: next.artboards.map((artboard) => ({ ...artboard, layers: mapLeafBounds(next, artboard.layers, id, bounds) })),
}), document);
}
function applyGroupBounds(document: ImageDocument, groupId: LayerId, bounds: Rect): ImageDocument {
const initial = resolveTransformTargetBounds(document, { type: "layer", id: groupId });
if (!initial || initial.w === 0 || initial.h === 0) return document;
const scale = { x: bounds.w / initial.w, y: bounds.h / initial.h };
return { ...document, artboards: document.artboards.map((artboard) => ({ ...artboard, layers: mapGroupBounds(document, artboard.layers, groupId, initial, bounds, scale) })) };
}
function mapGroupBounds(document: ImageDocument, layers: Layer[], groupId: LayerId, initial: Rect, bounds: Rect, scale: { x: number; y: number }): Layer[] {
return layers.map((layer) => {
if (layer.type === "group" && layer.id === groupId) return { ...layer, children: layer.children.map((child) => scaleSubtree(document, child, initial, bounds, scale)) };
return layer.type === "group" ? { ...layer, children: mapGroupBounds(document, layer.children, groupId, initial, bounds, scale) } : layer;
});
}
function scaleSubtree(document: ImageDocument, layer: Layer, initial: Rect, bounds: Rect, scale: { x: number; y: number }): Layer {
if (layer.type === "group") return { ...layer, children: layer.children.map((child) => scaleSubtree(document, child, initial, bounds, scale)) };
if (layer.type === "adjustment") return layer;
if (layer.type !== "text" && !document.assets.some((asset) => asset.id === layer.assetId)) return layer;
return { ...layer, transform: { ...layer.transform, position: { x: bounds.x + (layer.transform.position.x - initial.x) * scale.x, y: bounds.y + (layer.transform.position.y - initial.y) * scale.y }, scale: { x: layer.transform.scale.x * scale.x, y: layer.transform.scale.y * scale.y } } };
}
function mapLeafBounds(document: ImageDocument, layers: Layer[], layerId: LayerId, bounds: Rect): Layer[] {
return layers.map((layer) => {
if (layer.id === layerId && layer.type !== "group" && layer.type !== "adjustment") {
const asset = layer.type === "text" ? undefined : document.assets.find((candidate) => candidate.id === layer.assetId);
if (layer.type !== "text" && !asset) return layer;
const source = layer.type === "text" ? { x: 0, y: 0, ...measureTextLayer(layer) } : layer.sourceRect ?? { x: 0, y: 0, ...asset!.intrinsicSize };
const scale = { x: bounds.w / source.w, y: bounds.h / source.h };
return { ...layer, transform: { ...layer.transform, position: { x: bounds.x - source.x * scale.x, y: bounds.y - source.y * scale.y }, scale } };
}
return layer.type === "group" ? { ...layer, children: mapLeafBounds(document, layer.children, layerId, bounds) } : layer;
});
}
function findLayer(document: ImageDocument, layerId: LayerId): Layer | undefined {
const visit = (layers: readonly Layer[]): Layer | undefined => {
for (const layer of layers) {
if (layer.id === layerId) return layer;
if (layer.type === "group") { const child = visit(layer.children); if (child) return child; }
}
};
for (const artboard of document.artboards) { const layer = visit(artboard.layers); if (layer) return layer; }
}

View File

@@ -1,7 +1,8 @@
import { describe, expect, test } from "bun:test";
import { createInitialAppState } from "@editor/initial-state";
import { documentAddArtboardCommand } from "./document";
import { transformBeginCommand, transformEndCommand, transformSetBoundsCommand, transformUpdateCommand } from "./transform";
import { transformBeginCommand, transformEndCommand, transformSetBoundsCommand, transformSetRotationCommand, transformUpdateCommand } from "./transform";
import { documentAddAssetCommand, documentAddRasterLayerCommand, documentAddLayerMaskCommand, documentSetLayerLockedCommand } from "./document";
function artboardState() {
return documentAddArtboardCommand.execute(
@@ -79,6 +80,19 @@ describe("transform commands", () => {
expect(updated.document.artboards[0]?.bounds).toEqual({ x: 4, y: 19, w: 1, h: 1 });
});
test("sets leaf rotation and keeps its attached mask aligned", () => {
let state = artboardState();
state = documentAddAssetCommand.execute({ state }, { asset: { id: "asset", name: "Asset", mimeType: "image/png", source: "asset", intrinsicSize: { w: 10, h: 10 } } });
state = documentAddRasterLayerCommand.execute({ state }, { artboardId: "a1", layer: { id: "layer", type: "raster", name: "Layer", visible: true, locked: false, opacity: 1, assetId: "asset", transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 } } });
state = documentAddLayerMaskCommand.execute({ state }, { layerId: "layer", asset: { id: "mask-asset", name: "Mask", mimeType: "image/png", source: "mask", intrinsicSize: { w: 10, h: 10 } }, maskLayer: { id: "mask", type: "raster", name: "Mask", visible: true, locked: false, opacity: 1, assetId: "mask-asset", transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 } } });
const rotated = transformSetRotationCommand.execute({ state }, { target: { type: "layer", id: "layer" }, rotation: Math.PI / 2 });
expect(rotated.document.artboards[0]?.layers.map((layer) => layer.transform.rotation)).toEqual([Math.PI / 2, Math.PI / 2]);
const locked = documentSetLayerLockedCommand.execute({ state: rotated }, { layerId: "layer", locked: true });
expect(transformSetRotationCommand.execute({ state: locked }, { target: { type: "layer", id: "layer" }, rotation: 0 })).toBe(locked);
});
test("ends transform session", () => {
const started = transformBeginCommand.execute(
{ state: artboardState() },

View File

@@ -1,8 +1,10 @@
import type { Rect, Vec2D } from "@core/geometry";
import { applyTransformTargetBounds } from "@editor/transform-targets";
import type { Layer } from "@core/layer";
import { applyTransformTargetBounds } from "./transform-document";
import type { TransformHandle, TransformTarget } from "@editor/transform";
import type { Command } from "./command";
import { commandIds } from "./ids";
import type { AppState } from "@editor/state";
export type TransformBeginPayload = {
target: TransformTarget;
@@ -21,9 +23,15 @@ export type TransformSetBoundsPayload = {
bounds: Rect;
};
export type TransformSetRotationPayload = {
target: TransformTarget;
rotation: number;
};
export const transformBeginCommand: Command<TransformBeginPayload> = {
id: commandIds.transformBegin,
name: "Begin transform",
history: { mode: "deferred", phase: "begin" },
execute({ state }, payload) {
return {
...state,
@@ -43,6 +51,7 @@ export const transformBeginCommand: Command<TransformBeginPayload> = {
export const transformUpdateCommand: Command<TransformUpdatePayload> = {
id: commandIds.transformUpdate,
name: "Update transform",
history: { mode: "deferred", phase: "update" },
execute({ state }, payload) {
const session = state.editor.transformSession;
if (!session) return state;
@@ -68,6 +77,7 @@ export const transformSetBoundsCommand: Command<TransformSetBoundsPayload> = {
id: commandIds.transformSetBounds,
name: "Set transform bounds",
execute({ state }, payload) {
if (isTargetLocked(state, payload.target)) return state;
return {
...state,
document: applyTransformTargetBounds(state.document, payload.target, normalizeRect(payload.bounds)),
@@ -75,9 +85,46 @@ export const transformSetBoundsCommand: Command<TransformSetBoundsPayload> = {
},
};
export const transformSetRotationCommand: Command<TransformSetRotationPayload> = {
id: commandIds.transformSetRotation,
name: "Set transform rotation",
execute({ state }, payload) {
if (payload.target.type !== "layer" || !Number.isFinite(payload.rotation) || isTargetLocked(state, payload.target)) return state;
const location = state.document.artboards.flatMap((artboard) => findLayerInTree(artboard.layers, payload.target.id)).find(Boolean);
if (!location || location.type === "group") return state;
const maskId = "layerMask" in location ? location.layerMask?.maskLayerId : undefined;
const ids = new Set([payload.target.id, ...(maskId ? [maskId] : [])]);
return {
...state,
document: {
...state.document,
artboards: state.document.artboards.map((artboard) => ({ ...artboard, layers: mapRotation(artboard.layers, ids, payload.rotation) })),
},
};
},
};
function isTargetLocked(state: AppState, target: TransformTarget) {
if (target.type === "artboard") return state.document.artboards.find((artboard) => artboard.id === target.id)?.locked !== false;
return state.document.artboards.flatMap((artboard) => findLayerInTree(artboard.layers, target.id)).find(Boolean)?.locked !== false;
}
function findLayerInTree(layers: Layer[], id: string): Layer[] {
return layers.flatMap((layer) => layer.id === id ? [layer] : layer.type === "group" ? findLayerInTree(layer.children, id) : []);
}
function mapRotation(layers: Layer[], ids: ReadonlySet<string>, rotation: number): Layer[] {
return layers.map((layer) => ({
...layer,
...(ids.has(layer.id) ? { transform: { ...layer.transform, rotation } } : {}),
...(layer.type === "group" ? { children: mapRotation(layer.children, ids, rotation) } : {}),
})) as Layer[];
}
export const transformEndCommand: Command = {
id: commandIds.transformEnd,
name: "End transform",
history: { mode: "deferred", phase: "commit" },
execute({ state }) {
if (!state.editor.transformSession) return state;
@@ -91,7 +138,7 @@ export const transformEndCommand: Command = {
},
};
export const transformCommands = [transformBeginCommand, transformUpdateCommand, transformSetBoundsCommand, transformEndCommand] satisfies Command<unknown>[];
export const transformCommands = [transformBeginCommand, transformUpdateCommand, transformSetBoundsCommand, transformSetRotationCommand, transformEndCommand] satisfies Command<unknown>[];
function transformBounds(bounds: Rect, handle: TransformHandle, delta: Vec2D, constrained = false): Rect {
if (handle === "body") {

View File

@@ -0,0 +1,23 @@
import { describe, expect, test } from "bun:test";
import { createInitialAppState } from "@editor/initial-state";
import { workspaceSetPanelCommand } from "./workspace";
describe("workspace commands", () => {
test.each(["generate", "chromaKey"] as const)("opens the %s operation without changing the persistent tool", (panel) => {
const initial = createInitialAppState("Test");
const state = {
...initial,
editor: {
...initial.editor,
tools: { ...initial.editor.tools, activeTool: "brush" as const, interactionMode: { type: "tool" as const, tool: "brush" as const } },
brushPreview: { position: { x: 10, y: 20 } },
brushStrokePreview: { layerId: "layer", assetId: "asset", source: "preview" },
},
};
const next = workspaceSetPanelCommand.execute({ state }, { panel });
expect(next.editor.workspace.panel).toBe(panel);
expect(next.editor.tools.activeTool).toBe("brush");
expect(next.editor.brushPreview).toBeUndefined();
expect(next.editor.brushStrokePreview).toBeUndefined();
});
});

28
commands/workspace.ts Normal file
View File

@@ -0,0 +1,28 @@
import type { WorkspacePanel } from "@editor/state";
import type { Command } from "./command";
import { commandIds } from "./ids";
export type WorkspaceSetPanelPayload = { panel: WorkspacePanel };
export const workspaceSetPanelCommand: Command<WorkspaceSetPanelPayload> = {
id: commandIds.workspaceSetPanel,
name: "Set workspace panel",
history: { mode: "ignore" },
execute({ state }, payload) {
if (!workspacePanels.has(payload.panel)) return state;
if (state.editor.workspace.panel === payload.panel && !state.editor.brushPreview && !state.editor.brushStrokePreview) return state;
return {
...state,
editor: {
...state.editor,
workspace: { panel: payload.panel },
brushPreview: undefined,
brushStrokePreview: undefined,
},
};
},
};
const workspacePanels = new Set<WorkspacePanel>(["none", "generate", "chromaKey", "layers"]);
export const workspaceCommands = [workspaceSetPanelCommand] satisfies Command<unknown>[];

26
core/adjustment-layer.ts Normal file
View File

@@ -0,0 +1,26 @@
import type { BaseLayer } from "./base-layer";
export type ColorAdjustment = {
brightness: number;
contrast: number;
saturation: number;
colorBalance: { red: number; green: number; blue: number };
};
/**
* A non-destructive adjustment affecting pixels already composited beneath it
* on its artboard. Adjustment layers are top-level artboard nodes so live WebGL
* and exported PNG compositing have identical scope. Transform and masks are
* intentionally unsupported.
*/
export type AdjustmentLayer = BaseLayer & {
type: "adjustment";
adjustment: ColorAdjustment;
};
export const neutralColorAdjustment: ColorAdjustment = {
brightness: 0,
contrast: 0,
saturation: 0,
colorBalance: { red: 0, green: 0, blue: 0 },
};

64
core/asset-provenance.ts Normal file
View File

@@ -0,0 +1,64 @@
import type { Rect, Size } from "./geometry";
import type { AssetId, GenerationCandidateId, InpaintRegionId, LayerId } from "./id";
export type GeneratedAssetMode = "text-to-image" | "image-to-image" | "inpaint" | "outpaint";
export type GeneratedAssetAcceptance = "layer" | "replacement";
export type AssetGenerationProvenance = {
kind: "generated";
candidateId: GenerationCandidateId;
mode: GeneratedAssetMode;
acceptance: GeneratedAssetAcceptance;
prompt: string;
negativePrompt: string;
seed: number;
outputSize: Size;
settings: {
architecture: string;
model: string;
textEncoder: string;
vae: string;
strength: number;
steps: number;
cfg: number;
sampler: string;
scheduler: string;
width: number;
height: number;
batchSize: number;
refinePass: boolean;
refineStrength: number;
};
inpaint?: {
targetLayerId: LayerId;
regionId: InpaintRegionId;
sourceAssetId: AssetId;
maskAssetId: AssetId;
crop: {
assetBounds: Rect;
documentBounds: Rect;
padding: number;
maskedAreaOnly: boolean;
};
mask: {
polarity: "hidden" | "revealed";
activeBounds: Rect;
};
backend: {
growMaskBy: number;
maskedContent: string;
maskBlur: number;
maskFeather: number;
maskExpand: number;
cropPadding: number;
profile: string;
structureControl: string;
controlStrength: number;
controlModel: string;
colorMatch: boolean;
};
};
};
export type AssetProvenance = AssetGenerationProvenance;

View File

@@ -1,5 +1,6 @@
import type { Size } from "./geometry";
import type { AssetId } from "./id";
import type { AssetProvenance } from "./asset-provenance";
export type Asset = {
id: AssetId;
@@ -7,4 +8,5 @@ export type Asset = {
mimeType: string;
source: string;
intrinsicSize: Size;
provenance?: AssetProvenance;
};

View File

@@ -1,5 +1,6 @@
import type { Transform } from "./geometry";
import type { LayerId } from "./id";
import type { LayerMask } from "./layer-mask";
export type LayerClippingMask = {
maskLayerId: LayerId;
@@ -12,5 +13,7 @@ export type BaseLayer = {
locked: boolean;
opacity: number;
transform: Transform;
layerMask?: LayerMask;
/** @deprecated Use layerMask. Kept readable for older documents. */
clippingMask?: LayerClippingMask;
};

View File

@@ -1,6 +1,7 @@
import type { Artboard } from "./artboard";
import type { Asset } from "./asset";
import type { DocumentId } from "./id";
import type { InpaintRegion } from "./inpaint-region";
export type ImageDocument = {
id: DocumentId;
@@ -8,4 +9,5 @@ export type ImageDocument = {
version: number;
artboards: Artboard[];
assets: Asset[];
inpaintRegions: InpaintRegion[];
};

View File

@@ -1,4 +1,10 @@
export type DocumentId = string;
export type ArtboardId = string;
export type LayerId = string;
export type AssetId = string;
declare const idBrand: unique symbol;
type OpaqueId<Brand extends string> = string & { readonly [idBrand]?: Brand };
export type DocumentId = OpaqueId<"DocumentId">;
export type ArtboardId = OpaqueId<"ArtboardId">;
export type LayerId = OpaqueId<"LayerId">;
export type AssetId = OpaqueId<"AssetId">;
export type InpaintRegionId = OpaqueId<"InpaintRegionId">;
export type GenerationCandidateId = OpaqueId<"GenerationCandidateId">;
export type GenerationJobId = OpaqueId<"GenerationJobId">;

View File

@@ -1,7 +1,10 @@
import type { BaseLayer } from "./base-layer";
import type { AssetId } from "./id";
import type { Rect } from "./geometry";
export type ImageLayer = BaseLayer & {
type: "image";
assetId: AssetId;
/** Non-destructive source pixels displayed by this layer. Absence means the full asset. */
sourceRect?: Rect;
};

View File

@@ -1,5 +1,6 @@
export type { Artboard } from "./artboard";
export type { Asset } from "./asset";
export type { AssetGenerationProvenance, AssetProvenance, GeneratedAssetAcceptance, GeneratedAssetMode } from "./asset-provenance";
export type { BaseLayer, LayerClippingMask } from "./base-layer";
export type { ImageDocument } from "./document";
export type {
@@ -12,8 +13,15 @@ export type {
Transform,
Vec2D,
} from "./geometry";
export type { ArtboardId, AssetId, DocumentId, LayerId } from "./id";
export type { ArtboardId, AssetId, DocumentId, GenerationCandidateId, GenerationJobId, InpaintRegionId, LayerId } from "./id";
export type { InpaintRegion } from "./inpaint-region";
export type { ImageLayer } from "./image-layer";
export type { AdjustmentLayer, ColorAdjustment } from "./adjustment-layer";
export { neutralColorAdjustment } from "./adjustment-layer";
export type { Layer } from "./layer";
export type { LayerMask } from "./layer-mask";
export { getLayerMask, hasLayerMask } from "./layer-mask-utils";
export type { LayerGroup } from "./layer-group";
export type { RasterLayer } from "./raster-layer";
export type { TextAlignment, TextFontFamily, TextFontStyle, TextFontWeight, TextLayer, TextStyle } from "./text-layer";
export { builtInTextFonts, isValidTextStyle, measureTextLayer } from "./text-layer";

9
core/inpaint-region.ts Normal file
View File

@@ -0,0 +1,9 @@
import type { AssetId, InpaintRegionId, LayerId } from "./id";
export type InpaintRegion = {
id: InpaintRegionId;
name: string;
targetLayerId: LayerId;
maskAssetId: AssetId;
enabled: boolean;
};

19
core/layer-mask-utils.ts Normal file
View File

@@ -0,0 +1,19 @@
import type { Layer } from "./layer";
import type { LayerMask } from "./layer-mask";
export function getLayerMask(layer: Layer | undefined): LayerMask | undefined {
if (!layer) return undefined;
if (layer.layerMask) return layer.layerMask;
if (!layer.clippingMask) return undefined;
return {
kind: "raster",
maskLayerId: layer.clippingMask.maskLayerId,
enabled: true,
inverted: false,
};
}
export function hasLayerMask(layer: Layer): boolean {
return Boolean(getLayerMask(layer));
}

8
core/layer-mask.ts Normal file
View File

@@ -0,0 +1,8 @@
import type { LayerId } from "./id";
export type LayerMask = {
kind: "raster";
maskLayerId: LayerId;
enabled: boolean;
inverted: boolean;
};

View File

@@ -1,5 +1,7 @@
import type { ImageLayer } from "./image-layer";
import type { LayerGroup } from "./layer-group";
import type { RasterLayer } from "./raster-layer";
import type { AdjustmentLayer } from "./adjustment-layer";
import type { TextLayer } from "./text-layer";
export type Layer = ImageLayer | RasterLayer | LayerGroup;
export type Layer = ImageLayer | RasterLayer | LayerGroup | AdjustmentLayer | TextLayer;

View File

@@ -1,7 +1,10 @@
import type { BaseLayer } from "./base-layer";
import type { AssetId } from "./id";
import type { Rect } from "./geometry";
export type RasterLayer = BaseLayer & {
type: "raster";
assetId: AssetId;
/** Non-destructive source pixels displayed by this layer. Absence means the full asset. */
sourceRect?: Rect;
};

13
core/text-layer.test.ts Normal file
View File

@@ -0,0 +1,13 @@
import { describe, expect, test } from "bun:test";
import { isValidTextStyle, measureTextLayer, type TextLayer } from "./text-layer";
const layer: TextLayer = { id: "text-1", type: "text", name: "Title", visible: true, locked: false, opacity: 1, transform: { position: { x: 10, y: 20 }, scale: { x: 1, y: 1 }, rotation: 0 }, content: "Hello\nWorld", style: { fontFamily: "Arial", fontSize: 20, fontWeight: 400, fontStyle: "normal", color: "#ffffff", alignment: "left", lineHeight: 1.25 } };
describe("text layer metrics", () => {
test("uses deterministic multiline bounds", () => expect(measureTextLayer(layer)).toEqual({ w: 54, h: 50 }));
test("validates the closed built-in typography policy", () => {
expect(isValidTextStyle(layer.style)).toBeTrue();
expect(isValidTextStyle({ ...layer.style, fontFamily: "Remote Font" as never })).toBeFalse();
expect(isValidTextStyle({ ...layer.style, color: "red" })).toBeFalse();
});
});

44
core/text-layer.ts Normal file
View File

@@ -0,0 +1,44 @@
import type { BaseLayer } from "./base-layer";
import type { Size } from "./geometry";
export const builtInTextFonts = ["Arial", "Georgia", "Courier New", "Trebuchet MS"] as const;
export type TextFontFamily = typeof builtInTextFonts[number];
export type TextAlignment = "left" | "center" | "right";
export type TextFontStyle = "normal" | "italic";
export type TextFontWeight = 400 | 700;
export type TextStyle = {
fontFamily: TextFontFamily;
fontSize: number;
fontWeight: TextFontWeight;
fontStyle: TextFontStyle;
color: string;
alignment: TextAlignment;
lineHeight: number;
};
export type TextLayer = BaseLayer & {
type: "text";
content: string;
style: TextStyle;
};
/** Deterministic layout box shared by selection, renderer and export. */
export function measureTextLayer(layer: Pick<TextLayer, "content" | "style">): Size {
const lines = layer.content.split("\n");
const weightFactor = layer.style.fontWeight === 700 ? 1.04 : 1;
const italicFactor = layer.style.fontStyle === "italic" ? 1.03 : 1;
const familyFactor = layer.style.fontFamily === "Courier New" ? 0.62 : layer.style.fontFamily === "Georgia" ? 0.56 : 0.54;
const longest = Math.max(1, ...lines.map((line) => [...line].length));
return {
w: Math.max(1, longest * layer.style.fontSize * familyFactor * weightFactor * italicFactor),
h: Math.max(1, lines.length * layer.style.fontSize * layer.style.lineHeight),
};
}
export function isValidTextStyle(style: TextStyle): boolean {
return builtInTextFonts.includes(style.fontFamily) && Number.isFinite(style.fontSize) && style.fontSize >= 1 && style.fontSize <= 1000
&& (style.fontWeight === 400 || style.fontWeight === 700) && (style.fontStyle === "normal" || style.fontStyle === "italic")
&& /^#[0-9a-f]{6}$/i.test(style.color) && (style.alignment === "left" || style.alignment === "center" || style.alignment === "right")
&& Number.isFinite(style.lineHeight) && style.lineHeight >= 0.5 && style.lineHeight <= 5;
}

View File

@@ -0,0 +1,314 @@
# Image Studio product, UX, and architecture audit
Date: 2026-07-09
Status: Phase 1 audit complete — code, workflow, architecture, and automated verification. A screenshot-based interaction pass remains pending because the in-app browser automation bridge was unavailable during this audit.
Implementation update, 2026-07-09: the Phase 0 strict TypeScript baseline has been restored. `bun run check` now runs type-checking, all 130 tests, ESLint, and the production build as one passing validation gate.
## Executive summary
Image Studio has a substantially better technical foundation than its current interface suggests. The document model supports artboards, nested groups, image and raster layers, masks, transforms, assets, generation provenance, selection, command history, and non-destructive generation candidates. The command-only state write path and read-only renderer are especially valuable foundations for a serious editor.
The main problem is that the product is still expressed as a collection of prototype controls rather than a coherent workflow. Generation is treated simultaneously as a tool, a settings panel, a canvas preview mode, and a dense bottom action bar. Layers, masks, generation candidates, document actions, transform properties, shortcuts, and debug commands compete for attention without a stable hierarchy.
Further product work should retain the document, command, input, and renderer foundations and organize behavior around three durable concepts:
1. **Document** — artboards, editable layers, masks, groups, transforms, and provenance.
2. **Operation** — select, transform, paint, mask, remove background, inpaint, outpaint, and generate.
3. **Candidate session** — temporary model outputs that can be compared, refined, accepted, combined, or dismissed without prematurely mutating the document.
## Audit method and limits
The audit covered:
- Repository and domain-specific `AGENTS.md` guidance.
- The core document model, editor state, commands, input, renderer, application composition, and React view.
- Primary workflows: image import, selection and transform, layer and group management, masks, brush/eraser, chroma key, magic wand, generation, inpaint preparation, candidate comparison, candidate acceptance, and export.
- Current test, lint, type-check, and build results.
- Current Git status and recent architectural history.
- Runtime HTTP health on a local development server.
The audit could not include screenshots or direct browser interactions. Findings about structure, behavior, accessibility markup, responsiveness, and styling are code-confirmed; exact visual proportions, animation feel, pointer hit areas, focus rendering, and browser-specific behavior still need a live visual pass.
## Product thesis
The strongest version of Image Studio is not a smaller Photoshop with an AI dialog. It is an editor for turning uncertain model outputs into controlled, editable compositions.
A central loop should be:
> Select context → describe an operation → generate alternatives → compare → accept or combine → refine locally
That loop should work for full-image generation, image-to-image, inpainting, outpainting, background replacement, object cleanup, and future model-backed operations. The document remains authoritative; generated results remain provisional until accepted.
## What is already strong
### 1. The state architecture has a clear backbone
Persistent document state lives in `core/`, transient editor state lives in `editor/`, changes flow through deterministic commands, input resolves intent, and the renderer consumes state without owning it. This is a sound basis for undo, serialization, collaboration, macros, and future agent-driven operations.
### 2. Generated outputs are provisional candidates
`GenerationState` separates candidates from the document. Candidates retain settings, seed, source inputs, masks, placement, crop information, and inpaint preparation metadata. This is exactly the right conceptual direction for comparing uncertain model outputs before committing them.
### 3. Accepted generated assets retain provenance
Generated assets record their mode, prompt, negative prompt, seed, model configuration, acceptance path, and inpaint context. This is strategically important. The UI does not expose it yet, but the underlying data could support “reuse settings,” “make variations,” auditability, and workflow history.
### 4. Masks and nested groups are real document concepts
Groups are tree nodes rather than flat UI labels. Masks are backed by raster assets and participate in commands and rendering. This avoids the common prototype trap where layers look hierarchical but behave as a flat list.
### 5. Automated behavioral coverage is meaningful
The repository has 130 passing tests across commands, history, transforms, selection, nested layer operations, masks, input, rendering decisions, generation candidates, inpaint preparation, and ComfyUI workflow construction.
## Priority findings
Severity meanings:
- **P0** — blocks treating the project as safely maintainable.
- **P1** — breaks or obscures a primary product workflow.
- **P2** — significant friction, inconsistency, or scaling risk.
- **P3** — lower-priority product polish and cleanup.
### P0 — strict TypeScript validation fails — resolved 2026-07-09
`bunx tsc --noEmit` fails despite tests, ESLint, and the production build passing. Current failures include unsafe layer narrowing, possibly undefined mask access, generic select-menu handlers, canvas typed-array incompatibilities, a resize hook call, input test typing, and the two currently modified Comfy files.
This creates a false-green validation story: production assets can build while the architecture's type contracts are already drifting. Strict type checking should be a required validation baseline.
Resolution: the affected layer narrowing, optional mask access, generic select handling, typed-array inference, resize-hook initialization, input-test dispatch typing, and current Comfy adapter typing were corrected without weakening the domain unions. The new `typecheck` and aggregate `check` scripts make the baseline repeatable.
### P1 — there is no durable project lifecycle
The app can import images and export an artboard PNG, but there is no project save, project open, autosave, recovery, or recent-document flow. `ImageDocument` is already serialization-friendly, but imported assets use object URLs and there is no persistence adapter.
For a layer-based editor, this is not an optional feature. Without it, the product cannot fulfill “turn an AI image into an editable project” beyond a single browser session.
The architecture needs an asset persistence strategy before project import/export is presented as complete.
### P1 — generation mode is hidden while infrastructure settings are promoted
The primary user intent—text-to-image, image-to-image, inpaint, or outpaint—is inside an “Advanced” disclosure. Meanwhile backend architecture, model, seed, and sometimes text encoder and VAE are presented as essentials.
This hierarchy is backwards for the target audience. Users should choose an operation first; compatible models and defaults should follow. Model infrastructure belongs in a secondary model/settings layer unless a power user explicitly expands it.
### P1 — generation is modeled as four competing UI concepts
Generation appears as:
- A tool in the left rail.
- A duplicate sparkle action in the top-right toolbar.
- A right-side settings sheet.
- A bottom action island containing execution, status, candidate navigation, comparison, regeneration, acceptance, mask editing, and dismissal.
These surfaces do not form a legible sequence. “Generate” is closer to an application operation than a pointer mode. Treating it as a normal canvas tool also makes panel visibility and keyboard behavior artificially coupled to `activeTool`.
### P1 — accepting one candidate destroys the entire candidate session — resolved 2026-07-09
Both “accept as layer” and “replace pixels” call `clearCommittedGenerationPreview`, which clears all candidates, not only the accepted candidate. This conflicts with the core use case of combining multiple model outputs. A user who generates several alternatives and accepts one loses the remaining comparison set.
Accepting a candidate should mark or remove only that candidate by default while preserving the rest of the session.
Resolution: both layer acceptance and masked-pixel replacement now remove only the committed candidate, select the nearest remaining candidate, and preserve the active comparison mode while results remain.
### P1 — up to twelve candidates are stored but only six are selectable — resolved 2026-07-09
Generation state retains twelve candidates, while `CandidatePicker` renders only `candidates.slice(0, 6)`. Candidates seven through twelve have no visible selection path. This is a concrete interaction bug, not merely a styling concern.
Resolution: the picker now renders the complete bounded candidate set in a horizontally scrollable group.
### P1 — “variant” is only a renamed ordinary layer — resolved 2026-07-09
“Accept variant” creates a standard top-level layer and records `variant-layer` provenance. There is no document-level variant set, linked source, stack semantics, exclusive visibility, or comparison group. The label promises more structure than the product provides.
Either call this “Add as another layer” or introduce a real variant/result-set concept.
Resolution: the duplicate variant action and `variant-layer` provenance value were removed. The UI now describes the real operation as “Add as layer”; refinement layers use the same honest layer acceptance semantics.
### P1 — generation preconditions are not represented clearly
The Generate button only requires a non-empty prompt. Image-to-image can proceed without a selected source image. Inpaint configuration is available without explaining or enforcing the required source layer and mask. Outpaint and mode-specific settings coexist regardless of current mode.
The workflow should state its required context before execution and offer direct repair actions such as “Select a layer,” “Create mask,” or “Use active artboard.” Disabled buttons alone are insufficient.
### P1 — non-inpaint generated placement ignores the active context
Non-inpaint candidates default to document position `(0, 0)` at native scale. The default artboard spans negative and positive coordinates, so a 1024×1024 result can appear partly outside the active artboard and does not use the viewport center or selected artboard bounds.
Placement should be explicit: fit active artboard, use requested frame, place at viewport center, or preserve source-layer bounds depending on operation.
### P1 — asynchronous generation state is owned by a transient React control — resolved 2026-07-10
Busy state, elapsed time, and errors live inside `GenerateActionControls`. Switching away from Generate unmounts that surface while the request continues. The user loses status and error visibility, and remounting removes the local busy guard even if a request is still running.
Generation jobs are application state, not ephemeral component state. They need stable IDs, lifecycle status, cancellation where supported, error details, and persistence across panel changes.
Resolution: generation and candidate follow-up work now run through command-driven, bounded job state with stable IDs, lifecycle timestamps, and durable errors. The existing Generate controls consume that state, the top toolbar keeps activity visible across tool and panel changes, concurrent submissions are rejected authoritatively, and document undo/redo no longer rewinds job lifecycle state. Cancellation remains a future adapter capability because the current Comfy request path does not expose cancellation.
### P2 — tool semantics mix modes, operations, and effects
The rail places Select, Generate, Brush, Eraser, Chroma Key, Magic Wand, and Pan at the same level. These are not peers:
- Select and Pan are navigation/interaction modes.
- Brush and Eraser are paint modes whose meaning changes during mask editing.
- Magic Wand is a selection/mask operation.
- Chroma Key is an effect-to-mask workflow.
- Generate is an AI operation.
Tool behavior should be classified explicitly so interaction modes and application operations do not share accidental state semantics.
### P2 — panel state ownership is inconsistent — resolved 2026-07-10
Generate visibility is derived from authoritative `activeTool`, while Layers visibility is local React state. `App.tsx` then manually enforces mutual exclusion across buttons, shortcuts, effects, and command-palette callbacks.
This works today but does not scale to additional application surfaces or operation states. Meaningful application state should have one model and one transition path.
Resolution: workspace panel state and Generate/Layers mutual exclusion now live in `EditorState` and transition only through commands. React consumes the resulting snapshot without corrective panel effects or local application state.
### P2 — view code owns application workflows and side effects — resolved 2026-07-10
React/view modules directly orchestrate image decoding, object URLs, network requests, generation preparation, candidate acceptance setup, raster processing, download behavior, and Comfy model discovery. Important examples are `useImageImport.tsx`, `GenerateControls.tsx`, `GenerateActionControls.tsx`, `runGenerate.ts`, and the mask/chroma-key helpers.
These functions are testable only unevenly and blur the intended boundary that React should display state and capture intent. Explicit application services/jobs should own these workflows without weakening the command-only mutation rule.
Resolution: explicit `operations/`, `platform/`, and `server/` boundaries now separate application use cases, browser/runtime adapters, and backend integrations. View modules emit intent and retain only UI-local drafts/disclosures; operations are prevented from accessing browser globals by ESLint and continue to write state exclusively through commands.
### P2 — imported object URLs have no durable ownership — resolved 2026-07-10
Image import creates object URLs and revokes them only when no artboard exists. Successful imports keep the URL indefinitely and would not survive project serialization or browser restart.
Asset sources need a lifecycle: persisted blob/handle, data migration, load/release hooks, and garbage collection when unreferenced.
Resolution: imported files are decoded into serialization-safe data URLs before command submission, eliminating retained object URLs and making imported asset sources independent of browser-session URL lifetimes. Temporary paint-preview object URLs remain platform-owned and are explicitly released.
### P2 — primary document actions are either invisible or duplicated
Undo and redo exist only as shortcuts/commands. Export exists in both the global top bar and every artboard row. Generate exists in both the rail and top bar. Fit/reset/zoom actions are split between transient bottom controls and the command palette. The command palette also exposes debug commands in the normal product surface.
Document actions should have one canonical invocation path, with the command palette acting as an accelerator rather than a separate source of behavior.
### P2 — accessibility is inconsistent
The four icon-only top-bar buttons have no `aria-label` or visible label. Other icon buttons are labeled more carefully, but abbreviations such as “Contig,” “Sub,” “Tol,” “Hard,” and “Clean” assume specialist knowledge. Tooltips depend mainly on native `title` attributes. Focus styles and hit targets need live verification.
### P2 — large modules have become change hotspots — resolved 2026-07-10
Several files combine multiple responsibilities:
- `commands/document.ts` — 923 lines.
- `renderer/image-textures.ts` — 754 lines.
- `view/CommandPalette.tsx` — 617 lines.
- `view/LayersSheet.tsx` — 505 lines.
- `commands/tool.ts` — 378 lines.
- `view/bottom-controls/GenerateControls.tsx` — 338 lines.
- `commands/generation.ts` — 315 lines.
- `view/bottom-controls/ChromaKeyControls.tsx` — 313 lines.
Line count alone is not a defect, but these files are already coordinating distinct concepts. Modules should split by product responsibility rather than by arbitrary component size.
Resolution: document-tree mutation helpers, WebGL texture programs, command-palette item construction, layer mask controls, server routes, and browser raster adapters now have focused modules. The remaining larger files represent cohesive command or rendering orchestration rather than mixing those extracted responsibilities.
### P3 — prototype identity remains in project metadata
The package is still named `bun-react-template`, the document defaults to “Untitled” without displaying that identity, the app has no visible product title, and debug palette items ship beside user actions. These details reinforce the prototype feel.
## Architecture recommendations
### Preserve
- Core document and asset model direction.
- Command-only state mutation.
- Editor/document state separation.
- Input resolution layer.
- Read-only renderer architecture.
- Generation candidate and provenance concepts.
- Nested group and mask invariants.
### Refine
- Restore strict TypeScript cleanliness.
- Model workspace/panel state consistently.
- Introduce generation job state separate from candidates.
- Change candidate commitment to preserve unrelated candidates.
- Define placement rules per operation.
- Define project and asset persistence.
- Separate application services from React presentation.
- Add operation precondition selectors with user-facing reasons.
### Avoid
- Rewriting the renderer without a product or performance requirement.
- Encoding presentation concepts in the core document model.
- Adding more one-off local panel booleans.
- Treating every AI workflow as a distinct persistent interaction mode.
## Recommended implementation sequence
### Phase 0 — restore engineering baseline
- Make `bunx tsc --noEmit` pass.
- Add it to the standard validation script/CI.
- Preserve and finish the current Comfy changes separately.
- Add focused tests for candidate preservation and generation preconditions.
### Phase 1 — product model corrections
- Introduce durable generation job state.
- Preserve candidate sessions after acceptance.
- Make all candidates reachable.
- Rename or model variants honestly.
- Define operation-specific placement and prerequisites.
- Define project persistence and asset lifecycle.
### Phase 2 — operation contracts
- Add operation precondition selectors with actionable failure reasons.
- Define output placement for text-to-image, image-to-image, inpaint, and outpaint.
- Define the valid acceptance actions for each candidate type.
- Add focused tests for missing context and placement behavior.
### Phase 3 — project persistence
- Define a versioned project serialization format.
- Persist asset data with explicit ownership and cleanup rules.
- Implement save, open, autosave, and recovery behavior.
- Add round-trip and migration tests.
### Phase 4 — candidate-session capabilities
- Support explicit session clearing and individual candidate dismissal.
- Add pinning and multi-selection only if concrete workflows require them.
- Surface provenance and reuse-settings actions through application operations.
- Keep candidate state provisional until an explicit acceptance command runs.
### Phase 5 — workflow consolidation
- Remove duplicate invocation paths that implement the same action differently.
- Keep model compatibility and defaults in shared application logic.
- Ensure generation jobs remain observable independently of mounted React controls.
- Verify undo/redo boundaries around accepted results and asynchronous jobs.
### Phase 6 — persistence and production hardening
- Save/open/recovery.
- Asset storage and cleanup.
- Keyboard and screen-reader pass.
- Pointer, touch, and pen pass.
- Performance profiling with large images, masks, deep trees, and candidate sets.
## Verification snapshot
Run on 2026-07-09:
- `bun test`: **pass**, 130 tests.
- `bun run lint`: **pass**.
- `bun run build`: **pass**.
- `bunx tsc --noEmit`: **fail**, multiple strict type errors.
- Local HTTP server: **200 OK** on port 3017 when run outside sandbox isolation.
- Screenshot/browser interaction pass: **not run**, browser automation bridge unavailable.
## Immediate next deliverable
Define and test operation preconditions and output-placement rules for text-to-image, image-to-image, inpaint, and outpaint. Each operation should report missing context explicitly and produce candidates with deterministic document placement.

View File

@@ -0,0 +1,125 @@
import { describe, expect, test } from "bun:test";
import type { ImageDocument } from "@core/document";
import type { Layer } from "@core/layer";
import { createDocumentReadIndex, forEachLayerBackToFront, resolveIndexedLayerBounds } from "./document-indexes";
const document: ImageDocument = {
inpaintRegions: [],
id: "d1",
name: "Indexed Document",
version: 1,
assets: [
{ id: "asset-target", name: "Target", mimeType: "image/png", source: "asset://target", intrinsicSize: { w: 100, h: 50 } },
{ id: "asset-mask", name: "Mask", mimeType: "image/png", source: "asset://mask", intrinsicSize: { w: 100, h: 50 } },
{ id: "asset-nested", name: "Nested", mimeType: "image/png", source: "asset://nested", intrinsicSize: { w: 20, h: 10 } },
],
artboards: [
{
id: "a1",
name: "Artboard",
bounds: { x: 0, y: 0, w: 400, h: 300 },
backgroundColor: "transparent",
visible: true,
locked: false,
layers: [
raster("mask", "Mask", "asset-mask"),
{
...raster("target", "Target", "asset-target", { x: 10, y: 20 }, { x: 0.5, y: 0.5 }),
layerMask: { kind: "raster", maskLayerId: "mask", enabled: true, inverted: false },
},
group("group", "Group", [
raster("nested-mask", "Nested Mask", "asset-mask"),
{ ...raster("nested-target", "Nested Target", "asset-nested", { x: 80, y: 10 }, { x: 2, y: 3 }), clippingMask: { maskLayerId: "nested-mask" } },
]),
],
},
],
};
describe("document read indexes", () => {
test("indexes assets, layers, layer info, masks, and display counts", () => {
const index = createDocumentReadIndex(document);
expect(index.assetById.get("asset-target")).toBe(document.assets[0]);
expect(index.layerById.get("nested-target")?.name).toBe("Nested Target");
expect(index.layerInfoById.get("target")).toMatchObject({ artboardId: "a1", index: 1 });
expect(index.layerInfoById.get("nested-target")).toMatchObject({ artboardId: "a1", parentGroupId: "group", index: 1 });
expect(index.maskLayerIds).toEqual(new Set(["mask", "nested-mask"]));
expect(index.maskLayerIdsByArtboardId.get("a1")).toEqual(new Set(["mask", "nested-mask"]));
expect(index.maskLayerIdsByLayerList.get(document.artboards[0]!.layers)).toEqual(new Set(["mask", "nested-mask"]));
expect(index.maskLayerIdsByLayerList.get(groupLayer(document, "group").children)).toEqual(new Set(["nested-mask"]));
expect(index.displayLayerCountByArtboardId.get("a1")).toBe(3);
});
test("resolves layer bounds from indexed assets without scanning the document", () => {
const index = createDocumentReadIndex(document);
expect(resolveIndexedLayerBounds(index, "target")).toEqual({ x: 10, y: 20, w: 50, h: 25 });
expect(resolveIndexedLayerBounds(index, "group")).toEqual({ x: 0, y: 0, w: 120, h: 50 });
expect(resolveIndexedLayerBounds(index, raster("missing", "Missing", "missing-asset"))).toBeUndefined();
});
test("resolves a cropped layer from its retained source-pixel position", () => {
const cropped = { ...document, artboards: [{ ...document.artboards[0]!, layers: [{ ...raster("cropped", "Cropped", "asset-target", { x: 10, y: 20 }, { x: 2, y: 3 }), sourceRect: { x: 5, y: 4, w: 20, h: 10 } }] }] };
expect(resolveIndexedLayerBounds(createDocumentReadIndex(cropped), "cropped")).toEqual({ x: 20, y: 32, w: 40, h: 30 });
});
test("visits layers back to front without mutating source order", () => {
const layers = document.artboards[0]!.layers;
const visited: string[] = [];
forEachLayerBackToFront(layers, (layer) => visited.push(layer.id));
expect(visited).toEqual(["group", "target", "mask"]);
expect(layers.map((layer) => layer.id)).toEqual(["mask", "target", "group"]);
});
test("indexes pathological nesting without recursive stack overflow", () => {
let layers: Layer[] = [raster("leaf", "Leaf", "asset-nested", { x: 80, y: 10 }, { x: 2, y: 3 })];
for (let depth = 0; depth < 10_000; depth += 1) layers = [group(`group-${depth}`, `Group ${depth}`, layers)];
const deepDocument = { ...document, artboards: [{ ...document.artboards[0]!, layers }] };
const index = createDocumentReadIndex(deepDocument);
expect(index.layerById.size).toBe(10_001);
expect(resolveIndexedLayerBounds(index, layers[0]!)).toEqual({ x: 80, y: 10, w: 40, h: 30 });
});
});
function raster(
id: string,
name: string,
assetId: string,
position = { x: 0, y: 0 },
scale = { x: 1, y: 1 },
): Extract<Layer, { type: "raster" }> {
return {
id,
type: "raster",
name,
visible: true,
locked: false,
opacity: 1,
assetId,
transform: { position, scale, rotation: 0 },
};
}
function group(id: string, name: string, children: Layer[]): Extract<Layer, { type: "group" }> {
return {
id,
type: "group",
name,
visible: true,
locked: false,
opacity: 1,
transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 },
children,
};
}
function groupLayer(document: ImageDocument, id: string): Extract<Layer, { type: "group" }> {
const layer = document.artboards[0]!.layers.find((candidate) => candidate.id === id);
if (!layer || layer.type !== "group") throw new Error(`Missing group ${id}`);
return layer;
}

178
editor/document-indexes.ts Normal file
View File

@@ -0,0 +1,178 @@
import type { Asset } from "@core/asset";
import type { ImageDocument } from "@core/document";
import type { Rect } from "@core/geometry";
import type { ArtboardId, AssetId, LayerId } from "@core/id";
import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
import { measureTextLayer } from "@core/text-layer";
export type IndexedLayerInfo = {
artboardId: ArtboardId;
parentGroupId?: LayerId;
layer: Layer;
siblings: readonly Layer[];
index: number;
};
export type DocumentReadIndex = {
assetById: ReadonlyMap<AssetId, Asset>;
layerById: ReadonlyMap<LayerId, Layer>;
layerInfoById: ReadonlyMap<LayerId, IndexedLayerInfo>;
maskLayerIds: ReadonlySet<LayerId>;
maskLayerIdsByArtboardId: ReadonlyMap<ArtboardId, ReadonlySet<LayerId>>;
maskLayerIdsByLayerList: ReadonlyMap<readonly Layer[], ReadonlySet<LayerId>>;
displayLayerCountByArtboardId: ReadonlyMap<ArtboardId, number>;
};
export function createDocumentReadIndex(document: ImageDocument): DocumentReadIndex {
const assetById = new Map<AssetId, Asset>();
const layerById = new Map<LayerId, Layer>();
const layerInfoById = new Map<LayerId, IndexedLayerInfo>();
const maskLayerIds = new Set<LayerId>();
const maskLayerIdsByArtboardId = new Map<ArtboardId, ReadonlySet<LayerId>>();
const maskLayerIdsByLayerList = new Map<readonly Layer[], ReadonlySet<LayerId>>();
const displayLayerCountByArtboardId = new Map<ArtboardId, number>();
for (const asset of document.assets) assetById.set(asset.id, asset);
for (const artboard of document.artboards) {
const artboardMaskLayerIds = indexLayerTree({
layers: artboard.layers,
artboardId: artboard.id,
layerById,
layerInfoById,
documentMaskLayerIds: maskLayerIds,
maskLayerIdsByLayerList,
});
maskLayerIdsByArtboardId.set(artboard.id, artboardMaskLayerIds);
}
for (const artboard of document.artboards) {
displayLayerCountByArtboardId.set(artboard.id, countDisplayLayers(artboard.layers, maskLayerIds));
}
return {
assetById,
layerById,
layerInfoById,
maskLayerIds,
maskLayerIdsByArtboardId,
maskLayerIdsByLayerList,
displayLayerCountByArtboardId,
};
}
export function forEachLayerBackToFront(layers: readonly Layer[], visit: (layer: Layer) => void) {
for (let index = layers.length - 1; index >= 0; index -= 1) {
const layer = layers[index];
if (layer) visit(layer);
}
}
export function resolveIndexedLayerBounds(index: DocumentReadIndex, layerOrId: Layer | LayerId): Rect | undefined {
const layer = typeof layerOrId === "string" ? index.layerById.get(layerOrId) : layerOrId;
if (!layer) return undefined;
switch (layer.type) {
case "group":
return unionLayerBounds(index, layer.children);
case "adjustment":
return undefined;
case "text": {
const size = measureTextLayer(layer);
return { x: layer.transform.position.x, y: layer.transform.position.y, w: size.w * layer.transform.scale.x, h: size.h * layer.transform.scale.y };
}
case "image":
case "raster": {
const asset = index.assetById.get(layer.assetId);
if (!asset) return undefined;
const source = layer.sourceRect ?? { x: 0, y: 0, ...asset.intrinsicSize };
return {
x: layer.transform.position.x + source.x * layer.transform.scale.x,
y: layer.transform.position.y + source.y * layer.transform.scale.y,
w: source.w * layer.transform.scale.x,
h: source.h * layer.transform.scale.y,
};
}
}
}
function indexLayerTree(options: {
layers: readonly Layer[];
artboardId: ArtboardId;
parentGroupId?: LayerId;
layerById: Map<LayerId, Layer>;
layerInfoById: Map<LayerId, IndexedLayerInfo>;
documentMaskLayerIds: Set<LayerId>;
maskLayerIdsByLayerList: Map<readonly Layer[], ReadonlySet<LayerId>>;
}): Set<LayerId> {
type Frame = { layers: readonly Layer[]; parentGroupId?: LayerId; visited: boolean };
const stack: Frame[] = [{ layers: options.layers, parentGroupId: options.parentGroupId, visited: false }];
while (stack.length > 0) {
const frame = stack.pop();
if (!frame) continue;
if (frame.visited) {
const ids = new Set<LayerId>();
for (const layer of frame.layers) {
const mask = getLayerMask(layer);
if (mask) ids.add(mask.maskLayerId);
if (layer.type === "group") for (const id of options.maskLayerIdsByLayerList.get(layer.children) ?? []) ids.add(id);
}
options.maskLayerIdsByLayerList.set(frame.layers, ids);
continue;
}
stack.push({ ...frame, visited: true });
for (let index = frame.layers.length - 1; index >= 0; index -= 1) {
const layer = frame.layers[index];
if (!layer) continue;
options.layerById.set(layer.id, layer);
options.layerInfoById.set(layer.id, { artboardId: options.artboardId, parentGroupId: frame.parentGroupId, layer, siblings: frame.layers, index });
const mask = getLayerMask(layer);
if (mask) options.documentMaskLayerIds.add(mask.maskLayerId);
if (layer.type === "group") stack.push({ layers: layer.children, parentGroupId: layer.id, visited: false });
}
}
return new Set(options.maskLayerIdsByLayerList.get(options.layers) ?? []);
}
function countDisplayLayers(layers: readonly Layer[], maskLayerIds: ReadonlySet<LayerId>): number {
let count = 0;
const stack = [...layers];
while (stack.length > 0) {
const layer = stack.pop();
if (!layer) continue;
if (maskLayerIds.has(layer.id)) continue;
count += 1;
if (layer.type === "group") stack.push(...layer.children);
}
return count;
}
function unionLayerBounds(index: DocumentReadIndex, layers: readonly Layer[]): Rect | undefined {
let bounds: Rect | undefined;
const stack = [...layers];
while (stack.length > 0) {
const layer = stack.pop();
if (!layer) continue;
if (layer.type === "group") {
stack.push(...layer.children);
continue;
}
if (layer.type === "adjustment") continue;
const layerBounds = resolveIndexedLayerBounds(index, layer);
if (!layerBounds) continue;
bounds = bounds ? unionRects(bounds, layerBounds) : layerBounds;
}
return bounds;
}
function unionRects(a: Rect, b: Rect): Rect {
const minX = Math.min(a.x, b.x);
const minY = Math.min(a.y, b.y);
const maxX = Math.max(a.x + a.w, b.x + b.w);
const maxY = Math.max(a.y + a.h, b.y + b.h);
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
}

View File

@@ -1,6 +1,8 @@
export type { AppState, EditorState, SelectionState, ViewportState } from "./state";
export type { InteractionMode, ToolId, ToolState } from "./tools";
export type { BrushSettings, ChromaKeySettings, FeatherSettings, MagicWandSettings, InteractionMode, OperationId, ToolId, ToolState } from "./tools";
export { initialToolState } from "./tools";
export { createInitialAppState, initialEditorState } from "./initial-state";
export type { AppStore, StateListener } from "./store";
export { createAppStore } from "./store";
export type { DocumentReadIndex, IndexedLayerInfo } from "./document-indexes";
export { createDocumentReadIndex, forEachLayerBackToFront, resolveIndexedLayerBounds } from "./document-indexes";

View File

@@ -12,10 +12,27 @@ export const initialEditorState: EditorState = {
layerIds: [],
},
tools: initialToolState,
generation: {
candidates: [],
selectedCandidateId: undefined,
compareMode: "result",
jobs: [],
resources: { status: "idle" },
},
commandPalette: {
open: false,
query: "",
selectedIndex: 0,
},
workspace: {
panel: "none",
},
transformSession: undefined,
maskEdit: undefined,
brushPreview: undefined,
brushStrokePreview: undefined,
maskShapeSession: undefined,
pointerSession: undefined,
};
export function createInitialAppState(name = "Untitled"): AppState {
@@ -26,6 +43,7 @@ export function createInitialAppState(name = "Untitled"): AppState {
version: 1,
artboards: [],
assets: [],
inpaintRegions: [],
},
editor: initialEditorState,
history: { past: [], future: [] },

View File

@@ -1,7 +1,7 @@
import type { ImageDocument } from "@core/document";
import type { Angle, Size, Vec2D } from "@core/geometry";
import type { ArtboardId, AssetId, LayerId } from "@core/id";
import type { ToolState } from "./tools";
import type { Angle, Rect, Size, Transform, Vec2D } from "@core/geometry";
import type { ArtboardId, AssetId, GenerationCandidateId, GenerationJobId, InpaintRegionId, LayerId } from "@core/id";
import type { GenerateArchitecture, GenerateMode, GenerateSettings, ToolState } from "./tools";
import type { TransformSession } from "./transform";
export type ViewportState = {
@@ -19,8 +19,11 @@ export type SelectionState = {
export type MaskViewMode = "composite" | "blackWhite" | "alpha" | "overlay";
export type MaskEditState = {
kind: "layerMask" | "inpaintRegion";
targetLayerId: LayerId;
maskLayerId: LayerId;
maskAssetId: AssetId;
maskLayerId?: LayerId;
inpaintRegionId?: InpaintRegionId;
viewMode?: MaskViewMode;
};
@@ -32,16 +35,144 @@ export type BrushStrokePreviewState = {
layerId: LayerId;
assetId: AssetId;
source: string;
pendingTargetLayerId?: LayerId;
intrinsicSize?: Size;
};
export type MaskShapeSession = {
shape: "lasso" | "rectangle";
points: Vec2D[];
mode: "replace" | "add" | "subtract";
};
export type GenerationCandidate = {
id: GenerationCandidateId;
source: string;
mimeType: string;
intrinsicSize: Size;
mode: GenerateSettings["mode"];
settings: GenerateSettings;
seed: number;
favorite?: boolean;
width: number;
height: number;
inputImage?: string;
maskImage?: string;
blendMaskImage?: string;
placement: {
artboardId: ArtboardId;
layerName: string;
transform: Transform;
};
inpaint?: {
targetLayerId: LayerId;
regionId: InpaintRegionId;
sourceAssetId: AssetId;
maskAssetId: AssetId;
inputImage: string;
maskImage: string;
editMaskImage: string;
blendMaskImage: string;
revision: { source: string; mask: string };
crop: {
assetBounds: Rect;
documentBounds: Rect;
padding: number;
maskedAreaOnly: boolean;
};
mask: {
polarity: GenerateSettings["inpaint"]["maskPolarity"];
activeBounds: Rect;
};
backend: {
growMaskBy: number;
maskedContent: GenerateSettings["inpaint"]["maskedContent"];
maskBlur: number;
maskFeather: number;
maskExpand: number;
cropPadding: number;
};
};
};
export type GenerationCompareMode = "result" | "before" | "split";
export type GenerationJobKind = "generate" | "regenerate" | "refine" | "replace" | "mask";
export type GenerationJobStatus = "running" | "succeeded" | "failed" | "cancelled";
export type GenerationJob = {
id: GenerationJobId;
kind: GenerationJobKind;
label: string;
status: GenerationJobStatus;
startedAt: number;
finishedAt?: number;
error?: string;
progress?: number;
detail?: string;
};
export type GenerationState = {
candidates: GenerationCandidate[];
selectedCandidateId?: string;
compareMode: GenerationCompareMode;
jobs: GenerationJob[];
resources: GenerationResourcesState;
};
export type GenerationArchitectureOption = {
value: GenerateArchitecture;
label: string;
defaultModel: string;
models: string[];
supportedModes: GenerateMode[];
};
export type GenerationOptions = {
architectures?: GenerationArchitectureOption[];
models?: string[];
inpaintModels?: string[];
textEncoders?: string[];
vaes?: string[];
samplers?: string[];
schedulers?: string[];
controlModels?: string[];
structureControls?: Array<"canny" | "depth" | "pose">;
semanticSelection?: boolean;
sam3Models?: string[];
};
export type GenerationResourcesState = { status: "idle" | "loading" | "ready" | "failed"; options?: GenerationOptions; error?: string };
export type CommandPaletteState = {
open: boolean;
query: string;
selectedIndex: number;
};
export type WorkspacePanel = "none" | "generate" | "chromaKey" | "layers";
export function isOperationWorkspacePanel(panel: WorkspacePanel): boolean {
return panel === "generate" || panel === "chromaKey";
}
export type WorkspaceState = {
panel: WorkspacePanel;
};
export type EditorState = {
viewport: ViewportState;
selection: SelectionState;
tools: ToolState;
generation: GenerationState;
commandPalette: CommandPaletteState;
workspace: WorkspaceState;
transformSession?: TransformSession;
maskEdit?: MaskEditState;
brushPreview?: BrushPreviewState;
brushStrokePreview?: BrushStrokePreviewState;
maskShapeSession?: MaskShapeSession;
pointerSession?: { type: "pan" };
};
export type HistorySnapshot = {

View File

@@ -1,6 +1,9 @@
export const availableToolIds = ["select", "brush", "eraser", "pan"] as const;
export const availableToolIds = ["select", "brush", "eraser", "feather", "magicWand", "semanticSelect", "maskLasso", "maskRectangle", "pan"] as const;
export const availableOperationIds = ["generate", "chromaKey"] as const;
export type ToolId = (typeof availableToolIds)[number];
export type OperationId = (typeof availableOperationIds)[number];
export type InteractionMode =
| { type: "tool"; tool: ToolId }
@@ -10,18 +13,159 @@ export type BrushSettings = {
color: string;
size: number;
hardness: number;
opacity: number;
flow: number;
smoothing: number;
pressureSize: boolean;
};
export type FeatherSettings = {
size: number;
radius: number;
strength: number;
smoothing: number;
pressureSize: boolean;
};
export type ChromaKeySettings = {
color: string;
tolerance: number;
softness: number;
feather: number;
choke: number;
despeckle: number;
spill: number;
};
export type MagicWandMode = "replace" | "add" | "subtract";
export type GenerateMode = "text-to-image" | "image-to-image" | "inpaint" | "outpaint";
export type GenerateIntent = "create" | "replace" | "remove" | "extend" | "variations";
export const generateArchitectures = ["sdxl", "z-image", "z-image-turbo", "anima"] as const;
export type GenerateArchitecture = (typeof generateArchitectures)[number];
export type GenerateModel = string;
export type InpaintMaskedContent = "neutral" | "original" | "originalColor" | "edges";
export type InpaintProfile = "remove" | "replace" | "repair" | "material" | "reshape" | "custom";
export type InpaintStructureControl = "none" | "canny" | "depth" | "pose";
export type GenerateSettings = {
architecture: GenerateArchitecture;
mode: GenerateMode;
model: GenerateModel;
textEncoder: string;
vae: string;
prompt: string;
negativePrompt: string;
strength: number;
steps: number;
cfg: number;
seed: number;
sampler: string;
scheduler: string;
width: number;
height: number;
batchSize: number;
refinePass: boolean;
refineStrength: number;
outpaint: {
left: number;
top: number;
right: number;
bottom: number;
feathering: number;
};
inpaint: {
profile: InpaintProfile;
maskedAreaOnly: boolean;
cropPadding: number;
maskPolarity: "hidden" | "revealed";
maskedContent: InpaintMaskedContent;
growMaskBy: number;
maskExpand: number;
maskFeather: number;
maskBlur: number;
maskDespeckle: number;
structureControl: InpaintStructureControl;
controlStrength: number;
controlModel: string;
colorMatch: boolean;
};
};
export const generateArchitectureDefaults: Record<GenerateArchitecture, {
model: GenerateModel;
textEncoder: string;
vae: string;
steps: number;
cfg: number;
sampler: string;
scheduler: string;
supportedModes: readonly GenerateMode[];
}> = {
sdxl: { model: "auto", textEncoder: "auto", vae: "auto", steps: 30, cfg: 7, sampler: "euler", scheduler: "normal", supportedModes: ["text-to-image", "image-to-image", "inpaint", "outpaint"] },
"z-image": { model: "auto", textEncoder: "qwen_3_4b.safetensors", vae: "ae.safetensors", steps: 30, cfg: 4, sampler: "res_multistep", scheduler: "simple", supportedModes: ["text-to-image"] },
"z-image-turbo": { model: "auto", textEncoder: "qwen_3_4b.safetensors", vae: "ae.safetensors", steps: 8, cfg: 1, sampler: "res_multistep", scheduler: "simple", supportedModes: ["text-to-image"] },
anima: { model: "auto", textEncoder: "qwen_3_06b_base.safetensors", vae: "qwen_image_vae.safetensors", steps: 30, cfg: 4, sampler: "er_sde", scheduler: "simple", supportedModes: ["text-to-image"] },
};
export type MagicWandSettings = {
tolerance: number;
feather: number;
choke: number;
despeckle: number;
contiguous: boolean;
mode: MagicWandMode;
};
export type ToolState = {
activeTool: ToolId;
interactionMode: InteractionMode;
brush: BrushSettings;
feather: FeatherSettings;
chromaKey: ChromaKeySettings;
magicWand: MagicWandSettings;
generate: GenerateSettings;
};
export const initialToolState: ToolState = {
activeTool: "select",
interactionMode: { type: "tool", tool: "select" },
brush: { color: "#111827", size: 8, hardness: 100 },
brush: { color: "#111827", size: 8, hardness: 100, opacity: 100, flow: 100, smoothing: 20, pressureSize: true },
feather: { size: 96, radius: 16, strength: 65, smoothing: 25, pressureSize: true },
chromaKey: { color: "#00ff00", tolerance: 32, softness: 24, feather: 0, choke: 0, despeckle: 0, spill: 50 },
magicWand: { tolerance: 32, feather: 0, choke: 0, despeckle: 0, contiguous: true, mode: "replace" },
generate: {
architecture: "sdxl",
mode: "text-to-image",
model: "auto",
textEncoder: "auto",
vae: "auto",
prompt: "",
negativePrompt: "",
strength: 75,
steps: 30,
cfg: 7,
seed: -1,
sampler: "euler",
scheduler: "normal",
width: 1024,
height: 1024,
batchSize: 4,
refinePass: true,
refineStrength: 20,
outpaint: { left: 128, top: 128, right: 128, bottom: 128, feathering: 32 },
inpaint: { profile: "replace", maskedAreaOnly: true, cropPadding: 128, maskPolarity: "revealed", maskedContent: "neutral", growMaskBy: 6, maskExpand: 8, maskFeather: 3, maskBlur: 0, maskDespeckle: 0, structureControl: "none", controlStrength: 0.55, controlModel: "auto", colorMatch: true },
},
};
export const inpaintProfileDefaults: Record<Exclude<InpaintProfile, "custom">, Omit<Partial<GenerateSettings>, "inpaint"> & { inpaint: Partial<GenerateSettings["inpaint"]> }> = {
remove: { strength: 100, inpaint: { maskedContent: "neutral", cropPadding: 160, maskExpand: 12, maskFeather: 4, structureControl: "none" } },
replace: { strength: 85, inpaint: { maskedContent: "neutral", cropPadding: 128, maskExpand: 8, maskFeather: 3, structureControl: "none" } },
repair: { strength: 45, refinePass: true, refineStrength: 15, inpaint: { maskedContent: "original", cropPadding: 96, maskExpand: 4, maskFeather: 2, structureControl: "none" } },
material: { strength: 65, refinePass: true, refineStrength: 20, inpaint: { maskedContent: "edges", cropPadding: 128, maskExpand: 6, maskFeather: 3, structureControl: "canny", controlStrength: 0.55, colorMatch: false } },
reshape: { strength: 90, inpaint: { maskedContent: "neutral", cropPadding: 160, maskExpand: 10, maskFeather: 4, structureControl: "depth", controlStrength: 0.4 } },
};
export function isPanInteractionMode(interactionMode: InteractionMode): boolean {

View File

@@ -1,8 +1,10 @@
import { describe, expect, test } from "bun:test";
import type { ImageDocument } from "@core/document";
import { applyTransformTargetBounds, resolveTransformTargetBounds, selectedTransformTarget } from "./transform-targets";
import { resolveTransformTargetBounds, selectedTransformTarget } from "./transform-targets";
import { applyTransformTargetBounds } from "@commands/transform-document";
const document: ImageDocument = {
inpaintRegions: [],
id: "d1",
name: "Test",
version: 1,
@@ -44,6 +46,10 @@ describe("transform targets", () => {
expect(resolveTransformTargetBounds(document, { type: "layer", id: "l1" })).toEqual({ x: 10, y: 20, w: 100, h: 200 });
});
test("resolves group bounds from descendant layers", () => {
expect(resolveTransformTargetBounds(groupDocument(), { type: "layer", id: "g1" })).toEqual({ x: 10, y: 20, w: 300, h: 200 });
});
test("applies image layer bounds to transform", () => {
const next = applyTransformTargetBounds(document, { type: "layer", id: "l1" }, { x: 30, y: 40, w: 400, h: 50 });
const layer = next.artboards[0]?.layers[0];
@@ -51,6 +57,13 @@ describe("transform targets", () => {
expect(layer?.transform).toEqual({ position: { x: 30, y: 40 }, scale: { x: 2, y: 0.5 }, rotation: 0 });
});
test("applies visible bounds to a cropped layer while retaining its source crop", () => {
const cropped = { ...document, artboards: [{ ...document.artboards[0]!, layers: [{ ...document.artboards[0]!.layers[0]!, sourceRect: { x: 10, y: 5, w: 40, h: 20 } }] }] };
const next = applyTransformTargetBounds(cropped, { type: "layer", id: "l1" }, { x: 100, y: 80, w: 200, h: 60 });
expect(next.artboards[0]?.layers[0]).toMatchObject({ sourceRect: { x: 10, y: 5, w: 40, h: 20 }, transform: { position: { x: 50, y: 65 }, scale: { x: 5, y: 3 } } });
expect(resolveTransformTargetBounds(next, { type: "layer", id: "l1" })).toEqual({ x: 100, y: 80, w: 200, h: 60 });
});
test("keeps attached mask bounds in sync with transformed layers", () => {
const maskedDocument: ImageDocument = {
...document,
@@ -81,8 +94,49 @@ describe("transform targets", () => {
expect(next.artboards[0]?.layers[1]?.transform).toEqual({ position: { x: 30, y: 40 }, scale: { x: 2, y: 0.5 }, rotation: 0 });
});
test("applies group bounds to descendant layer transforms", () => {
const next = applyTransformTargetBounds(groupDocument(), { type: "layer", id: "g1" }, { x: 20, y: 40, w: 600, h: 100 });
const group = next.artboards[0]?.layers[0];
const first = group?.type === "group" ? group.children[0] : undefined;
const second = group?.type === "group" ? group.children[1] : undefined;
expect(first?.transform).toEqual({ position: { x: 20, y: 40 }, scale: { x: 1, y: 1 }, rotation: 0 });
expect(second?.transform).toEqual({ position: { x: 420, y: 90 }, scale: { x: 1, y: 0.5 }, rotation: 0 });
});
test("applies artboard bounds", () => {
const next = applyTransformTargetBounds(document, { type: "artboard", id: "a1" }, { x: 10, y: 20, w: 200, h: 160 });
expect(next.artboards[0]?.bounds).toEqual({ x: 10, y: 20, w: 200, h: 160 });
});
});
function groupDocument(): ImageDocument {
return {
...document,
artboards: [
{
...document.artboards[0]!,
layers: [
{
id: "g1",
type: "group",
name: "Group",
visible: true,
locked: false,
opacity: 1,
transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 },
children: [
document.artboards[0]!.layers[0]!,
{
...document.artboards[0]!.layers[0]!,
id: "l2",
name: "Second Layer",
transform: { position: { x: 210, y: 120 }, scale: { x: 0.5, y: 1 }, rotation: 0 },
},
],
},
],
},
],
};
}

View File

@@ -3,6 +3,7 @@ import type { Rect } from "@core/geometry";
import type { Layer } from "@core/layer";
import type { ArtboardId, LayerId } from "@core/id";
import type { TransformTarget } from "./transform";
import { measureTextLayer } from "@core/text-layer";
export function resolveTransformTargetBounds(document: ImageDocument, target: TransformTarget): Rect | undefined {
switch (target.type) {
@@ -15,64 +16,12 @@ export function resolveTransformTargetBounds(document: ImageDocument, target: Tr
}
}
export function applyTransformTargetBounds(document: ImageDocument, target: TransformTarget, bounds: Rect): ImageDocument {
switch (target.type) {
case "artboard":
return {
...document,
artboards: document.artboards.map((artboard) => (artboard.id === target.id ? { ...artboard, bounds: { ...bounds } } : artboard)),
};
case "layer":
return applyLayerBounds(document, target.id, bounds);
}
}
export function selectedTransformTarget(document: ImageDocument, selection: { artboardId?: ArtboardId; layerIds: LayerId[] }): TransformTarget | undefined {
if (selection.layerIds.length === 1 && selection.layerIds[0]) return { type: "layer", id: selection.layerIds[0] };
if (selection.artboardId) return { type: "artboard", id: selection.artboardId };
return undefined;
}
function applyLayerBounds(document: ImageDocument, layerId: LayerId, bounds: Rect): ImageDocument {
const layer = findLayer(document, layerId);
const targetLayerIds = layer?.clippingMask ? [layerId, layer.clippingMask.maskLayerId] : [layerId];
return targetLayerIds.reduce(
(nextDocument, targetLayerId) => ({
...nextDocument,
artboards: nextDocument.artboards.map((artboard) => ({
...artboard,
layers: applyLayerBoundsInTree(nextDocument, artboard.layers, targetLayerId, bounds),
})),
}),
document,
);
}
function applyLayerBoundsInTree(document: ImageDocument, layers: Layer[], layerId: LayerId, bounds: Rect): Layer[] {
return layers.map((layer) => {
if (layer.id === layerId && (layer.type === "image" || layer.type === "raster")) {
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
if (!asset) return layer;
return {
...layer,
transform: {
...layer.transform,
position: { x: bounds.x, y: bounds.y },
scale: {
x: bounds.w / asset.intrinsicSize.w,
y: bounds.h / asset.intrinsicSize.h,
},
},
};
}
if (layer.type === "group") return { ...layer, children: applyLayerBoundsInTree(document, layer.children, layerId, bounds) };
return layer;
});
}
function findLayer(document: ImageDocument, layerId: LayerId): Layer | undefined {
for (const artboard of document.artboards) {
const layer = findLayerInTree(artboard.layers, layerId);
@@ -105,14 +54,21 @@ function resolveLayerBounds(document: ImageDocument, layer: Layer): Rect | undef
case "raster": {
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
if (!asset) return undefined;
const source = layer.sourceRect ?? { x: 0, y: 0, ...asset.intrinsicSize };
return {
x: layer.transform.position.x,
y: layer.transform.position.y,
w: asset.intrinsicSize.w * layer.transform.scale.x,
h: asset.intrinsicSize.h * layer.transform.scale.y,
x: layer.transform.position.x + source.x * layer.transform.scale.x,
y: layer.transform.position.y + source.y * layer.transform.scale.y,
w: source.w * layer.transform.scale.x,
h: source.h * layer.transform.scale.y,
};
}
case "text": {
const size = measureTextLayer(layer);
return { x: layer.transform.position.x, y: layer.transform.position.y, w: size.w * layer.transform.scale.x, h: size.h * layer.transform.scale.y };
}
case "adjustment":
return undefined;
}
}

View File

@@ -14,6 +14,9 @@ const appLayerImports = [
"../commands/*",
"../editor/*",
"../input/*",
"@operations/*",
"@platform/*",
"@server/*",
];
export default tseslint.config(
@@ -40,7 +43,7 @@ export default tseslint.config(
rules: {
"no-restricted-imports": [
"error",
{ patterns: ["@app/*", "@view/*", "@renderer/*", "../app/*", "../view/*", "../renderer/*", "react", "react-dom"] },
{ patterns: ["@app/*", "@view/*", "@renderer/*", "@operations/*", "@platform/*", "@server/*", "../app/*", "../view/*", "../renderer/*", "react", "react-dom"] },
],
},
},
@@ -49,7 +52,7 @@ export default tseslint.config(
rules: {
"no-restricted-imports": [
"error",
{ patterns: ["@app/*", "@view/*", "@renderer/*", "../app/*", "../view/*", "../renderer/*", "react", "react-dom"] },
{ patterns: ["@app/*", "@view/*", "@renderer/*", "@operations/*", "@platform/*", "@server/*", "../app/*", "../view/*", "../renderer/*", "react", "react-dom"] },
],
},
},
@@ -79,9 +82,32 @@ export default tseslint.config(
"../editor/*",
"react",
"react-dom",
"@core/document",
"@core/layer",
"@core/artboard",
"@core/asset",
],
},
],
},
},
{
files: ["operations/**/*.{ts,tsx}"],
rules: {
"no-restricted-imports": ["error", { patterns: ["@app/*", "@view/*", "@renderer/*", "@server/*", "react", "react-dom"] }],
"no-restricted-globals": ["error", "document", "Image", "URL", "fetch", "requestAnimationFrame", "cancelAnimationFrame"],
},
},
{
files: ["platform/**/*.{ts,tsx}"],
rules: {
"no-restricted-imports": ["error", { patterns: ["@app/*", "@view/*", "@renderer/*", "@commands/*", "@editor/*", "@input/*", "@operations/*", "@server/*", "react", "react-dom"] }],
},
},
{
files: ["server/**/*.{ts,tsx}"],
rules: {
"no-restricted-imports": ["error", { patterns: ["@app/*", "@view/*", "@renderer/*", "@commands/*", "@editor/*", "@input/*", "@operations/*", "@platform/*", "react", "react-dom"] }],
},
},
);

View File

@@ -1,8 +1,12 @@
import { serve } from "bun";
import { handleComfyApi } from "./server/comfy-routes";
import index from "./view/index.html";
const server = serve({
routes: {
"/api/comfy/models": handleComfyApi,
"/api/comfy/generate": handleComfyApi,
"/api/comfy/segment": handleComfyApi,
"/*": index,
},

View File

@@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test";
import { commandIds } from "@commands/ids";
import { handleCommandPaletteKey } from "./command-palette";
describe("command palette input", () => {
test("opens the command palette with the platform shortcut", () => {
const dispatched: unknown[] = [];
const consumed = handleCommandPaletteKey({
event: { key: "k", code: "KeyK", altKey: false, ctrlKey: false, metaKey: true, shiftKey: false },
dispatch: (commandId, payload) => {
dispatched.push({ commandId, payload });
return undefined as never;
},
});
expect(consumed).toBe(true);
expect(dispatched).toEqual([{ commandId: commandIds.commandPaletteOpen, payload: undefined }]);
});
test("ignores unmodified K so the chroma key operation keeps its shortcut", () => {
const consumed = handleCommandPaletteKey({
event: { key: "k", code: "KeyK", altKey: false, ctrlKey: false, metaKey: false, shiftKey: false },
dispatch: () => undefined as never,
});
expect(consumed).toBe(false);
});
});

12
input/command-palette.ts Normal file
View File

@@ -0,0 +1,12 @@
import { commandIds } from "@commands/ids";
import type { Dispatch } from "@commands/dispatcher";
import type { KeybindEvent } from "./keyboard";
export function handleCommandPaletteKey(options: { event: KeybindEvent; dispatch: Dispatch }): boolean {
if (options.event.altKey || options.event.shiftKey) return false;
const modifier = options.event.metaKey || options.event.ctrlKey;
if (!modifier || options.event.key.toLowerCase() !== "k") return false;
options.dispatch(commandIds.commandPaletteOpen, undefined);
return true;
}

View File

@@ -1,7 +1,6 @@
import type { ImageDocument } from "@core/document";
import type { Rect, Size, Vec2D } from "@core/geometry";
import type { ArtboardId, LayerId } from "@core/id";
import type { Layer } from "@core/layer";
import type { InputArtboardId, InputDocument, InputLayer, InputLayerId } from "./read-model";
import { measureTextLayer } from "@core/text-layer";
export type InputViewportState = {
center: Vec2D;
@@ -11,21 +10,21 @@ export type InputViewportState = {
};
export type InputSelectionState = {
artboardId?: ArtboardId;
layerIds: LayerId[];
artboardId?: InputArtboardId;
layerIds: InputLayerId[];
};
export type InputTransformTarget =
| { type: "artboard"; id: ArtboardId }
| { type: "layer"; id: LayerId };
| { type: "artboard"; id: InputArtboardId }
| { type: "layer"; id: InputLayerId };
export function selectedTransformTarget(_document: ImageDocument, selection: InputSelectionState): InputTransformTarget | undefined {
export function selectedTransformTarget(_document: InputDocument, selection: InputSelectionState): InputTransformTarget | undefined {
if (selection.layerIds.length === 1 && selection.layerIds[0]) return { type: "layer", id: selection.layerIds[0] };
if (selection.artboardId) return { type: "artboard", id: selection.artboardId };
return undefined;
}
export function resolveTransformTargetBounds(document: ImageDocument, target: InputTransformTarget): Rect | undefined {
export function resolveTransformTargetBounds(document: InputDocument, target: InputTransformTarget): Rect | undefined {
switch (target.type) {
case "artboard":
return document.artboards.find((artboard) => artboard.id === target.id)?.bounds;
@@ -52,7 +51,7 @@ export function documentRectToViewportRect(rect: Rect, viewport: InputViewportSt
};
}
function findLayer(document: ImageDocument, layerId: LayerId): Layer | undefined {
function findLayer(document: InputDocument, layerId: InputLayerId): InputLayer | undefined {
for (const artboard of document.artboards) {
const layer = findLayerInTree(artboard.layers, layerId);
if (layer) return layer;
@@ -61,7 +60,7 @@ function findLayer(document: ImageDocument, layerId: LayerId): Layer | undefined
return undefined;
}
function findLayerInTree(layers: readonly Layer[], layerId: LayerId): Layer | undefined {
function findLayerInTree(layers: readonly InputLayer[], layerId: InputLayerId): InputLayer | undefined {
for (const layer of layers) {
if (layer.id === layerId) return layer;
if (layer.type === "group") {
@@ -73,7 +72,7 @@ function findLayerInTree(layers: readonly Layer[], layerId: LayerId): Layer | un
return undefined;
}
function resolveLayerBounds(document: ImageDocument, layer: Layer): Rect | undefined {
function resolveLayerBounds(document: InputDocument, layer: InputLayer): Rect | undefined {
switch (layer.type) {
case "group":
return unionRects(layer.children.flatMap((child) => {
@@ -92,6 +91,12 @@ function resolveLayerBounds(document: ImageDocument, layer: Layer): Rect | undef
h: asset.intrinsicSize.h * layer.transform.scale.y,
};
}
case "text": {
const size = measureTextLayer(layer);
return { x: layer.transform.position.x, y: layer.transform.position.y, w: size.w * layer.transform.scale.x, h: size.h * layer.transform.scale.y };
}
case "adjustment":
return undefined;
}
}

View File

@@ -23,6 +23,7 @@ export function pointerInputEventFromPointerEvent(event: PointerEvent): PointerI
pointerType: normalizePointerType(event.pointerType),
position: { x: event.offsetX, y: event.offsetY },
buttons: event.buttons,
pressure: event.pointerType === "pen" ? event.pressure : 1,
altKey: event.altKey,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,

View File

@@ -2,11 +2,20 @@ import { commandIds } from "@commands/ids";
import type { Dispatch } from "@commands/dispatcher";
import type { KeybindEvent } from "./keyboard";
export function handleHistoryKey(options: { event: KeybindEvent; dispatch: Dispatch }): boolean {
export type HistoryActions = { undo(): void; redo(): void };
export function handleHistoryKey(options: { event: KeybindEvent; dispatch?: Dispatch; actions?: HistoryActions }): boolean {
if (options.event.altKey) return false;
const modifier = options.event.metaKey || options.event.ctrlKey;
if (!modifier || options.event.key.toLowerCase() !== "z") return false;
options.dispatch(options.event.shiftKey ? commandIds.historyRedo : commandIds.historyUndo, undefined);
if (options.actions) {
if (options.event.shiftKey) options.actions.redo();
else options.actions.undo();
} else if (options.dispatch) {
options.dispatch(options.event.shiftKey ? commandIds.historyRedo : commandIds.historyUndo, undefined);
} else {
return false;
}
return true;
}

View File

@@ -5,7 +5,10 @@ export {
} from "./dom";
export type { CommandKeybind, GlobalKeybindConsumer, Keybind, KeybindEvent, KeybindMap } from "./keyboard";
export { handleKeybind, keybindFromEvent } from "./keyboard";
export { handleCommandPaletteKey } from "./command-palette";
export { handleHistoryKey } from "./history";
export { handleToolKey } from "./tool-keybinds";
export { handleOperationKey } from "./operation-keybinds";
export { findGroup, findLayerInfoInDocument, handleDeleteSelectionKey, resolveLayerDrop } from "./layers-panel";
export type { LayerDropTarget, LayerInfo } from "./layers-panel";
export { handleArtboardSelection } from "./selection";

View File

@@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test";
import { commandIds } from "@commands/ids";
import type { ImageDocument } from "@core/document";
import type { Layer } from "@core/layer";
import type { InputDocument as ImageDocument, InputLayer as Layer } from "./read-model";
import { handleDeleteSelectionKey, resolveLayerDrop } from "./layers-panel";
const document: ImageDocument = {
@@ -41,6 +40,10 @@ describe("layers panel input", () => {
});
});
test("does not resolve dropping a group into one of its descendants", () => {
expect(resolveLayerDrop({ document, sourceLayerId: "g", target: { artboardId: "a1", layer: group("c") }, verticalRatio: 0.5 })).toBeUndefined();
});
test("dispatches delete commands for selected layers", () => {
const dispatched: unknown[] = [];
const consumed = handleDeleteSelectionKey({

View File

@@ -1,8 +1,6 @@
import { commandIds } from "@commands/ids";
import type { Dispatch } from "@commands/dispatcher";
import type { ImageDocument } from "@core/document";
import type { ArtboardId, LayerId } from "@core/id";
import type { Layer } from "@core/layer";
import type { InputArtboardId as ArtboardId, InputDocument, InputLayer as Layer, InputLayerId as LayerId } from "./read-model";
import type { KeybindEvent } from "./keyboard";
export type LayerInfo = {
@@ -33,7 +31,7 @@ export function handleDeleteSelectionKey(options: { event: KeybindEvent; selecti
}
export function resolveLayerDrop(options: {
document: ImageDocument;
document: InputDocument;
sourceLayerId: LayerId;
target: LayerDropTarget;
verticalRatio: number;
@@ -41,15 +39,17 @@ export function resolveLayerDrop(options: {
const targetInfo = findLayerInfoInDocument(options.document, options.target.layer.id);
const sourceInfo = findLayerInfoInDocument(options.document, options.sourceLayerId);
if (!targetInfo || !sourceInfo || options.sourceLayerId === options.target.layer.id) return undefined;
if (isDescendantLayer(sourceInfo.layer, options.target.layer.id)) return undefined;
const verticalRatio = Math.max(0, Math.min(1, options.verticalRatio));
const dropIntoGroup = options.target.layer.type === "group" && verticalRatio >= 0.33 && verticalRatio <= 0.66;
const targetLayer = options.target.layer;
const dropIntoGroup = targetLayer.type === "group" && verticalRatio >= 0.33 && verticalRatio <= 0.66;
if (dropIntoGroup) {
return {
layerId: options.sourceLayerId,
toArtboardId: options.target.artboardId,
toParentGroupId: options.target.layer.id,
toIndex: options.target.layer.children.length,
toParentGroupId: targetLayer.id,
toIndex: targetLayer.children.length,
};
}
@@ -73,7 +73,7 @@ export function resolveLayerDrop(options: {
};
}
export function findLayerInfoInDocument(document: ImageDocument, layerId?: LayerId): LayerInfo | undefined {
export function findLayerInfoInDocument(document: InputDocument, layerId?: LayerId): LayerInfo | undefined {
if (!layerId) return undefined;
for (const artboard of document.artboards) {
const found = findLayerInfo(artboard.layers, layerId, artboard.id);
@@ -82,7 +82,7 @@ export function findLayerInfoInDocument(document: ImageDocument, layerId?: Layer
return undefined;
}
export function findGroup(document: ImageDocument, groupId: LayerId): Extract<Layer, { type: "group" }> | undefined {
export function findGroup(document: InputDocument, groupId: LayerId): Extract<Layer, { type: "group" }> | undefined {
const info = findLayerInfoInDocument(document, groupId);
return info?.layer.type === "group" ? info.layer : undefined;
}
@@ -97,3 +97,11 @@ function findLayerInfo(layers: Layer[], layerId: LayerId, artboardId: ArtboardId
}
return undefined;
}
function isDescendantLayer(layer: Layer, descendantLayerId: LayerId): boolean {
if (layer.type !== "group") return false;
for (const child of layer.children) {
if (child.id === descendantLayerId || isDescendantLayer(child, descendantLayerId)) return true;
}
return false;
}

View File

@@ -0,0 +1,22 @@
import { describe, expect, test } from "bun:test";
import { commandIds } from "@commands/ids";
import type { Dispatch } from "@commands/dispatcher";
import { handleOperationKey } from "./operation-keybinds";
describe("operation keybinds", () => {
test.each([
["g", "generate"],
["k", "chromaKey"],
] as const)("opens the %s operation without selecting a tool", (key, panel) => {
const dispatched: unknown[] = [];
const consumed = handleOperationKey({
event: { key, code: `Key${key.toUpperCase()}`, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false },
dispatch: ((commandId: unknown, payload: unknown) => {
dispatched.push({ commandId, payload });
return undefined;
}) as unknown as Dispatch,
});
expect(consumed).toBe(true);
expect(dispatched).toEqual([{ commandId: commandIds.workspaceSetPanel, payload: { panel } }]);
});
});

View File

@@ -0,0 +1,16 @@
import { commandIds } from "@commands/ids";
import type { Dispatch } from "@commands/dispatcher";
import type { KeybindEvent } from "./keyboard";
const operationKeybinds = {
g: "generate",
k: "chromaKey",
} as const;
export function handleOperationKey(options: { event: KeybindEvent; dispatch: Dispatch }): boolean {
if (options.event.altKey || options.event.ctrlKey || options.event.metaKey) return false;
const panel = operationKeybinds[options.event.key.toLowerCase() as keyof typeof operationKeybinds];
if (!panel) return false;
options.dispatch(commandIds.workspaceSetPanel, { panel });
return true;
}

View File

@@ -5,6 +5,7 @@ export type PointerInputEvent = {
pointerType: "mouse" | "pen" | "touch";
position: Vec2D;
buttons: number;
pressure?: number;
altKey: boolean;
ctrlKey: boolean;
metaKey: boolean;

30
input/read-model.ts Normal file
View File

@@ -0,0 +1,30 @@
import type { Rect, Size, Transform } from "@core/geometry";
import type { TextStyle } from "@core/text-layer";
export type InputLayerId = string;
export type InputArtboardId = string;
type InputBaseLayer = {
id: InputLayerId;
name?: string;
visible: boolean;
locked: boolean;
opacity?: number;
transform: Transform;
layerMask?: { maskLayerId: InputLayerId };
clippingMask?: { maskLayerId: InputLayerId };
};
export type InputLayer =
| (InputBaseLayer & { type: "group"; children: InputLayer[] })
| (InputBaseLayer & { type: "adjustment" })
| (InputBaseLayer & { type: "text"; content: string; style: TextStyle })
| (InputBaseLayer & { type: "image" | "raster"; assetId: string });
export type InputDocument = {
id?: string;
name?: string;
version?: number;
assets: Array<{ id: string; name?: string; mimeType?: string; source?: string; intrinsicSize: Size }>;
artboards: Array<{ id: InputArtboardId; name?: string; backgroundColor?: string; visible: boolean; locked: boolean; bounds: Rect; layers: InputLayer[] }>;
};

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { commandIds } from "@commands/ids";
import type { ImageDocument } from "@core/document";
import type { InputDocument as ImageDocument } from "./read-model";
import { handleArtboardSelection } from "./selection";
import type { PointerInputEvent } from "./pointer";

View File

@@ -1,13 +1,12 @@
import { commandIds } from "@commands/ids";
import type { Dispatch } from "@commands/dispatcher";
import type { ImageDocument } from "@core/document";
import type { Layer } from "@core/layer";
import type { InputDocument, InputLayer } from "./read-model";
import { resolveTransformTargetBounds, viewportPointToDocumentPoint, type InputViewportState } from "./document-geometry";
import type { PointerInputEvent } from "./pointer";
export function handleArtboardSelection(options: {
event: PointerInputEvent;
document: ImageDocument;
document: InputDocument;
viewport: InputViewportState;
dispatch: Dispatch;
}): boolean {
@@ -35,7 +34,7 @@ export function handleArtboardSelection(options: {
return true;
}
function findTopmostLayerAtPoint(document: ImageDocument, point: { x: number; y: number }) {
function findTopmostLayerAtPoint(document: InputDocument, point: { x: number; y: number }) {
const maskLayerIds = collectMaskLayerIds(document.artboards.flatMap((artboard) => artboard.layers));
for (const artboard of [...document.artboards].reverse()) {
if (!artboard.visible || artboard.locked) continue;
@@ -46,7 +45,7 @@ function findTopmostLayerAtPoint(document: ImageDocument, point: { x: number; y:
return undefined;
}
function findTopmostLayerInTreeAtPoint(document: ImageDocument, layers: Layer[], point: { x: number; y: number }, maskLayerIds: ReadonlySet<string>): string | undefined {
function findTopmostLayerInTreeAtPoint(document: InputDocument, layers: InputLayer[], point: { x: number; y: number }, maskLayerIds: ReadonlySet<string>): string | undefined {
for (const layer of layers) {
if (!layer.visible || layer.locked || maskLayerIds.has(layer.id)) continue;
if (layer.type === "group") {
@@ -63,11 +62,11 @@ function findTopmostLayerInTreeAtPoint(document: ImageDocument, layers: Layer[],
return undefined;
}
function collectMaskLayerIds(layers: readonly Layer[], ids = new Set<string>()): Set<string> {
function collectMaskLayerIds(layers: readonly InputLayer[], ids = new Set<string>()): Set<string> {
for (const layer of layers) {
if (layer.clippingMask) ids.add(layer.clippingMask.maskLayerId);
const layerMask = layer.layerMask ?? layer.clippingMask;
if (layerMask) ids.add(layerMask.maskLayerId);
if (layer.type === "group") collectMaskLayerIds(layer.children, ids);
}
return ids;
}

View File

@@ -0,0 +1,20 @@
import { describe, expect, test } from "bun:test";
import { commandIds } from "@commands/ids";
import type { Dispatch } from "@commands/dispatcher";
import { handleToolKey } from "./tool-keybinds";
describe("tool keybinds", () => {
test("selects the feather tool with F", () => {
const dispatched: unknown[] = [];
const consumed = handleToolKey({
event: { key: "f", code: "KeyF", altKey: false, ctrlKey: false, metaKey: false, shiftKey: false },
dispatch: ((commandId: unknown, payload: unknown) => {
dispatched.push({ commandId, payload });
return undefined;
}) as unknown as Dispatch,
});
expect(consumed).toBe(true);
expect(dispatched).toEqual([{ commandId: commandIds.toolSetActive, payload: { tool: "feather" } }]);
});
});

22
input/tool-keybinds.ts Normal file
View File

@@ -0,0 +1,22 @@
import { commandIds } from "@commands/ids";
import type { Dispatch } from "@commands/dispatcher";
import type { KeybindEvent } from "./keyboard";
const toolKeybinds = {
b: "brush",
e: "eraser",
f: "feather",
w: "magicWand",
p: "pan",
s: "select",
} as const;
export function handleToolKey(options: { event: KeybindEvent; dispatch: Dispatch }): boolean {
if (options.event.altKey || options.event.ctrlKey || options.event.metaKey) return false;
const tool = toolKeybinds[options.event.key.toLowerCase() as keyof typeof toolKeybinds];
if (!tool) return false;
options.dispatch(commandIds.toolSetActive, { tool });
return true;
}

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { commandIds } from "@commands/ids";
import type { ImageDocument } from "@core/document";
import type { InputDocument as ImageDocument } from "./read-model";
import type { PointerInputEvent } from "./pointer";
import { createTransformControlsInputController, hitTestArtboardTransformHandle, type TransformControlsEditorState } from "./transform-controls";

View File

@@ -1,7 +1,7 @@
import { commandIds } from "@commands/ids";
import type { Dispatch } from "@commands/dispatcher";
import type { ImageDocument } from "@core/document";
import type { Rect, Vec2D } from "@core/geometry";
import type { InputDocument } from "./read-model";
import {
documentRectToViewportRect,
resolveTransformTargetBounds,
@@ -16,7 +16,7 @@ import type { PointerInputEvent } from "./pointer";
type TransformHandle = "body" | "nw" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w";
type InputToolId = "select" | "brush" | "eraser" | "pan";
type InputToolId = "select" | "brush" | "eraser" | "feather" | "magicWand" | "semanticSelect" | "maskLasso" | "maskRectangle" | "pan";
type InputInteractionMode =
| { type: "tool"; tool: InputToolId }
@@ -39,7 +39,7 @@ export type TransformControlsInputController = {
};
export function createTransformControlsInputController(options: {
getDocument: () => ImageDocument;
getDocument: () => InputDocument;
getEditor: () => TransformControlsEditorState;
dispatch: Dispatch;
}): TransformControlsInputController {
@@ -93,7 +93,7 @@ export function hitTestArtboardTransformHandle(position: Vec2D, bounds: Rect, vi
return undefined;
}
function isTransformTargetLocked(document: ImageDocument, target: InputTransformTarget) {
function isTransformTargetLocked(document: InputDocument, target: InputTransformTarget) {
if (target.type === "artboard") {
const artboard = document.artboards.find((candidate) => candidate.id === target.id);
return !artboard || !artboard.visible || artboard.locked;

View File

@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test";
import type { Dispatch } from "@commands/dispatcher";
import { commandIds } from "@commands/ids";
import type { CommandPayloads } from "@commands/payloads";
import type { PointerInputEvent } from "./pointer";
import { createViewportPanInputController } from "./viewport-pan";
@@ -45,25 +46,36 @@ describe("viewport pan store integration", () => {
});
});
type InputToolId = "select" | "brush" | "eraser" | "pan";
type InputToolId = CommandPayloads[typeof commandIds.toolSetActive]["tool"];
type InputInteractionMode =
| { type: "tool"; tool: InputToolId }
| { type: "temporary-pan"; previousTool: InputToolId };
type InputStoreState = {
viewport: { center: { x: number; y: number }; zoom: number };
tools: { activeTool: InputToolId; interactionMode: InputInteractionMode };
};
type InputStore = ReturnType<typeof createInputStore>;
function createInputStore() {
const store = {
state: {
viewport: { center: { x: 0, y: 0 }, zoom: 1 },
tools: {
activeTool: "select" as InputToolId,
interactionMode: { type: "tool" as const, tool: "select" as InputToolId },
},
const state: InputStoreState = {
viewport: { center: { x: 0, y: 0 }, zoom: 1 },
tools: {
activeTool: "select",
interactionMode: { type: "tool", tool: "select" },
},
};
const store = {
state,
dispatch: ((commandId, payload) => {
switch (commandId) {
case commandIds.toolSetActive:
store.state.tools.activeTool = payload.tool;
store.state.tools.interactionMode = { type: "tool", tool: payload.tool };
case commandIds.toolSetActive: {
const next = payload as CommandPayloads[typeof commandIds.toolSetActive];
store.state.tools.activeTool = next.tool;
store.state.tools.interactionMode = { type: "tool", tool: next.tool };
break;
}
case commandIds.toolEnterTemporaryPan:
store.state.tools.interactionMode = { type: "temporary-pan", previousTool: store.state.tools.activeTool };
break;
@@ -75,12 +87,14 @@ function createInputStore() {
}
break;
}
case commandIds.viewportPan:
case commandIds.viewportPan: {
const next = payload as CommandPayloads[typeof commandIds.viewportPan];
store.state.viewport.center = {
x: store.state.viewport.center.x + payload.delta.x,
y: store.state.viewport.center.y + payload.delta.y,
x: store.state.viewport.center.x + next.delta.x,
y: store.state.viewport.center.y + next.delta.y,
};
break;
}
}
return ignoredState;
}) as Dispatch,

7
operations/AGENTS.md Normal file
View File

@@ -0,0 +1,7 @@
# Application Operation Rules
- `operations/` coordinates asynchronous editor use cases such as generation, import, raster processing, and export.
- Operations may read immutable state snapshots, call injected platform capabilities, and dispatch commands.
- Operations must never mutate `ImageDocument` or `EditorState` directly.
- Keep React, DOM elements, WebGL objects, server-only APIs, and concrete network details out of operation contracts.
- Prefer dependency injection for platform behavior so operation control flow remains testable.

View File

@@ -0,0 +1,193 @@
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";
import { neutralColorAdjustment } from "@core/adjustment-layer";
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 addAdjustmentLayer(artboardId: ArtboardId, dispatch: AppStore["dispatch"]) {
dispatch(commandIds.documentAddAdjustmentLayer, { artboardId, layer: { id: crypto.randomUUID(), type: "adjustment", name: "Color adjustment", visible: true, locked: false, opacity: 1, transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 }, adjustment: { ...neutralColorAdjustment, colorBalance: { ...neutralColorAdjustment.colorBalance } } } });
}
export function addTextLayer(document: ImageDocument, artboardId: ArtboardId, selectedLayer: IndexedLayerInfo | undefined, dispatch: AppStore["dispatch"]) {
const artboard = document.artboards.find((candidate) => candidate.id === artboardId);
if (!artboard) return;
dispatch(commandIds.documentAddTextLayer, { artboardId, parentGroupId: selectedLayer?.layer.type === "group" ? selectedLayer.layer.id : undefined, layer: {
id: crypto.randomUUID(), type: "text", name: "Text", visible: true, locked: false, opacity: 1,
transform: { position: { x: artboard.bounds.x + 40, y: artboard.bounds.y + 40 }, scale: { x: 1, y: 1 }, rotation: 0 },
content: "Text", style: { fontFamily: "Arial", fontSize: 48, fontWeight: 400, fontStyle: "normal", color: "#ffffff", alignment: "left", lineHeight: 1.2 },
} });
}
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" || layer.type === "adjustment" || layer.type === "text") 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,
},
},
});
}
export function duplicateLayer(documentIndex: DocumentReadIndex, layerInfo: IndexedLayerInfo, dispatch: AppStore["dispatch"]) {
const ids = collectDuplicateIds(layerInfo.layer, layerInfo.siblings);
dispatch(commandIds.documentDuplicateLayer, {
layerId: layerInfo.layer.id,
idByLayerId: Object.fromEntries(ids.map((id) => [id, crypto.randomUUID()])),
});
}
function collectDuplicateIds(layer: Layer, siblings: readonly Layer[]): string[] {
const ids = collectTreeIds(layer);
const maskId = getLayerMask(layer)?.maskLayerId;
const mask = maskId ? siblings.find((candidate) => candidate.id === maskId) : undefined;
return mask ? [...collectTreeIds(mask), ...ids] : ids;
}
function collectTreeIds(layer: Layer): string[] {
return [layer.id, ...(layer.type === "group" ? layer.children.flatMap(collectTreeIds) : [])];
}
const emptyLayerIds = new Set<string>();
function createGroup(name: string): Extract<Layer, { type: "group" }> {
return {
id: crypto.randomUUID(),
type: "group",
name,
visible: true,
locked: false,
opacity: 1,
transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 },
children: [],
};
}

View File

@@ -0,0 +1,4 @@
import type { Artboard } from "@core/artboard";
import type { Asset } from "@core/asset";
import { downloadArtboardPng as download } from "@platform/browser/exportArtboardPng";
export function downloadArtboardPng(artboard: Artboard, assets: readonly Asset[]) { return download(artboard, assets); }

View File

@@ -0,0 +1,82 @@
import type { ImageDocument } from "@core/document";
import type { GenerationCandidate } from "@editor/state";
import { loadImageCanvas, maskValueFromRgba } from "@platform/browser/maskRaster";
import { createContentRevision } from "./inpaintPrep";
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 region = document.inpaintRegions.find((item) => item.id === candidate.inpaint?.regionId);
const maskAsset = region ? document.assets.find((asset) => asset.id === region.maskAssetId) : undefined;
if (!region || !maskAsset) throw new Error("The AI edit region for this candidate no longer exists.");
const [sourceRevision, maskRevision] = await Promise.all([createContentRevision(targetAsset.source), createContentRevision(maskAsset.source)]);
if (sourceRevision !== candidate.inpaint.revision.source || maskRevision !== candidate.inpaint.revision.mask) {
throw new Error("The source or AI edit region changed after generation. Rebuild candidates from the current edit region before replacing pixels.");
}
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.blendMaskImage, 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;
const colorOffset = candidate.settings.inpaint.colorMatch ? boundaryColorOffset(targetData.data, generatedData.data, maskData.data, candidate.width, candidate.height, crop, targetCanvas.width, targetCanvas.height) : [0, 0, 0];
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 rawNext = generatedData.data[generatedIndex + channel] ?? previous;
const next = channel < 3 ? Math.max(0, Math.min(255, rawNext + (colorOffset[channel] ?? 0))) : rawNext;
targetData.data[targetIndex + channel] = Math.round(previous * (1 - mask) + next * mask);
}
}
}
targetContext.putImageData(targetData, 0, 0);
return targetCanvas.toDataURL("image/png");
}
function boundaryColorOffset(target: Uint8ClampedArray, generated: Uint8ClampedArray, mask: Uint8ClampedArray, width: number, height: number, crop: { x: number; y: number }, targetWidth: number, targetHeight: number): number[] {
const targetTotal = [0, 0, 0];
const generatedTotal = [0, 0, 0];
let count = 0;
for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) {
const index = (y * width + x) * 4;
const amount = maskValueFromRgba(mask, index) / 255;
if (amount <= 0.05 || amount >= 0.65) continue;
const targetX = Math.round(crop.x) + x;
const targetY = Math.round(crop.y) + y;
if (targetX < 0 || targetY < 0 || targetX >= targetWidth || targetY >= targetHeight) continue;
const targetIndex = (targetY * targetWidth + targetX) * 4;
for (let channel = 0; channel < 3; channel += 1) {
targetTotal[channel] = (targetTotal[channel] ?? 0) + (target[targetIndex + channel] ?? 0);
generatedTotal[channel] = (generatedTotal[channel] ?? 0) + (generated[index + channel] ?? 0);
}
count += 1;
}
if (count < 16) return [0, 0, 0];
return targetTotal.map((total, channel) => Math.max(-32, Math.min(32, total / count - (generatedTotal[channel] ?? 0) / count)));
}
function require2dContext(canvas: HTMLCanvasElement): CanvasRenderingContext2D {
const context = canvas.getContext("2d");
if (!context) throw new Error("Unable to prepare generated candidate");
return context;
}

View File

@@ -0,0 +1,31 @@
import { commandIds } from "@commands/ids";
import type { GenerationJobKind } from "@editor/state";
import type { AppStore } from "@editor/store";
export async function runGenerationJob(options: {
kind: GenerationJobKind;
label: string;
dispatch: AppStore["dispatch"];
signal: AbortSignal;
task: (signal: AbortSignal, report: (progress: number, detail: string) => void) => Promise<void>;
}): Promise<void> {
const jobId = crypto.randomUUID();
const startedAt = Date.now();
const nextState = options.dispatch(commandIds.generationStartJob, { jobId, kind: options.kind, label: options.label, startedAt });
if (!nextState.editor.generation.jobs.some((job) => job.id === jobId && job.status === "running")) return;
try {
await options.task(options.signal, (progress, detail) => options.dispatch(commandIds.generationUpdateJob, { jobId, progress, detail }));
options.dispatch(commandIds.generationSucceedJob, { jobId, finishedAt: Date.now() });
} catch (reason: unknown) {
if (options.signal.aborted) {
options.dispatch(commandIds.generationCancelJob, { jobId, finishedAt: Date.now() });
return;
}
options.dispatch(commandIds.generationFailJob, {
jobId,
finishedAt: Date.now(),
error: reason instanceof Error ? reason.message : `${options.label} failed`,
});
}
}

View File

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

View File

@@ -0,0 +1,328 @@
import type { Asset } from "@core/asset";
import type { ImageDocument } from "@core/document";
import type { Rect } from "@core/geometry";
import type { AssetId, InpaintRegionId, LayerId } from "@core/id";
import type { Layer } from "@core/layer";
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, sampleDocumentCanvasInLayerSpace } from "@platform/browser/maskRaster";
import { renderArtboardCanvas } from "@platform/browser/exportArtboardPng";
export type InpaintBundle = {
inputImage: string;
sourceImage: string;
contextImage: string;
maskImage: string;
editMaskImage: string;
blendMaskImage: string;
width: number;
height: number;
targetLayerId: LayerId;
regionId: InpaintRegionId;
sourceAssetId: AssetId;
maskAssetId: AssetId;
revision: { source: string; mask: 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;
maskAsset: Asset;
regionId: InpaintRegionId;
};
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 sourceLimit = target.layer.sourceRect ?? { x: 0, y: 0, w: width, h: height };
const editMask = await createNormalizedMaskSource(target.maskAsset.source, width, height, {
polarity: "revealed",
despeckle: settings.inpaint.maskDespeckle,
limit: sourceLimit,
});
const noiseMask = await createNormalizedMaskSource(target.maskAsset.source, width, height, {
polarity: "revealed",
expand: settings.inpaint.maskExpand,
blur: settings.inpaint.maskBlur,
despeckle: settings.inpaint.maskDespeckle,
limit: sourceLimit,
});
const blendMask = await createNormalizedMaskSource(target.maskAsset.source, width, height, {
polarity: "revealed",
feather: settings.inpaint.maskFeather,
despeckle: settings.inpaint.maskDespeckle,
limit: sourceLimit,
});
if (!editMask.bounds || !noiseMask.bounds) throw new Error("Paint over the area you want AI to replace.");
const crop = settings.inpaint.maskedAreaOnly
? expandRectWithinBounds(noiseMask.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 sourceCanvas = await loadImageCanvas(target.asset.source, width, height);
const sourceImage = cropCanvas(sourceCanvas, crop, outputWidth, outputHeight);
const preparedCanvas = prepareMaskedContentInputCanvas(await loadImageCanvas(target.asset.source, width, height), editMask.values, settings.inpaint.maskedContent);
const preparedSourceImage = cropCanvas(preparedCanvas, crop, outputWidth, outputHeight);
const artboard = document.artboards.find((candidate) => candidate.id === target.artboardId);
if (!artboard) throw new Error("The target artboard no longer exists.");
const contextImage = sampleDocumentCanvasInLayerSpace(await renderArtboardCanvas(artboard, document.assets), artboard.bounds, target.layer, target.asset.intrinsicSize, crop, outputWidth, outputHeight);
const inputImage = await mergePreparedRegionIntoContext(contextImage, preparedSourceImage, cropMaskValuesToDataUrl(editMask.values, width, height, crop, outputWidth, outputHeight), outputWidth, outputHeight);
const maskImage = cropMaskValuesToDataUrl(noiseMask.values, width, height, crop, outputWidth, outputHeight);
const editMaskImage = cropMaskValuesToDataUrl(editMask.values, width, height, crop, outputWidth, outputHeight);
const blendMaskImage = cropMaskValuesToDataUrl(blendMask.values, width, height, crop, outputWidth, outputHeight);
const scaleX = target.layer.transform.scale.x;
const scaleY = target.layer.transform.scale.y;
const documentBounds = {
x: target.layer.transform.position.x + crop.x * scaleX,
y: target.layer.transform.position.y + crop.y * scaleY,
w: outputWidth * scaleX,
h: outputHeight * scaleY,
};
return {
inputImage,
sourceImage,
contextImage,
maskImage,
editMaskImage,
blendMaskImage,
width: outputWidth,
height: outputHeight,
targetLayerId: target.layer.id,
regionId: target.regionId,
sourceAssetId: target.asset.id,
maskAssetId: target.maskAsset.id,
crop: {
assetBounds: crop,
documentBounds,
padding: settings.inpaint.cropPadding,
maskedAreaOnly: settings.inpaint.maskedAreaOnly,
},
mask: {
polarity: "revealed",
activeBounds: editMask.bounds,
},
placement: {
artboardId: target.artboardId,
layerName: `${target.layer.name} inpaint`,
transform: {
position: rotatedCropPosition(target.layer, target.asset, { ...crop, w: outputWidth, h: outputHeight }),
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,
},
revision: {
source: await createContentRevision(target.asset.source),
mask: await createContentRevision(target.maskAsset.source),
},
};
}
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 !== "image" && layerInfo.layer.type !== "raster")) throw new Error("Select one image or raster layer to inpaint. Text must be rasterized first.");
const asset = documentIndex.assetById.get(layerInfo.layer.assetId);
if (!asset) throw new Error("The selected layer is missing its source image.");
const region = document.inpaintRegions.find((candidate) => candidate.targetLayerId === layerInfo.layer.id && candidate.enabled);
if (!region) throw new Error("Add an AI edit region before running inpaint.");
const maskAsset = documentIndex.assetById.get(region.maskAssetId);
if (!maskAsset) throw new Error("The AI edit region is missing its mask data.");
if (!resolveIndexedLayerBounds(documentIndex, layerInfo.layer)) throw new Error("Unable to resolve the selected layer bounds.");
return { artboardId: layerInfo.artboardId, layer: layerInfo.layer, asset, maskAsset, regionId: region.id };
}
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.");
}
}
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));
}
async function mergePreparedRegionIntoContext(contextSource: string, preparedSource: string, editMaskSource: string, width: number, height: number): Promise<string> {
const [contextCanvas, preparedCanvas, maskCanvas] = await Promise.all([
loadImageCanvas(contextSource, width, height),
loadImageCanvas(preparedSource, width, height),
loadImageCanvas(editMaskSource, width, height),
]);
const context = contextCanvas.getContext("2d");
const prepared = preparedCanvas.getContext("2d");
const mask = maskCanvas.getContext("2d");
if (!context || !prepared || !mask) return contextSource;
const contextData = context.getImageData(0, 0, width, height);
const preparedData = prepared.getImageData(0, 0, width, height);
const maskData = mask.getImageData(0, 0, width, height);
for (let pixel = 0; pixel < width * height; pixel += 1) {
const amount = (maskData.data[pixel * 4] ?? 0) / 255;
if (amount <= 0) continue;
for (let channel = 0; channel < 4; channel += 1) {
const index = pixel * 4 + channel;
contextData.data[index] = blendChannel(contextData.data[index] ?? 0, preparedData.data[index] ?? 0, amount);
}
}
context.putImageData(contextData, 0, 0);
return contextCanvas.toDataURL("image/png");
}
function rotatedCropPosition(layer: InpaintTarget["layer"], asset: Asset, crop: Rect) {
const source = layer.sourceRect ?? { x: 0, y: 0, ...asset.intrinsicSize };
const scale = layer.transform.scale;
const originalCenter = {
x: layer.transform.position.x + (source.x + source.w / 2) * scale.x,
y: layer.transform.position.y + (source.y + source.h / 2) * scale.y,
};
const cropCenter = {
x: layer.transform.position.x + (crop.x + crop.w / 2) * scale.x,
y: layer.transform.position.y + (crop.y + crop.h / 2) * scale.y,
};
const dx = cropCenter.x - originalCenter.x;
const dy = cropCenter.y - originalCenter.y;
const cos = Math.cos(layer.transform.rotation);
const sin = Math.sin(layer.transform.rotation);
const rotatedCenter = { x: originalCenter.x + dx * cos - dy * sin, y: originalCenter.y + dx * sin + dy * cos };
return { x: rotatedCenter.x - crop.w * scale.x / 2, y: rotatedCenter.y - crop.h * scale.y / 2 };
}
export async function createContentRevision(source: string): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(source));
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
}

View File

@@ -0,0 +1,16 @@
import { commandIds } from "@commands/ids";
import type { AppStore } from "@editor/store";
import { fetchGenerationOptions } from "@platform/comfy/generationClient";
import type { GenerationOptions } from "@editor/state";
export async function loadGenerationResources(store: AppStore): Promise<void> {
const current = store.getState().editor.generation.resources.status;
if (current === "loading" || current === "ready") return;
store.dispatch(commandIds.generationLoadResources, undefined);
try {
const options = await fetchGenerationOptions() as GenerationOptions;
store.dispatch(commandIds.generationSetResources, { options });
} catch (reason: unknown) {
store.dispatch(commandIds.generationFailResources, { error: reason instanceof Error ? reason.message : "Unable to load ComfyUI models" });
}
}

View File

@@ -0,0 +1,23 @@
import { describe, expect, test } from "bun:test";
import { initialToolState } from "@editor/tools";
import { resolveGenerationModeOptions, resolveGenerationModelOptions } from "./options";
describe("generation compatibility options", () => {
test("uses backend compatibility data for modes", () => {
const settings = { ...initialToolState.generate, architecture: "anima" as const, mode: "inpaint" as const };
const modes = [
{ value: "text-to-image" as const, label: "Text" },
{ value: "inpaint" as const, label: "Inpaint" },
];
const options = { architectures: [{ value: "anima" as const, label: "Anima", models: [], defaultModel: "auto", supportedModes: ["text-to-image" as const] }], models: [], samplers: [], schedulers: [], textEncoders: [], vaes: [] };
expect(resolveGenerationModeOptions(settings, options, modes).map((option) => option.value)).toEqual(["inpaint", "text-to-image"]);
});
test("keeps the current model alongside discovered models", () => {
const settings = { ...initialToolState.generate, model: "current.safetensors" };
const options = { architectures: [], models: ["found.safetensors"], samplers: [], schedulers: [], textEncoders: [], vaes: [] };
expect(resolveGenerationModelOptions(settings, options).map((option) => option.value)).toEqual(["auto", "found.safetensors", "current.safetensors"]);
});
});

View File

@@ -0,0 +1,41 @@
import type { GenerationOptions } from "@editor/state";
import { generateArchitectureDefaults } from "@editor/tools";
import type { GenerateMode, GenerateModel, GenerateSettings } from "@editor/tools";
export type GenerationSelectOption<TValue extends string> = { value: TValue; label: string };
export function resolveGenerationModelOptions(settings: GenerateSettings, options: GenerationOptions | undefined): readonly GenerationSelectOption<GenerateModel>[] {
const architecture = options?.architectures?.find((item) => item.value === settings.architecture);
const models = architecture?.models ?? (settings.architecture === "sdxl" ? options?.models : undefined) ?? [];
const fallbackModel = architecture?.defaultModel ?? generateArchitectureDefaults[settings.architecture].model;
const values = unique(["auto", ...models, ...(models.length === 0 && fallbackModel !== "auto" ? [fallbackModel] : []), settings.model]);
return values.map((model) => ({ value: model, label: model === "auto" ? "Auto" : model }));
}
export function resolveGenerationSupportOptions(settings: GenerateSettings, options: GenerationOptions | undefined) {
const defaults = generateArchitectureDefaults[settings.architecture];
return {
textEncoders: resolveGenerationStringOptions([...(options?.textEncoders ?? []), defaults.textEncoder].filter((value) => value !== "auto"), settings.textEncoder),
vaes: resolveGenerationStringOptions([...(options?.vaes ?? []), defaults.vae].filter((value) => value !== "auto"), settings.vae),
};
}
export function resolveGenerationStringOptions(values: string[] | undefined, current: string): readonly GenerationSelectOption<string>[] {
return unique([...(values ?? []), current]).map((value) => ({ value, label: value }));
}
export function resolveGenerationModeOptions(
settings: GenerateSettings,
options: GenerationOptions | undefined,
modes: readonly GenerationSelectOption<GenerateMode>[],
): readonly GenerationSelectOption<GenerateMode>[] {
const architecture = options?.architectures?.find((item) => item.value === settings.architecture);
const supportedModes = architecture?.supportedModes?.length ? architecture.supportedModes : generateArchitectureDefaults[settings.architecture].supportedModes;
const availableModes = modes.filter((mode) => supportedModes.includes(mode.value));
if (availableModes.some((mode) => mode.value === settings.mode)) return availableModes;
return [modes.find((mode) => mode.value === settings.mode), ...availableModes].filter((mode): mode is GenerationSelectOption<GenerateMode> => Boolean(mode));
}
function unique<T>(values: T[]): T[] {
return Array.from(new Set(values));
}

View File

@@ -0,0 +1,94 @@
import { describe, expect, test } from "bun:test";
import type { ImageDocument } from "@core/document";
import type { GenerateSettings } from "@editor/tools";
import { createInitialAppState } from "@editor/initial-state";
import { resolveGeneratedOutputPlacement } from "./outputPlacement";
describe("generated output placement", () => {
test("centers text-to-image output in the active artboard", () => {
const placement = resolveGeneratedOutputPlacement({
document: document(),
selection: { artboardId: "artboard", layerIds: [] },
settings: settings("text-to-image"),
intrinsicSize: { w: 400, h: 200 },
});
expect(placement).toEqual({
artboardId: "artboard",
layerName: "Generated image",
transform: { position: { x: 310, y: 270 }, scale: { x: 1, y: 1 }, rotation: 0 },
});
});
test("matches image-to-image output to the selected layer bounds", () => {
const placement = resolveGeneratedOutputPlacement({
document: document(),
selection: selected(),
settings: settings("image-to-image"),
intrinsicSize: { w: 200, h: 250 },
});
expect(placement.transform).toEqual({
position: { x: 80, y: 90 },
scale: { x: 0.75, y: 1 },
rotation: 12,
});
});
test("places an inpaint crop at its recorded document bounds", () => {
const placement = resolveGeneratedOutputPlacement({
document: document(),
selection: selected(),
settings: settings("inpaint"),
intrinsicSize: { w: 128, h: 64 },
inpaintBundle: {
inputImage: "input", sourceImage: "source", contextImage: "context", maskImage: "mask", editMaskImage: "edit", blendMaskImage: "blend", width: 256, height: 128,
targetLayerId: "source", regionId: "region", sourceAssetId: "source-asset", maskAssetId: "mask-asset", revision: { source: "source-revision", mask: "mask-revision" },
crop: { assetBounds: { x: 0, y: 0, w: 256, h: 128 }, documentBounds: { x: 140, y: 150, w: 384, h: 64 }, padding: 16, maskedAreaOnly: true },
mask: { polarity: "hidden", activeBounds: { x: 20, y: 20, w: 40, h: 40 } },
placement: { artboardId: "artboard", layerName: "Source inpaint", transform: { position: { x: 140, y: 150 }, scale: { x: 1.5, y: 0.5 }, rotation: 12 } },
backend: { growMaskBy: 6, maskedContent: "neutral", maskBlur: 0, maskFeather: 0, maskExpand: 0, cropPadding: 16 },
},
});
expect(placement.transform).toEqual({ position: { x: 140, y: 150 }, scale: { x: 3, y: 1 }, rotation: 12 });
});
test("extends outpaint output left and top while preserving source pixel scale", () => {
const outpaint = { ...settings("outpaint"), outpaint: { left: 40, top: 20, right: 10, bottom: 30, feathering: 8 } };
const placement = resolveGeneratedOutputPlacement({
document: document(),
selection: selected(),
settings: outpaint,
intrinsicSize: { w: 150, h: 550 },
});
expect(placement.transform).toEqual({
position: { x: 20, y: 80 },
scale: { x: 1.5, y: 0.5 },
rotation: 12,
});
});
});
function settings(mode: GenerateSettings["mode"]): GenerateSettings {
return { ...createInitialAppState("Test").editor.tools.generate, mode };
}
function selected() {
return { artboardId: "artboard", layerIds: ["source"] };
}
function document(): ImageDocument {
return {
id: "document", name: "Test", version: 1, inpaintRegions: [],
assets: [
{ id: "source-asset", name: "Source", mimeType: "image/png", source: "source", intrinsicSize: { w: 100, h: 500 } },
{ id: "mask-asset", name: "Mask", mimeType: "image/png", source: "mask", intrinsicSize: { w: 100, h: 500 } },
],
artboards: [{
id: "artboard", name: "Artboard", bounds: { x: 10, y: 20, w: 1000, h: 700 }, backgroundColor: "transparent", visible: true, locked: false,
layers: [{ id: "source", type: "raster", name: "Source", visible: true, locked: false, opacity: 1, assetId: "source-asset", transform: { position: { x: 80, y: 90 }, scale: { x: 1.5, y: 0.5 }, rotation: 12 } }],
}],
};
}

View File

@@ -0,0 +1,105 @@
import type { ImageDocument } from "@core/document";
import type { Size, Transform } from "@core/geometry";
import type { ArtboardId } from "@core/id";
import { createDocumentReadIndex } from "@editor/document-indexes";
import type { GenerationCandidate, SelectionState } from "@editor/state";
import type { GenerateSettings } from "@editor/tools";
import type { InpaintBundle } from "./inpaintPrep";
export type GeneratedOutputPlacement = GenerationCandidate["placement"];
export function resolveGeneratedOutputPlacement(options: {
document: ImageDocument;
selection: SelectionState;
settings: GenerateSettings;
intrinsicSize: Size;
inpaintBundle?: InpaintBundle;
}): GeneratedOutputPlacement {
const { document, selection, settings, intrinsicSize, inpaintBundle } = options;
if (inpaintBundle) {
return {
artboardId: inpaintBundle.placement.artboardId,
layerName: inpaintBundle.placement.layerName,
transform: scaleInpaintPlacement(inpaintBundle, intrinsicSize),
};
}
const artboard = selection.artboardId
? document.artboards.find((candidate) => candidate.id === selection.artboardId)
: document.artboards[0];
if (!artboard) throw new Error("Create an artboard before placing generated output.");
if (settings.mode === "text-to-image") {
return {
artboardId: artboard.id,
layerName: "Generated image",
transform: {
position: {
x: artboard.bounds.x + (artboard.bounds.w - intrinsicSize.w) / 2,
y: artboard.bounds.y + (artboard.bounds.h - intrinsicSize.h) / 2,
},
scale: { x: 1, y: 1 },
rotation: 0,
},
};
}
const target = resolveTarget(document, selection, artboard.id);
const sourceScale = {
x: target.layer.transform.scale.x * target.asset.intrinsicSize.w / Math.max(1, intrinsicSize.w),
y: target.layer.transform.scale.y * target.asset.intrinsicSize.h / Math.max(1, intrinsicSize.h),
};
if (settings.mode === "image-to-image") {
return {
artboardId: artboard.id,
layerName: "Generated image",
transform: {
position: { ...target.layer.transform.position },
scale: sourceScale,
rotation: target.layer.transform.rotation,
},
};
}
if (settings.mode === "outpaint") {
const pixelScale = target.layer.transform.scale;
return {
artboardId: artboard.id,
layerName: "Generated image",
transform: {
position: {
x: target.layer.transform.position.x - settings.outpaint.left * pixelScale.x,
y: target.layer.transform.position.y - settings.outpaint.top * pixelScale.y,
},
scale: { ...pixelScale },
rotation: target.layer.transform.rotation,
},
};
}
throw new Error("Inpaint output requires an inpaint placement bundle.");
}
function resolveTarget(document: ImageDocument, selection: SelectionState, artboardId: ArtboardId) {
const layerId = selection.layerIds[0];
const index = createDocumentReadIndex(document);
const layerInfo = layerId ? index.layerInfoById.get(layerId) : undefined;
if (!layerInfo || layerInfo.artboardId !== artboardId || (layerInfo.layer.type !== "image" && layerInfo.layer.type !== "raster")) {
throw new Error("Select one image or raster layer before placing generated output.");
}
const asset = index.assetById.get(layerInfo.layer.assetId);
if (!asset) throw new Error("The selected layer is missing its source image.");
return { layer: layerInfo.layer, asset };
}
function scaleInpaintPlacement(bundle: InpaintBundle, intrinsicSize: Size): Transform {
return {
position: { ...bundle.placement.transform.position },
scale: {
x: bundle.placement.transform.scale.x * bundle.width / Math.max(1, intrinsicSize.w),
y: bundle.placement.transform.scale.y * bundle.height / Math.max(1, intrinsicSize.h),
},
rotation: bundle.placement.transform.rotation,
};
}

View File

@@ -0,0 +1,72 @@
import { describe, expect, test } from "bun:test";
import type { ImageDocument } from "@core/document";
import { initialToolState } from "@editor/tools";
import { checkGenerationPreconditions } from "./preconditions";
describe("generation preconditions", () => {
test("allows text-to-image with a prompt and artboard but no layer selection", () => {
expect(checkGenerationPreconditions(document(), { artboardId: "artboard", layerIds: [] }, settings("text-to-image"))).toEqual({ ready: true });
});
test("requires exactly one source layer for image-to-image and outpaint", () => {
const selection = { artboardId: "artboard", layerIds: [] };
expect(checkGenerationPreconditions(document(), selection, settings("image-to-image"))).toEqual({ ready: false, message: "Select exactly one image or raster layer for image-to-image generation." });
expect(checkGenerationPreconditions(document(), selection, settings("outpaint"))).toEqual({ ready: false, message: "Select exactly one image or raster layer to outpaint." });
});
test("requires an enabled mask for inpaint", () => {
expect(checkGenerationPreconditions(document(), selected(), settings("inpaint"))).toEqual({ ready: false, message: "Paint an AI edit region over the area you want to replace.", repair: "add-mask" });
});
test("allows inpaint when the selected source has an aligned enabled mask", () => {
expect(checkGenerationPreconditions(document(true), selected(), settings("inpaint"))).toEqual({ ready: true });
});
test("explains prompt and outpaint-padding requirements", () => {
expect(checkGenerationPreconditions(document(), selected(), { ...settings("image-to-image"), prompt: " " })).toEqual({ ready: false, message: "Enter a prompt to generate an image." });
expect(checkGenerationPreconditions(document(), selected(), { ...settings("outpaint"), outpaint: { left: 0, top: 0, right: 0, bottom: 0, feathering: 0 } })).toEqual({ ready: false, message: "Add outpaint padding on at least one side.", repair: "set-outpaint-padding" });
});
});
function settings(mode: "text-to-image" | "image-to-image" | "inpaint" | "outpaint") {
return { ...initialToolState.generate, mode, prompt: "A lighthouse" };
}
function selected() {
return { artboardId: "artboard" as const, layerIds: ["source"] };
}
function document(masked = false): ImageDocument {
const transform = { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 };
return {
id: "document",
name: "Test",
version: 1,
assets: [
{ id: "source-asset", name: "Source", mimeType: "image/png", source: "source", intrinsicSize: { w: 100, h: 100 } },
{ id: "mask-asset", name: "Mask", mimeType: "image/png", source: "mask", intrinsicSize: { w: 100, h: 100 } },
],
inpaintRegions: masked ? [{ id: "region", name: "AI edit", targetLayerId: "source", maskAssetId: "mask-asset", enabled: true }] : [],
artboards: [{
id: "artboard",
name: "Artboard",
bounds: { x: 0, y: 0, w: 100, h: 100 },
backgroundColor: "transparent",
visible: true,
locked: false,
layers: [
{ id: "mask", type: "raster", name: "Mask", visible: true, locked: false, opacity: 1, assetId: "mask-asset", transform },
{
id: "source",
type: "raster",
name: "Source",
visible: true,
locked: false,
opacity: 1,
assetId: "source-asset",
transform,
},
],
}],
};
}

View File

@@ -0,0 +1,67 @@
import type { ImageDocument } from "@core/document";
import { createDocumentReadIndex, resolveIndexedLayerBounds } from "@editor/document-indexes";
import type { SelectionState } from "@editor/state";
import type { GenerateSettings } from "@editor/tools";
export type GenerationPrecondition =
| { ready: true }
| { ready: false; message: string; repair?: "add-mask" | "set-outpaint-padding" };
export function checkGenerationPreconditions(
document: ImageDocument,
selection: SelectionState,
settings: GenerateSettings,
): GenerationPrecondition {
if (!settings.prompt.trim()) return missing("Enter a prompt to generate an image.");
const artboard = selection.artboardId
? document.artboards.find((candidate) => candidate.id === selection.artboardId)
: document.artboards[0];
if (!artboard) return missing("Create an artboard before generating an image.");
if (settings.mode === "text-to-image") return { ready: true };
if (selection.layerIds.length !== 1 || !selection.layerIds[0]) {
return missing(modeSelectionMessage(settings.mode));
}
const index = createDocumentReadIndex(document);
const layerInfo = index.layerInfoById.get(selection.layerIds[0]);
if (!layerInfo || layerInfo.artboardId !== artboard.id || (layerInfo.layer.type !== "image" && layerInfo.layer.type !== "raster")) {
return missing(modeSelectionMessage(settings.mode));
}
const asset = index.assetById.get(layerInfo.layer.assetId);
if (!asset) return missing("The selected layer is missing its source image.");
if (asset.intrinsicSize.w <= 0 || asset.intrinsicSize.h <= 0) return missing("The selected image has an invalid size.");
if (settings.mode === "outpaint") {
const padding = settings.outpaint;
if (padding.left + padding.top + padding.right + padding.bottom <= 0) {
return missing("Add outpaint padding on at least one side.", "set-outpaint-padding");
}
}
if (settings.mode !== "inpaint") return { ready: true };
const region = document.inpaintRegions.find((candidate) => candidate.targetLayerId === layerInfo.layer.id && candidate.enabled);
if (!region) return missing("Paint an AI edit region over the area you want to replace.", "add-mask");
const maskAsset = index.assetById.get(region.maskAssetId);
if (!maskAsset) return missing("The AI edit region is missing its mask data.");
if (Math.round(asset.intrinsicSize.w) !== Math.round(maskAsset.intrinsicSize.w) || Math.round(asset.intrinsicSize.h) !== Math.round(maskAsset.intrinsicSize.h)) {
return missing("The selected layer and mask image sizes must match.");
}
if (!resolveIndexedLayerBounds(index, layerInfo.layer)) return missing("The selected layer has invalid geometry.");
return { ready: true };
}
function modeSelectionMessage(mode: GenerateSettings["mode"]): string {
if (mode === "image-to-image") return "Select exactly one image or raster layer for image-to-image generation.";
if (mode === "inpaint") return "Select exactly one masked image or raster layer to inpaint.";
return "Select exactly one image or raster layer to outpaint.";
}
function missing(message: string, repair?: Extract<GenerationPrecondition, { ready: false }>["repair"]): GenerationPrecondition {
return { ready: false, message, ...(repair ? { repair } : {}) };
}

View File

@@ -0,0 +1,250 @@
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 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";
import { imageSourceToDataUrl, loadImageSize } from "@platform/browser/imageRaster";
import { requestGeneration } from "@platform/comfy/generationClient";
import { checkGenerationPreconditions } from "./preconditions";
import { resolveGeneratedOutputPlacement } from "./outputPlacement";
export async function runGenerate(options: {
document: ImageDocument;
selection: SelectionState;
viewport: ViewportState;
settings: GenerateSettings;
dispatch: AppStore["dispatch"];
signal?: AbortSignal;
onProgress?: (progress: number, detail: string) => void;
}) {
const { document, selection, settings, dispatch } = options;
const precondition = checkGenerationPreconditions(document, selection, settings);
if (!precondition.ready) throw new Error(precondition.message);
const artboard = selection.artboardId ? document.artboards.find((candidate) => candidate.id === selection.artboardId) : document.artboards[0];
if (!artboard) throw new Error("Create an artboard before generating an image.");
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,
signal: options.signal,
onProgress: options.onProgress,
});
for (const result of generated.results) {
const intrinsicSize = await loadImageSize(result.source);
const placement = resolveGeneratedOutputPlacement({ document, selection, settings, intrinsicSize, inpaintBundle });
dispatch(commandIds.generationAddCandidate, {
candidate: createGenerationCandidate({
source: result.source,
mimeType: result.mimeType,
intrinsicSize,
settings: requestSettings,
seed: result.seed || seed,
width,
height,
inputImage,
maskImage,
placement,
inpaintBundle,
}),
});
}
}
export async function runGenerateFromCandidate(options: {
candidate: GenerationCandidate;
settings?: GenerateSettings;
dispatch: AppStore["dispatch"];
signal?: AbortSignal;
onProgress?: (progress: number, detail: string) => void;
}) {
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,
signal: options.signal,
onProgress: options.onProgress,
});
for (const result of generated.results) {
const intrinsicSize = await loadImageSize(result.source);
options.dispatch(commandIds.generationAddCandidate, {
candidate: {
...options.candidate,
id: crypto.randomUUID(),
source: result.source,
mimeType: result.mimeType,
intrinsicSize,
settings: requestSettings,
seed: result.seed || 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,
blendMaskImage: options.inpaintBundle?.blendMaskImage,
placement: options.placement,
inpaint: options.inpaintBundle
? {
targetLayerId: options.inpaintBundle.targetLayerId,
regionId: options.inpaintBundle.regionId,
sourceAssetId: options.inpaintBundle.sourceAssetId,
maskAssetId: options.inpaintBundle.maskAssetId,
inputImage: options.inpaintBundle.inputImage,
maskImage: options.inpaintBundle.maskImage,
editMaskImage: options.inpaintBundle.editMaskImage,
blendMaskImage: options.inpaintBundle.blendMaskImage,
revision: options.inpaintBundle.revision,
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;
signal?: AbortSignal;
onProgress?: (progress: number, detail: string) => void;
}) {
return requestGeneration({
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,
batchSize: options.settings.batchSize,
refinePass: options.settings.refinePass,
refineStrength: options.settings.refineStrength,
outpaint: options.settings.outpaint,
inpaint: resolveInpaintRequest(options.inpaintBundle, options.inpaintCandidate, options.settings),
inputImage: options.inputImage,
maskImage: options.maskImage,
}, options.signal, options.onProgress);
}
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,
structureControl: settings.inpaint.structureControl,
controlStrength: settings.inpaint.controlStrength,
controlModel: settings.inpaint.controlModel,
};
}
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,
structureControl: settings.inpaint.structureControl,
controlStrength: settings.inpaint.controlStrength,
controlModel: settings.inpaint.controlModel,
};
}
return settings.inpaint;
}
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 !== "image" && layer.type !== "raster")) 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 === "image" || maskLayer.type === "raster") ? 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;
}

View File

@@ -0,0 +1,125 @@
import { describe, expect, test } from "bun:test";
import { documentCommands } from "@commands/document";
import { inpaintRegionCommands } from "@commands/inpaint-region";
import { generationCommands } from "@commands/generation";
import { commandIds } from "@commands/ids";
import { createCommandRegistry } from "@commands/registry";
import { toolCommands } from "@commands/tool";
import { createInitialAppState } from "@editor/initial-state";
import { createAppStore } from "@editor/store";
import { initialToolState } from "@editor/tools";
import type { GenerationCandidate } from "@editor/state";
import { createGenerationWorkflow, type GenerationWorkflowDependencies } from "./workflow";
describe("generation workflow", () => {
test("reads canonical application state when generation starts", async () => {
const app = createTestApp();
let prompt = "";
const workflow = createGenerationWorkflow(app.store, dependencies({
runGenerate: async (options) => { prompt = options.settings.prompt; },
}));
app.store.dispatch(commandIds.toolSetGenerateSettings, { prompt: "Latest prompt" });
await workflow.generate();
expect(prompt).toBe("Latest prompt");
expect(app.store.getState().editor.generation.jobs[0]?.status).toBe("succeeded");
});
test("uses one acceptance path to add a candidate as a layer", () => {
const app = createTestApp();
const artboardId = app.store.getState().document.artboards[0]?.id;
if (!artboardId) throw new Error("Expected default artboard");
app.store.dispatch(commandIds.generationAddCandidate, { candidate: candidate(artboardId) });
const ids = ["asset-new", "layer-new"];
const workflow = createGenerationWorkflow(app.store, dependencies({ createId: () => ids.shift() ?? "unused" }));
workflow.applyCandidateAsLayer("candidate");
expect(app.store.getState().document.assets.some((asset) => asset.id === "asset-new")).toBe(true);
expect(app.store.getState().document.artboards[0]?.layers.some((layer) => layer.id === "layer-new")).toBe(true);
expect(app.store.getState().editor.generation.candidates).toHaveLength(0);
});
test("repairs replace prerequisites by adding an aligned editable mask through commands", async () => {
const app = createTestApp();
const state = app.store.getState();
state.document.assets.push({ id: "source-asset", name: "Source", mimeType: "image/png", source: "source", intrinsicSize: { w: 80, h: 60 } });
state.document.artboards[0]!.layers.push({ id: "source", type: "raster", name: "Source", visible: true, locked: false, opacity: 1, assetId: "source-asset", transform: { position: { x: 4, y: 8 }, scale: { x: 2, y: 2 }, rotation: 0 } });
state.editor.selection = { artboardId: "artboard", layerIds: ["source"] };
const ids = ["mask-asset", "region"];
const workflow = createGenerationWorkflow(app.store, dependencies({ createId: () => ids.shift() ?? "unused" }));
await workflow.prepareInpaintMask();
const next = app.store.getState();
expect(next.document.inpaintRegions).toContainEqual({ id: "region", name: "Source AI edit", targetLayerId: "source", maskAssetId: "mask-asset", enabled: true });
expect(next.editor.maskEdit).toEqual({ kind: "inpaintRegion", targetLayerId: "source", inpaintRegionId: "region", maskAssetId: "mask-asset", viewMode: "overlay" });
});
test("cancels the active operation and records a cancelled job", async () => {
const app = createTestApp();
let receivedSignal: AbortSignal | undefined;
const workflow = createGenerationWorkflow(app.store, dependencies({
runGenerate: async (options) => {
receivedSignal = options.signal;
await new Promise<void>((_resolve, reject) => options.signal?.addEventListener("abort", () => reject(new DOMException("Cancelled", "AbortError")), { once: true }));
},
}));
const running = workflow.generate();
await Promise.resolve();
workflow.cancel();
await running;
expect(receivedSignal?.aborted).toBe(true);
expect(app.store.getState().editor.generation.jobs[0]?.status).toBe("cancelled");
});
});
function dependencies(overrides: Partial<GenerationWorkflowDependencies>): GenerationWorkflowDependencies {
return {
runGenerate: async () => undefined,
runGenerateFromCandidate: async () => undefined,
createMaskedPixelReplacementSource: async () => "replacement",
createRefinementMask: async () => "mask",
createInpaintRegionMask: async () => "mask",
loadGenerationResources: async () => undefined,
createId: () => crypto.randomUUID(),
...overrides,
};
}
function candidate(artboardId: string): GenerationCandidate {
return {
id: "candidate",
source: "generated",
mimeType: "image/png",
intrinsicSize: { w: 64, h: 64 },
mode: "text-to-image",
settings: initialToolState.generate,
seed: 1,
width: 64,
height: 64,
placement: {
artboardId,
layerName: "Generated",
transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 },
},
};
}
function createTestApp() {
const state = createInitialAppState("Test");
state.document.artboards.push({
id: "artboard",
name: "Artboard",
bounds: { x: 0, y: 0, w: 100, h: 100 },
backgroundColor: "transparent",
visible: true,
locked: false,
layers: [],
});
const registry = createCommandRegistry([...documentCommands, ...inpaintRegionCommands, ...toolCommands, ...generationCommands]);
return { store: createAppStore(state, registry) };
}

View File

@@ -0,0 +1,194 @@
import { commandIds } from "@commands/ids";
import type { Layer } from "@core/layer";
import type { GenerationCandidate, GenerationJobKind } from "@editor/state";
import type { AppStore } from "@editor/store";
import type { GenerateSettings } from "@editor/tools";
import { createInpaintRegionMask, createRefinementMask } from "@operations/masks/rasterActions";
import { createMaskedPixelReplacementSource } from "./candidateActions";
import { runGenerationJob } from "./generationJob";
import { loadGenerationResources } from "./loadResources";
import { runGenerate, runGenerateFromCandidate } from "./runGenerate";
import { checkGenerationPreconditions } from "./preconditions";
export type GenerationWorkflow = ReturnType<typeof createGenerationWorkflow>;
export type GenerationWorkflowDependencies = {
runGenerate: typeof runGenerate;
runGenerateFromCandidate: typeof runGenerateFromCandidate;
createMaskedPixelReplacementSource: typeof createMaskedPixelReplacementSource;
createRefinementMask: typeof createRefinementMask;
createInpaintRegionMask: typeof createInpaintRegionMask;
loadGenerationResources: typeof loadGenerationResources;
createId(): string;
};
const defaultDependencies: GenerationWorkflowDependencies = {
runGenerate,
runGenerateFromCandidate,
createMaskedPixelReplacementSource,
createRefinementMask,
createInpaintRegionMask,
loadGenerationResources,
createId: () => crypto.randomUUID(),
};
export function createGenerationWorkflow(store: AppStore, dependencies: GenerationWorkflowDependencies = defaultDependencies) {
let activeController: AbortController | undefined;
const job = async (kind: GenerationJobKind, label: string, task: (signal: AbortSignal, report: (progress: number, detail: string) => void) => Promise<void>) => {
if (store.getState().editor.generation.jobs.some((candidate) => candidate.status === "running")) return;
const controller = new AbortController();
activeController = controller;
try {
await runGenerationJob({ kind, label, dispatch: store.dispatch, signal: controller.signal, task });
} finally {
if (activeController === controller) activeController = undefined;
}
};
return {
precondition: () => {
const state = store.getState();
return checkGenerationPreconditions(state.document, state.editor.selection, state.editor.tools.generate);
},
loadResources: () => dependencies.loadGenerationResources(store),
prepareInpaintMask: async () => {
const state = store.getState();
const layerId = state.editor.selection.layerIds.length === 1 ? state.editor.selection.layerIds[0] : undefined;
if (!layerId) return;
const artboard = state.document.artboards.find((candidate) => candidate.id === state.editor.selection.artboardId);
const layer = artboard ? findLayer(artboard.layers, layerId) : undefined;
if (!layer || (layer.type !== "image" && layer.type !== "raster")) return;
const asset = state.document.assets.find((candidate) => candidate.id === layer.assetId);
if (!asset) return;
const existing = state.document.inpaintRegions.find((region) => region.targetLayerId === layerId && region.enabled);
if (existing) {
store.dispatch(commandIds.toolEnterInpaintRegionEdit, { targetLayerId: layerId, regionId: existing.id });
return;
}
const source = await dependencies.createInpaintRegionMask(asset.intrinsicSize.w, asset.intrinsicSize.h);
const maskAssetId = dependencies.createId();
const regionId = dependencies.createId();
store.dispatch(commandIds.documentAddInpaintRegion, {
region: { id: regionId, name: `${layer.name} AI edit`, targetLayerId: layer.id, maskAssetId, enabled: true },
maskAsset: { id: maskAssetId, name: `${layer.name} AI edit mask`, mimeType: "image/png", source, intrinsicSize: { ...asset.intrinsicSize } },
});
store.dispatch(commandIds.toolEnterInpaintRegionEdit, { targetLayerId: layer.id, regionId });
},
generate: () => job("generate", "Generating", async (signal, report) => {
const state = store.getState();
await dependencies.runGenerate({
document: state.document,
selection: state.editor.selection,
viewport: state.editor.viewport,
settings: state.editor.tools.generate,
dispatch: store.dispatch,
signal,
onProgress: report,
});
}),
regenerate: (candidateId: string, settings?: GenerateSettings, label = "Regenerate") =>
job("regenerate", label, async (signal, report) => {
const candidate = findCandidate(store, candidateId);
const nextSettings = settings ?? candidate.settings;
store.dispatch(commandIds.toolSetGenerateSettings, nextSettings);
await dependencies.runGenerateFromCandidate({ candidate, settings: nextSettings, dispatch: store.dispatch, signal, onProgress: report });
}),
rebuildFromCurrentRegion: (candidateId: string) =>
job("regenerate", "Rebuilding from current edit region", async (signal, report) => {
const candidate = findCandidate(store, candidateId);
if (!candidate.inpaint) throw new Error("Only inpaint candidates can rebuild from an edit region.");
store.dispatch(commandIds.selectionSet, { artboardId: candidate.placement.artboardId, layerIds: [candidate.inpaint.targetLayerId] });
store.dispatch(commandIds.toolSetGenerateSettings, candidate.settings);
const state = store.getState();
await dependencies.runGenerate({
document: state.document,
selection: state.editor.selection,
viewport: state.editor.viewport,
settings: candidate.settings,
dispatch: store.dispatch,
signal,
onProgress: report,
});
}),
applyCandidateAsLayer: (candidateId: string) => {
store.dispatch(commandIds.generationApplyCandidateAsLayer, {
candidateId,
assetId: dependencies.createId(),
layerId: dependencies.createId(),
});
},
applyCandidateAsRefinementLayer: (candidateId: string) =>
job("refine", "Adding refinement mask", async () => {
const candidate = findCandidate(store, candidateId);
const layerId = dependencies.createId();
const maskAssetId = dependencies.createId();
const width = Math.max(1, Math.round(candidate.intrinsicSize.w));
const height = Math.max(1, Math.round(candidate.intrinsicSize.h));
const source = await dependencies.createRefinementMask(width, height);
store.dispatch(commandIds.generationApplyCandidateAsLayer, {
candidateId,
assetId: dependencies.createId(),
layerId,
});
store.dispatch(commandIds.documentAddLayerMask, {
layerId,
asset: {
id: maskAssetId,
name: `${candidate.placement.layerName} refinement mask`,
mimeType: "image/png",
source,
intrinsicSize: { w: width, h: height },
},
maskLayer: {
id: dependencies.createId(),
type: "raster",
name: `${candidate.placement.layerName} refinement mask`,
visible: true,
locked: false,
opacity: 1,
assetId: maskAssetId,
transform: {
position: { ...candidate.placement.transform.position },
scale: { ...candidate.placement.transform.scale },
rotation: candidate.placement.transform.rotation,
},
},
});
store.dispatch(commandIds.toolSetActive, { tool: "eraser" });
}),
replaceCandidatePixels: (candidateId: string) =>
job("replace", "Replacing pixels", async () => {
const candidate = findCandidate(store, candidateId);
const source = await dependencies.createMaskedPixelReplacementSource(store.getState().document, candidate);
store.dispatch(commandIds.generationReplaceCandidatePixels, { candidateId, source, mimeType: "image/png" });
}),
cancel: () => activeController?.abort(),
};
}
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 findCandidate(store: AppStore, candidateId: string): GenerationCandidate {
const candidate = store.getState().editor.generation.candidates.find((item) => item.id === candidateId);
if (!candidate) throw new Error("This generation candidate is no longer available.");
return candidate;
}

View File

@@ -0,0 +1,34 @@
import { commandIds } from "@commands/ids";
import type { AppStore } from "@editor/store";
import type { BrowserImageFile } from "@platform/browser/imageFiles";
export function importImageAsLayer(store: AppStore, image: BrowserImageFile): boolean {
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) {
image.release();
return false;
}
const assetId = crypto.randomUUID();
const layerId = crypto.randomUUID();
const center = state.editor.viewport.center;
store.dispatch(commandIds.documentAddAsset, { asset: { id: assetId, name: image.name, mimeType: image.mimeType, source: image.source, intrinsicSize: image.intrinsicSize } });
store.dispatch(commandIds.documentAddImageLayer, {
artboardId: artboard.id,
layer: {
id: layerId,
type: "image",
name: image.name,
visible: true,
locked: false,
opacity: 1,
assetId,
transform: { position: { x: center.x - image.intrinsicSize.w / 2, y: center.y - image.intrinsicSize.h / 2 }, scale: { x: 1, y: 1 }, rotation: 0 },
},
});
store.dispatch(commandIds.selectionSet, { artboardId: artboard.id, layerIds: [layerId] });
return true;
}

View File

@@ -0,0 +1,52 @@
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 { SelectionState } from "@editor/state";
import type { AppStore } from "@editor/store";
import type { ChromaKeySettings } from "@editor/tools";
import { createChromaKeyMask, createChromaKeyPreview } from "@platform/browser/chromaKey";
export function previewChromaKey(source: string, width: number, height: number, settings: ChromaKeySettings) { return createChromaKeyPreview(source, width, height, settings); }
export 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 !== "image" && layer.type !== "raster")) 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 === "image" || maskLayer.type === "raster") ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
return asset && bounds ? { layer, asset, bounds, maskLayer, maskAsset } : undefined;
}
export async function applyChromaKeyMask(target: NonNullable<ReturnType<typeof resolveChromaKeyTarget>>, settings: ChromaKeySettings, dispatch: AppStore["dispatch"]) {
const source = await createChromaKeyMask(target.asset.source, target.asset.intrinsicSize.w, target.asset.intrinsicSize.h, settings);
dispatch(commandIds.toolSetBrushStrokePreview, undefined);
if (target.maskAsset && target.maskLayer && (target.maskLayer.type === "image" || target.maskLayer.type === "raster")) {
dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId: target.maskLayer.id, source, mimeType: "image/png", operation: { type: "chromaKey" } });
dispatch(commandIds.workspaceSetPanel, { panel: "none" });
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.workspaceSetPanel, { panel: "none" });
}
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; }
}
}

Some files were not shown because too many files have changed in this diff Show More