diff --git a/AGENTS.md b/AGENTS.md index a4b664d..aea973f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/app/app.ts b/app/app.ts index b56cc74..cf6e617 100644 --- a/app/app.ts +++ b/app/app.ts @@ -8,13 +8,15 @@ 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 { createInitialAppState } from "@editor/initial-state"; import { createAppStore } from "@editor/store"; export type ImageStudioApp = ReturnType; export function createImageStudioApp(options?: { documentName?: string; createDefaultArtboard?: boolean }) { - const registry = createCommandRegistry([...viewportCommands, ...selectionCommands, ...documentCommands, ...toolCommands, ...generationCommands, ...transformCommands, ...historyCommands, ...commandPaletteCommands]); + const registry = createCommandRegistry([...viewportCommands, ...selectionCommands, ...documentCommands, ...toolCommands, ...generationCommands, ...transformCommands, ...historyCommands, ...commandPaletteCommands, ...workspaceCommands, ...editorCommands]); const store = createAppStore(createInitialAppState(options?.documentName), registry); if (options?.createDefaultArtboard !== false) { diff --git a/commands/document-tree.ts b/commands/document-tree.ts new file mode 100644 index 0000000..e06592b --- /dev/null +++ b/commands/document-tree.ts @@ -0,0 +1,302 @@ +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"; + +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, 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: { targetLayerId: LayerId; maskLayerId: LayerId } | undefined, targetLayerId: LayerId, maskLayerId: LayerId) { + return maskEdit?.targetLayerId === targetLayerId && maskEdit.maskLayerId === maskLayerId; +} + +export 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 && getLayerMask(target)?.maskLayerId === maskEdit.maskLayerId && mask && mask.type !== "group"); +} + +export function collectDocumentLayerIds(document: ImageDocument): Set { + const ids = new Set(); + for (const artboard of document.artboards) collectLayerIdsFromTree(artboard.layers, ids); + return ids; +} + +export function collectLayerIds(layer: Layer, ids = new Set()): Set { + ids.add(layer.id); + if (layer.type === "group") collectLayerIdsFromTree(layer.children, ids); + return ids; +} + +export function collectLayerIdsFromTree(layers: readonly Layer[], ids = new Set()): Set { + for (const layer of layers) collectLayerIds(layer, ids); + return ids; +} + +export function collectClippingMaskIds(layers: readonly Layer[], ids = new Set()): Set { + 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); + }); +} diff --git a/commands/document.ts b/commands/document.ts index ca7bdba..5da8d6e 100644 --- a/commands/document.ts +++ b/commands/document.ts @@ -1,9 +1,9 @@ +import { findLayerLocation, isReferencedMaskLayer, mapLayerInDocument, insertLayer, replaceLayerListInDocument, replaceSelectedLayersWithGroup, removeLayerFromDocument, ungroupLayerInDocument, findGroup, removeLayerMaskReference, withLayerMask, removeUnreferencedMaskLayer, removeMissingMaskReferences, isMaskEditFor, isMaskEditValid, collectLayerIds, collectClippingMaskIds, collectAttachedMaskIds } 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 { LayerGroup } from "@core/layer-group"; @@ -625,300 +625,3 @@ export const documentCommands = [ documentApplyLayerMaskOperationCommand, documentRemoveLayerMaskCommand, ] satisfies Command[]; - -type LayerLocation = { - artboardId: ArtboardId; - parentGroupId?: LayerId; - index: number; - layer: Layer; - siblings: readonly 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 isReferencedMaskLayer(document: ImageDocument, maskLayerId: LayerId): boolean { - return document.artboards.some((artboard) => isReferencedMaskLayerInTree(artboard.layers, maskLayerId)); -} - -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; -} - -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; -} - -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 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 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) }; - }), - }; -} - -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; - }); -} - -function replaceSelectedLayersWithGroup(layers: readonly Layer[], selectedLayerIds: ReadonlySet, 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; -} - -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, - }; -} - -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.layerMask; - delete next.clippingMask; - return next; -} - -function withLayerMask(layer: Layer, maskLayerId: LayerId): Layer { - return { - ...layer, - layerMask: { - kind: "raster", - maskLayerId, - enabled: true, - inverted: false, - }, - clippingMask: { maskLayerId }, - }; -} - -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) => { - const mask = getLayerMask(layer); - if (!mask || existingLayerIds.has(mask.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 && getLayerMask(target)?.maskLayerId === maskEdit.maskLayerId && mask && mask.type !== "group"); -} - -function collectDocumentLayerIds(document: ImageDocument): Set { - const ids = new Set(); - for (const artboard of document.artboards) collectLayerIdsFromTree(artboard.layers, ids); - return ids; -} - -function collectLayerIds(layer: Layer, ids = new Set()): Set { - ids.add(layer.id); - if (layer.type === "group") collectLayerIdsFromTree(layer.children, ids); - return ids; -} - -function collectLayerIdsFromTree(layers: readonly Layer[], ids = new Set()): Set { - for (const layer of layers) collectLayerIds(layer, ids); - return ids; -} - -function collectClippingMaskIds(layers: readonly Layer[], ids = new Set()): Set { - 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; -} - -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] : []; - }); -} - -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); - }); -} diff --git a/commands/editor.ts b/commands/editor.ts new file mode 100644 index 0000000..c7b38e7 --- /dev/null +++ b/commands/editor.ts @@ -0,0 +1,16 @@ +import type { Command } from "./command"; +import { commandIds } from "./ids"; + +export type EditorSetPointerSessionPayload = { type: "pan" } | undefined; + +export const editorSetPointerSessionCommand: Command = { + 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[]; diff --git a/commands/generation.test.ts b/commands/generation.test.ts index 46e4413..22baf9c 100644 --- a/commands/generation.test.ts +++ b/commands/generation.test.ts @@ -7,6 +7,9 @@ import { generationRemoveCandidateCommand, generationReplaceCandidatePixelsCommand, generationSetCompareModeCommand, + generationFailJobCommand, + generationStartJobCommand, + generationSucceedJobCommand, } from "./generation"; describe("generation commands", () => { @@ -64,6 +67,8 @@ describe("generation commands", () => { candidates: [generationCandidate("candidate-2")], selectedCandidateId: "candidate-2", compareMode: "split", + jobs: [], + resources: { status: "idle" }, }); }); @@ -99,8 +104,27 @@ describe("generation commands", () => { 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 }); + }); }); function documentWithSourceLayer() { diff --git a/commands/generation.ts b/commands/generation.ts index c0f6832..80a677d 100644 --- a/commands/generation.ts +++ b/commands/generation.ts @@ -1,10 +1,10 @@ import type { Asset } from "@core/asset"; import type { AssetGenerationProvenance, GeneratedAssetAcceptance } from "@core/asset-provenance"; import type { ImageDocument } from "@core/document"; -import type { ArtboardId, AssetId, LayerId } from "@core/id"; +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 { GenerationCandidate, GenerationCompareMode, GenerationState } from "@editor/state"; +import type { AppState, GenerationCandidate, GenerationCompareMode, GenerationJobKind, GenerationOptions, GenerationState } from "@editor/state"; import type { Command } from "./command"; import { commandIds } from "./ids"; @@ -13,7 +13,7 @@ export type GenerationAddCandidatePayload = { }; export type GenerationSelectCandidatePayload = { - candidateId?: string; + candidateId?: GenerationCandidateId; }; export type GenerationSetCompareModePayload = { @@ -21,22 +21,29 @@ export type GenerationSetCompareModePayload = { }; export type GenerationRemoveCandidatePayload = { - candidateId: string; + candidateId: GenerationCandidateId; }; export type GenerationApplyCandidateAsLayerPayload = { - candidateId: string; + candidateId: GenerationCandidateId; assetId: AssetId; layerId: LayerId; }; export type GenerationReplaceCandidatePixelsPayload = { - candidateId: string; + candidateId: GenerationCandidateId; source: string; mimeType?: string; }; +export type GenerationStartJobPayload = { jobId: GenerationJobId; kind: GenerationJobKind; label: string; startedAt: number }; +export type GenerationSucceedJobPayload = { jobId: GenerationJobId; finishedAt: number }; +export type GenerationFailJobPayload = { jobId: GenerationJobId; finishedAt: number; error: string }; +export type GenerationSetResourcesPayload = { options: GenerationOptions }; +export type GenerationFailResourcesPayload = { error: string }; + const maxCandidates = 12; +const maxJobs = 20; const generationCompareModes = new Set(["result", "before", "split"]); export const generationAddCandidateCommand: Command = { @@ -50,6 +57,7 @@ export const generationAddCandidateCommand: Command = { + 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 generationSucceedJobCommand: Command = { + 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 = { + 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 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 = { + 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 = { + 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, @@ -218,8 +286,29 @@ export const generationCommands = [ generationClearCandidatesCommand, generationApplyCandidateAsLayerCommand, generationReplaceCandidatePixelsCommand, + generationStartJobCommand, + generationSucceedJobCommand, + generationFailJobCommand, + generationLoadResourcesCommand, + generationSetResourcesCommand, + generationFailResourcesCommand, ] satisfies Command[]; +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", 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)); +} + type LayerLocation = { artboardId: ArtboardId; layer: Layer; @@ -251,7 +340,7 @@ function findLayerInTree(layers: readonly Layer[], layerId: LayerId): Layer | un return undefined; } -function removeGenerationCandidate(generation: GenerationState, candidateId: string): GenerationState { +function removeGenerationCandidate(generation: GenerationState, candidateId: GenerationCandidateId): GenerationState { const removedIndex = generation.candidates.findIndex((candidate) => candidate.id === candidateId); if (removedIndex < 0) return generation; @@ -264,6 +353,7 @@ function removeGenerationCandidate(generation: GenerationState, candidateId: str : candidates[Math.min(removedIndex, candidates.length - 1)]?.id; return { + ...generation, candidates, selectedCandidateId, compareMode: candidates.length > 0 ? generation.compareMode : "result", diff --git a/commands/history.test.ts b/commands/history.test.ts index 2a3a218..e5d3cc7 100644 --- a/commands/history.test.ts +++ b/commands/history.test.ts @@ -1,5 +1,6 @@ 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"; @@ -7,7 +8,7 @@ import { commandIds } from "./ids"; import { createCommandRegistry } from "./registry"; import { transformCommands } from "./transform"; -const registry = createCommandRegistry([documentAddArtboardCommand, ...historyCommands, ...transformCommands]); +const registry = createCommandRegistry([documentAddArtboardCommand, generationStartJobCommand, generationSucceedJobCommand, ...historyCommands, ...transformCommands]); describe("history commands", () => { test("records document changes and undoes/redoes them", () => { @@ -97,6 +98,17 @@ describe("history commands", () => { 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() { diff --git a/commands/history.ts b/commands/history.ts index 7c08375..36d7b60 100644 --- a/commands/history.ts +++ b/commands/history.ts @@ -1,4 +1,5 @@ import type { Command } from "./command"; +import type { EditorState } from "@editor/state"; import { commandIds } from "./ids"; export const historyUndoCommand: Command = { @@ -12,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], @@ -32,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), @@ -42,3 +43,13 @@ export const historyRedoCommand: Command = { }; export const historyCommands = [historyUndoCommand, historyRedoCommand] satisfies Command[]; + +function preserveGenerationJobs(target: EditorState, current: EditorState): EditorState { + return { + ...target, + generation: { + ...target.generation, + jobs: current.generation.jobs, + }, + }; +} diff --git a/commands/ids.ts b/commands/ids.ts index 3ea204f..4cecdc9 100644 --- a/commands/ids.ts +++ b/commands/ids.ts @@ -43,6 +43,12 @@ export const commandIds = { generationClearCandidates: "generation.clearCandidates", generationApplyCandidateAsLayer: "generation.applyCandidateAsLayer", generationReplaceCandidatePixels: "generation.replaceCandidatePixels", + generationStartJob: "generation.startJob", + generationSucceedJob: "generation.succeedJob", + generationFailJob: "generation.failJob", + generationLoadResources: "generation.loadResources", + generationSetResources: "generation.setResources", + generationFailResources: "generation.failResources", transformBegin: "transform.begin", transformUpdate: "transform.update", transformSetBounds: "transform.setBounds", @@ -59,4 +65,6 @@ export const commandIds = { commandPaletteClose: "commandPalette.close", commandPaletteSetQuery: "commandPalette.setQuery", commandPaletteSetSelectedIndex: "commandPalette.setSelectedIndex", + workspaceSetPanel: "workspace.setPanel", + editorSetPointerSession: "editor.setPointerSession", } as const; diff --git a/commands/payloads.ts b/commands/payloads.ts index e5ffe6b..88b832f 100644 --- a/commands/payloads.ts +++ b/commands/payloads.ts @@ -30,6 +30,11 @@ import type { GenerationReplaceCandidatePixelsPayload, GenerationSelectCandidatePayload, GenerationSetCompareModePayload, + GenerationStartJobPayload, + GenerationSucceedJobPayload, + GenerationFailJobPayload, + GenerationSetResourcesPayload, + GenerationFailResourcesPayload, } from "./generation"; import type { CommandPaletteOpenPayload, @@ -39,6 +44,8 @@ import type { import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection"; import type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool"; import type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform"; +import type { WorkspaceSetPanelPayload } from "./workspace"; +import type { EditorSetPointerSessionPayload } from "./editor"; import type { ViewportFitArtboardPayload, ViewportPanPayload, @@ -92,6 +99,12 @@ export type CommandPayloads = { [commandIds.generationClearCandidates]: void; [commandIds.generationApplyCandidateAsLayer]: GenerationApplyCandidateAsLayerPayload; [commandIds.generationReplaceCandidatePixels]: GenerationReplaceCandidatePixelsPayload; + [commandIds.generationStartJob]: GenerationStartJobPayload; + [commandIds.generationSucceedJob]: GenerationSucceedJobPayload; + [commandIds.generationFailJob]: GenerationFailJobPayload; + [commandIds.generationLoadResources]: void; + [commandIds.generationSetResources]: GenerationSetResourcesPayload; + [commandIds.generationFailResources]: GenerationFailResourcesPayload; [commandIds.transformBegin]: TransformBeginPayload; [commandIds.transformUpdate]: TransformUpdatePayload; [commandIds.transformSetBounds]: TransformSetBoundsPayload; @@ -108,6 +121,8 @@ export type CommandPayloads = { [commandIds.commandPaletteClose]: void; [commandIds.commandPaletteSetQuery]: CommandPaletteSetQueryPayload; [commandIds.commandPaletteSetSelectedIndex]: CommandPaletteSetSelectedIndexPayload; + [commandIds.workspaceSetPanel]: WorkspaceSetPanelPayload; + [commandIds.editorSetPointerSession]: EditorSetPointerSessionPayload; }; export type CommandId = keyof CommandPayloads; diff --git a/commands/tool.ts b/commands/tool.ts index d7cd349..26c7099 100644 --- a/commands/tool.ts +++ b/commands/tool.ts @@ -44,6 +44,8 @@ export const toolSetActiveCommand: Command = { id: commandIds.toolSetActive, name: "Set active tool", execute({ state }, payload) { + const previousNonGenerateTool = payload.tool === "generate" ? state.editor.workspace.previousNonGenerateTool : payload.tool; + const panel = payload.tool === "generate" ? "generate" : state.editor.workspace.panel === "generate" ? "none" : state.editor.workspace.panel; return { ...state, editor: { @@ -55,6 +57,7 @@ export const toolSetActiveCommand: Command = { }, brushPreview: undefined, brushStrokePreview: undefined, + workspace: { panel, previousNonGenerateTool }, }, }; }, diff --git a/commands/transform-document.ts b/commands/transform-document.ts new file mode 100644 index 0000000..49da68d --- /dev/null +++ b/commands/transform-document.ts @@ -0,0 +1,64 @@ +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"; + +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) : 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 (!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") { + const asset = document.assets.find((candidate) => candidate.id === layer.assetId); + return asset ? { ...layer, transform: { ...layer.transform, position: { x: bounds.x, y: bounds.y }, scale: { x: bounds.w / asset.intrinsicSize.w, y: bounds.h / asset.intrinsicSize.h } } } : layer; + } + 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; } +} diff --git a/commands/transform.ts b/commands/transform.ts index c76e3e2..ad0f595 100644 --- a/commands/transform.ts +++ b/commands/transform.ts @@ -1,5 +1,5 @@ import type { Rect, Vec2D } from "@core/geometry"; -import { applyTransformTargetBounds } from "@editor/transform-targets"; +import { applyTransformTargetBounds } from "./transform-document"; import type { TransformHandle, TransformTarget } from "@editor/transform"; import type { Command } from "./command"; import { commandIds } from "./ids"; diff --git a/commands/workspace.ts b/commands/workspace.ts new file mode 100644 index 0000000..ed8fc3a --- /dev/null +++ b/commands/workspace.ts @@ -0,0 +1,32 @@ +import type { WorkspacePanel } from "@editor/state"; +import type { Command } from "./command"; +import { commandIds } from "./ids"; + +export type WorkspaceSetPanelPayload = { panel: WorkspacePanel }; + +export const workspaceSetPanelCommand: Command = { + id: commandIds.workspaceSetPanel, + name: "Set workspace panel", + history: { mode: "ignore" }, + execute({ state }, payload) { + if (!workspacePanels.has(payload.panel)) return state; + const currentTool = state.editor.tools.activeTool; + const previousNonGenerateTool = currentTool === "generate" ? state.editor.workspace.previousNonGenerateTool : currentTool; + const nextTool = payload.panel === "generate" ? "generate" : currentTool === "generate" ? previousNonGenerateTool : currentTool; + if (state.editor.workspace.panel === payload.panel && currentTool === nextTool) return state; + return { + ...state, + editor: { + ...state.editor, + workspace: { panel: payload.panel, previousNonGenerateTool }, + tools: nextTool === currentTool ? state.editor.tools : { ...state.editor.tools, activeTool: nextTool, interactionMode: { type: "tool", tool: nextTool } }, + brushPreview: nextTool === currentTool ? state.editor.brushPreview : undefined, + brushStrokePreview: nextTool === currentTool ? state.editor.brushStrokePreview : undefined, + }, + }; + }, +}; + +const workspacePanels = new Set(["none", "generate", "layers"]); + +export const workspaceCommands = [workspaceSetPanelCommand] satisfies Command[]; diff --git a/core/asset-provenance.ts b/core/asset-provenance.ts index 5af5858..8c058fd 100644 --- a/core/asset-provenance.ts +++ b/core/asset-provenance.ts @@ -1,5 +1,5 @@ import type { Rect, Size } from "./geometry"; -import type { AssetId, LayerId } from "./id"; +import type { AssetId, GenerationCandidateId, LayerId } from "./id"; export type GeneratedAssetMode = "text-to-image" | "image-to-image" | "inpaint" | "outpaint"; @@ -7,7 +7,7 @@ export type GeneratedAssetAcceptance = "layer" | "replacement"; export type AssetGenerationProvenance = { kind: "generated"; - candidateId: string; + candidateId: GenerationCandidateId; mode: GeneratedAssetMode; acceptance: GeneratedAssetAcceptance; prompt: string; diff --git a/core/id.ts b/core/id.ts index b7a1e55..38d531b 100644 --- a/core/id.ts +++ b/core/id.ts @@ -1,4 +1,9 @@ -export type DocumentId = string; -export type ArtboardId = string; -export type LayerId = string; -export type AssetId = string; +declare const idBrand: unique symbol; +type OpaqueId = 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 GenerationCandidateId = OpaqueId<"GenerationCandidateId">; +export type GenerationJobId = OpaqueId<"GenerationJobId">; diff --git a/core/index.ts b/core/index.ts index 97639c5..552bb73 100644 --- a/core/index.ts +++ b/core/index.ts @@ -13,7 +13,7 @@ export type { Transform, Vec2D, } from "./geometry"; -export type { ArtboardId, AssetId, DocumentId, LayerId } from "./id"; +export type { ArtboardId, AssetId, DocumentId, GenerationCandidateId, GenerationJobId, LayerId } from "./id"; export type { ImageLayer } from "./image-layer"; export type { Layer } from "./layer"; export type { LayerMask } from "./layer-mask"; diff --git a/docs/audits/2026-07-09-product-ux-architecture-audit.md b/docs/audits/2026-07-09-product-ux-architecture-audit.md index 96f33ae..d2d90d6 100644 --- a/docs/audits/2026-07-09-product-ux-architecture-audit.md +++ b/docs/audits/2026-07-09-product-ux-architecture-audit.md @@ -141,12 +141,14 @@ Non-inpaint candidates default to document position `(0, 0)` at native scale. Th 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 +### 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 — the workspace lacks stable information architecture The current shell is a canvas surrounded by floating islands: @@ -192,24 +194,30 @@ The result is a dense management panel that still cannot answer the basic questi Redesign implication: use a durable document tree with thumbnails and compact row actions, then move selected-object properties and mask controls into a contextual inspector. Mask editing should become a clear editor mode, not an expanded sub-card full of unrelated actions. -### P2 — panel state ownership is inconsistent +### 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 more panels, inspectors, result trays, modal operation states, or workspace layouts. Meaningful workspace state should have one model and one transition path. -### P2 — view code owns application workflows and side effects +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. The redesign is an opportunity to introduce explicit application services/jobs without weakening the command-only mutation rule. -### P2 — imported object URLs have no durable ownership +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. @@ -220,7 +228,7 @@ The redesign should establish a predictable location for document actions and re 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 +### P2 — large modules have become change hotspots — resolved 2026-07-10 Several files combine multiple responsibilities: @@ -235,6 +243,8 @@ Several files combine multiple responsibilities: Line count alone is not a defect, but these files are already coordinating distinct concepts. The redesign 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 and chrome 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. diff --git a/editor/initial-state.ts b/editor/initial-state.ts index 357de5f..de64e00 100644 --- a/editor/initial-state.ts +++ b/editor/initial-state.ts @@ -16,16 +16,23 @@ export const initialEditorState: EditorState = { candidates: [], selectedCandidateId: undefined, compareMode: "result", + jobs: [], + resources: { status: "idle" }, }, commandPalette: { open: false, query: "", selectedIndex: 0, }, + workspace: { + panel: "none", + previousNonGenerateTool: "select", + }, transformSession: undefined, maskEdit: undefined, brushPreview: undefined, brushStrokePreview: undefined, + pointerSession: undefined, }; export function createInitialAppState(name = "Untitled"): AppState { diff --git a/editor/state.ts b/editor/state.ts index d384bc1..384da51 100644 --- a/editor/state.ts +++ b/editor/state.ts @@ -1,7 +1,7 @@ import type { ImageDocument } from "@core/document"; import type { Angle, Rect, Size, Transform, Vec2D } from "@core/geometry"; -import type { ArtboardId, AssetId, LayerId } from "@core/id"; -import type { GenerateSettings, ToolState } from "./tools"; +import type { ArtboardId, AssetId, GenerationCandidateId, GenerationJobId, LayerId } from "@core/id"; +import type { GenerateArchitecture, GenerateMode, GenerateSettings, ToolId, ToolState } from "./tools"; import type { TransformSession } from "./transform"; export type ViewportState = { @@ -35,7 +35,7 @@ export type BrushStrokePreviewState = { }; export type GenerationCandidate = { - id: string; + id: GenerationCandidateId; source: string; mimeType: string; intrinsicSize: Size; @@ -81,28 +81,71 @@ export type GenerationCandidate = { export type GenerationCompareMode = "result" | "before" | "split"; +export type GenerationJobKind = "generate" | "regenerate" | "refine" | "replace"; +export type GenerationJobStatus = "running" | "succeeded" | "failed"; + +export type GenerationJob = { + id: GenerationJobId; + kind: GenerationJobKind; + label: string; + status: GenerationJobStatus; + startedAt: number; + finishedAt?: number; + error?: 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[]; + textEncoders?: string[]; + vaes?: string[]; + samplers?: string[]; + schedulers?: 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" | "layers"; + +export type WorkspaceState = { + panel: WorkspacePanel; + previousNonGenerateTool: ToolId; +}; + export type EditorState = { viewport: ViewportState; selection: SelectionState; tools: ToolState; generation: GenerationState; commandPalette: CommandPaletteState; + workspace: WorkspaceState; transformSession?: TransformSession; maskEdit?: MaskEditState; brushPreview?: BrushPreviewState; brushStrokePreview?: BrushStrokePreviewState; + pointerSession?: { type: "pan" }; }; export type HistorySnapshot = { diff --git a/editor/transform-targets.test.ts b/editor/transform-targets.test.ts index 3b6f3fe..aeda4ee 100644 --- a/editor/transform-targets.test.ts +++ b/editor/transform-targets.test.ts @@ -1,6 +1,7 @@ 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 = { id: "d1", diff --git a/editor/transform-targets.ts b/editor/transform-targets.ts index 6fb598a..e5893e0 100644 --- a/editor/transform-targets.ts +++ b/editor/transform-targets.ts @@ -1,7 +1,6 @@ import type { ImageDocument } from "@core/document"; import type { Rect } from "@core/geometry"; import type { Layer } from "@core/layer"; -import { getLayerMask } from "@core/layer-mask-utils"; import type { ArtboardId, LayerId } from "@core/id"; import type { TransformTarget } from "./transform"; @@ -16,129 +15,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); - if (!layer) return document; - if (layer.type === "group") return applyGroupLayerBounds(document, layer.id, bounds); - - const layerMask = getLayerMask(layer); - const targetLayerIds = layerMask ? [layerId, layerMask.maskLayerId] : [layerId]; - - return targetLayerIds.reduce( - (nextDocument, targetLayerId) => ({ - ...nextDocument, - artboards: nextDocument.artboards.map((artboard) => ({ - ...artboard, - layers: applyLayerBoundsInTree(nextDocument, artboard.layers, targetLayerId, bounds), - })), - }), - document, - ); -} - -function applyGroupLayerBounds(document: ImageDocument, groupId: LayerId, bounds: Rect): ImageDocument { - const group = findLayer(document, groupId); - if (!group || group.type !== "group") return document; - - const initialBounds = resolveLayerBounds(document, group); - if (!initialBounds || initialBounds.w === 0 || initialBounds.h === 0) return document; - - const scale = { - x: bounds.w / initialBounds.w, - y: bounds.h / initialBounds.h, - }; - - return { - ...document, - artboards: document.artboards.map((artboard) => ({ - ...artboard, - layers: applyGroupLayerBoundsInTree(document, artboard.layers, groupId, initialBounds, bounds, scale), - })), - }; -} - -function applyGroupLayerBoundsInTree(document: ImageDocument, layers: Layer[], groupId: LayerId, initialBounds: 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) => scaleLayerSubtree(document, child, initialBounds, bounds, scale)), - }; - } - if (layer.type === "group") return { ...layer, children: applyGroupLayerBoundsInTree(document, layer.children, groupId, initialBounds, bounds, scale) }; - return layer; - }); -} - -function scaleLayerSubtree(document: ImageDocument, layer: Layer, initialBounds: Rect, bounds: Rect, scale: { x: number; y: number }): Layer { - if (layer.type === "group") { - return { - ...layer, - children: layer.children.map((child) => scaleLayerSubtree(document, child, initialBounds, bounds, scale)), - }; - } - - const asset = document.assets.find((candidate) => candidate.id === layer.assetId); - if (!asset) return layer; - - return { - ...layer, - transform: { - ...layer.transform, - position: { - x: bounds.x + (layer.transform.position.x - initialBounds.x) * scale.x, - y: bounds.y + (layer.transform.position.y - initialBounds.y) * scale.y, - }, - scale: { - x: layer.transform.scale.x * scale.x, - y: layer.transform.scale.y * scale.y, - }, - }, - }; -} - -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); diff --git a/eslint.config.js b/eslint.config.js index 44394c1..6e8bef2 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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"] }], + }, + }, ); diff --git a/index.ts b/index.ts index e9ed970..52bd3b9 100644 --- a/index.ts +++ b/index.ts @@ -1,5 +1,5 @@ import { serve } from "bun"; -import { handleComfyApi } from "./app/comfy"; +import { handleComfyApi } from "./server/comfy-routes"; import index from "./view/index.html"; const server = serve({ diff --git a/input/document-geometry.ts b/input/document-geometry.ts index 6a77436..281a20b 100644 --- a/input/document-geometry.ts +++ b/input/document-geometry.ts @@ -1,7 +1,5 @@ -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"; export type InputViewportState = { center: Vec2D; @@ -11,21 +9,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 +50,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 +59,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 +71,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) => { diff --git a/input/layers-panel.test.ts b/input/layers-panel.test.ts index 0f96671..425023f 100644 --- a/input/layers-panel.test.ts +++ b/input/layers-panel.test.ts @@ -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 = { diff --git a/input/layers-panel.ts b/input/layers-panel.ts index 35c61f0..8603c36 100644 --- a/input/layers-panel.ts +++ b/input/layers-panel.ts @@ -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; @@ -75,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); @@ -84,7 +82,7 @@ export function findLayerInfoInDocument(document: ImageDocument, layerId?: Layer return undefined; } -export function findGroup(document: ImageDocument, groupId: LayerId): Extract | undefined { +export function findGroup(document: InputDocument, groupId: LayerId): Extract | undefined { const info = findLayerInfoInDocument(document, groupId); return info?.layer.type === "group" ? info.layer : undefined; } diff --git a/input/read-model.ts b/input/read-model.ts new file mode 100644 index 0000000..e6beca8 --- /dev/null +++ b/input/read-model.ts @@ -0,0 +1,27 @@ +import type { Rect, Size, Transform } from "@core/geometry"; + +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: "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[] }>; +}; diff --git a/input/selection.test.ts b/input/selection.test.ts index 9093c23..4a3a404 100644 --- a/input/selection.test.ts +++ b/input/selection.test.ts @@ -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"; diff --git a/input/selection.ts b/input/selection.ts index 518acd0..616ecf2 100644 --- a/input/selection.ts +++ b/input/selection.ts @@ -1,14 +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 { getLayerMask } from "@core/layer-mask-utils"; +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 { @@ -36,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; @@ -47,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 | undefined { +function findTopmostLayerInTreeAtPoint(document: InputDocument, layers: InputLayer[], point: { x: number; y: number }, maskLayerIds: ReadonlySet): string | undefined { for (const layer of layers) { if (!layer.visible || layer.locked || maskLayerIds.has(layer.id)) continue; if (layer.type === "group") { @@ -64,9 +62,9 @@ function findTopmostLayerInTreeAtPoint(document: ImageDocument, layers: Layer[], return undefined; } -function collectMaskLayerIds(layers: readonly Layer[], ids = new Set()): Set { +function collectMaskLayerIds(layers: readonly InputLayer[], ids = new Set()): Set { for (const layer of layers) { - const layerMask = getLayerMask(layer); + const layerMask = layer.layerMask ?? layer.clippingMask; if (layerMask) ids.add(layerMask.maskLayerId); if (layer.type === "group") collectMaskLayerIds(layer.children, ids); } diff --git a/input/transform-controls.test.ts b/input/transform-controls.test.ts index 53b824c..f7b6ee6 100644 --- a/input/transform-controls.test.ts +++ b/input/transform-controls.test.ts @@ -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"; diff --git a/input/transform-controls.ts b/input/transform-controls.ts index 169f5ba..3c84d10 100644 --- a/input/transform-controls.ts +++ b/input/transform-controls.ts @@ -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, @@ -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; diff --git a/operations/AGENTS.md b/operations/AGENTS.md new file mode 100644 index 0000000..56cfad2 --- /dev/null +++ b/operations/AGENTS.md @@ -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. diff --git a/view/layerActions.ts b/operations/document/layerActions.ts similarity index 100% rename from view/layerActions.ts rename to operations/document/layerActions.ts diff --git a/operations/export/downloadArtboard.ts b/operations/export/downloadArtboard.ts new file mode 100644 index 0000000..8954ef5 --- /dev/null +++ b/operations/export/downloadArtboard.ts @@ -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); } diff --git a/view/generate/candidateActions.ts b/operations/generation/candidateActions.ts similarity index 96% rename from view/generate/candidateActions.ts rename to operations/generation/candidateActions.ts index 36c43c9..000ab62 100644 --- a/view/generate/candidateActions.ts +++ b/operations/generation/candidateActions.ts @@ -1,6 +1,6 @@ import type { ImageDocument } from "@core/document"; import type { GenerationCandidate } from "@editor/state"; -import { loadImageCanvas, maskValueFromRgba } from "../mask/maskRaster"; +import { loadImageCanvas, maskValueFromRgba } from "@platform/browser/maskRaster"; export async function createMaskedPixelReplacementSource(document: ImageDocument, candidate: GenerationCandidate): Promise { if (!candidate.inpaint) throw new Error("Only inpaint candidates can replace masked pixels."); diff --git a/operations/generation/generationJob.ts b/operations/generation/generationJob.ts new file mode 100644 index 0000000..9ac0c27 --- /dev/null +++ b/operations/generation/generationJob.ts @@ -0,0 +1,26 @@ +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"]; + task: () => Promise; +}): Promise { + 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.dispatch(commandIds.generationSucceedJob, { jobId, finishedAt: Date.now() }); + } catch (reason: unknown) { + options.dispatch(commandIds.generationFailJob, { + jobId, + finishedAt: Date.now(), + error: reason instanceof Error ? reason.message : `${options.label} failed`, + }); + } +} diff --git a/view/generate/inpaintPrep.test.ts b/operations/generation/inpaintPrep.test.ts similarity index 100% rename from view/generate/inpaintPrep.test.ts rename to operations/generation/inpaintPrep.test.ts diff --git a/view/generate/inpaintPrep.ts b/operations/generation/inpaintPrep.ts similarity index 99% rename from view/generate/inpaintPrep.ts rename to operations/generation/inpaintPrep.ts index 8032b0d..b2d1f4b 100644 --- a/view/generate/inpaintPrep.ts +++ b/operations/generation/inpaintPrep.ts @@ -6,7 +6,7 @@ import { getLayerMask } from "@core/layer-mask-utils"; import type { SelectionState } from "@editor/state"; import type { GenerateSettings } from "@editor/tools"; import { createDocumentReadIndex, resolveIndexedLayerBounds } from "@editor/document-indexes"; -import { createNormalizedMaskSource, cropCanvas, cropMaskValuesToDataUrl, expandRectWithinBounds, loadImageCanvas } from "../mask/maskRaster"; +import { createNormalizedMaskSource, cropCanvas, cropMaskValuesToDataUrl, expandRectWithinBounds, loadImageCanvas } from "@platform/browser/maskRaster"; export type InpaintBundle = { inputImage: string; diff --git a/operations/generation/loadResources.ts b/operations/generation/loadResources.ts new file mode 100644 index 0000000..c14d88f --- /dev/null +++ b/operations/generation/loadResources.ts @@ -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 { + 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" }); + } +} diff --git a/view/generate/runGenerate.ts b/operations/generation/runGenerate.ts similarity index 87% rename from view/generate/runGenerate.ts rename to operations/generation/runGenerate.ts index 3e4dfcf..0fa8eb3 100644 --- a/view/generate/runGenerate.ts +++ b/operations/generation/runGenerate.ts @@ -7,6 +7,8 @@ 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"; export async function runGenerate(options: { document: ImageDocument; @@ -142,10 +144,7 @@ async function requestGenerate(options: { inpaintBundle?: InpaintBundle; inpaintCandidate?: GenerationCandidate; }) { - const response = await fetch("/api/comfy/generate", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ + return requestGeneration({ architecture: options.settings.architecture, mode: options.settings.mode, model: options.settings.model, @@ -165,10 +164,7 @@ async function requestGenerate(options: { inpaint: resolveInpaintRequest(options.inpaintBundle, options.inpaintCandidate, options.settings), inputImage: options.inputImage, maskImage: options.maskImage, - }), }); - if (!response.ok) throw new Error(await response.text()); - return await response.json() as { source: string; mimeType: string }; } function resolveInpaintRequest(inpaintBundle: InpaintBundle | undefined, inpaintCandidate: GenerationCandidate | undefined, settings: GenerateSettings) { @@ -241,28 +237,3 @@ function findLayer(layers: readonly Layer[], layerId: string): Layer | undefined } return undefined; } - -async function imageSourceToDataUrl(source: string) { - if (source.startsWith("data:")) return source; - const image = await loadImage(source); - const canvas = document.createElement("canvas"); - canvas.width = image.naturalWidth; - canvas.height = image.naturalHeight; - const context = canvas.getContext("2d"); - if (!context) throw new Error("Unable to read selected image"); - context.drawImage(image, 0, 0); - return canvas.toDataURL("image/png"); -} - -function loadImageSize(source: string): Promise<{ w: number; h: number }> { - return loadImage(source).then((image) => ({ w: image.naturalWidth, h: image.naturalHeight })); -} - -function loadImage(source: string): Promise { - return new Promise((resolve, reject) => { - const image = new Image(); - image.onload = () => resolve(image); - image.onerror = () => reject(new Error("Failed to load image")); - image.src = source; - }); -} diff --git a/operations/import/importImage.ts b/operations/import/importImage.ts new file mode 100644 index 0000000..b5f9c59 --- /dev/null +++ b/operations/import/importImage.ts @@ -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; +} diff --git a/operations/masks/chromaKey.ts b/operations/masks/chromaKey.ts new file mode 100644 index 0000000..77692a2 --- /dev/null +++ b/operations/masks/chromaKey.ts @@ -0,0 +1,51 @@ +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 === "group") return undefined; + const asset = document.assets.find((candidate) => candidate.id === layer.assetId); + const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id }); + const layerMask = getLayerMask(layer); + const maskLayer = layerMask?.enabled ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerMask.maskLayerId) : undefined; + const maskAsset = maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined; + return asset && bounds ? { layer, asset, bounds, maskLayer, maskAsset } : undefined; +} + +export async function applyChromaKeyMask(target: NonNullable>, 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 !== "group") { + dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId: target.maskLayer.id, source, mimeType: "image/png", operation: { type: "chromaKey" } }); + return; + } + const assetId = crypto.randomUUID(); + const maskLayerId = crypto.randomUUID(); + const width = Math.max(1, Math.round(target.asset.intrinsicSize.w)); + const height = Math.max(1, Math.round(target.asset.intrinsicSize.h)); + dispatch(commandIds.documentAddLayerMask, { + layerId: target.layer.id, + asset: { id: assetId, name: `${target.layer.name} Chroma Mask`, mimeType: "image/png", source, intrinsicSize: { w: width, h: height } }, + maskLayer: { id: maskLayerId, type: "raster", name: `${target.layer.name} Chroma Mask`, visible: true, locked: false, opacity: 1, assetId, transform: { position: { x: target.bounds.x, y: target.bounds.y }, scale: { x: target.bounds.w / width, y: target.bounds.h / height }, rotation: target.layer.transform.rotation } }, + }); + dispatch(commandIds.toolExitMaskEdit, undefined); + dispatch(commandIds.toolSetActive, { tool: "chromaKey" }); +} + +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; } + } +} diff --git a/operations/masks/magic-wand.ts b/operations/masks/magic-wand.ts new file mode 100644 index 0000000..e91cf7e --- /dev/null +++ b/operations/masks/magic-wand.ts @@ -0,0 +1,60 @@ +import { commandIds } from "@commands/ids"; +import type { ImageDocument } from "@core/document"; +import type { Vec2D } from "@core/geometry"; +import type { Layer } from "@core/layer"; +import { getLayerMask } from "@core/layer-mask-utils"; +import { resolveTransformTargetBounds } from "@editor/transform-targets"; +import type { AppStore } from "@editor/store"; +import type { EditorState } from "@editor/state"; +import { createWandMask } from "@platform/browser/magicWandRaster"; + +export async function applyMagicWandAt(store: AppStore, point: Vec2D, modeOverride?: EditorState["tools"]["magicWand"]["mode"]) { + const state = store.getState(); + if (state.editor.tools.activeTool !== "magicWand") return false; + const target = resolveTarget(state.document, state.editor); + if (!target) return true; + const x = Math.floor((point.x - target.layer.transform.position.x) / Math.max(0.0001, target.layer.transform.scale.x)); + const y = Math.floor((point.y - target.layer.transform.position.y) / Math.max(0.0001, target.layer.transform.scale.y)); + if (x < 0 || y < 0 || x >= target.asset.intrinsicSize.w || y >= target.asset.intrinsicSize.h) return true; + const source = await createWandMask(target.asset.source, target.maskAsset?.source, Math.round(target.asset.intrinsicSize.w), Math.round(target.asset.intrinsicSize.h), x, y, { ...state.editor.tools.magicWand, mode: modeOverride ?? state.editor.tools.magicWand.mode }); + if (target.maskAsset && target.maskLayer && target.maskLayer.type !== "group") { + store.dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId: target.maskLayer.id, source, mimeType: "image/png", operation: { type: "magicWand" } }); + return true; + } + const assetId = crypto.randomUUID(); + const maskLayerId = crypto.randomUUID(); + const width = Math.max(1, Math.round(target.asset.intrinsicSize.w)); + const height = Math.max(1, Math.round(target.asset.intrinsicSize.h)); + store.dispatch(commandIds.documentAddLayerMask, { + layerId: target.layer.id, + asset: { id: assetId, name: `${target.layer.name} Wand Mask`, mimeType: "image/png", source, intrinsicSize: { w: width, h: height } }, + maskLayer: { id: maskLayerId, type: "raster", name: `${target.layer.name} Wand Mask`, visible: true, locked: false, opacity: 1, assetId, transform: { position: { x: target.bounds.x, y: target.bounds.y }, scale: { x: target.bounds.w / width, y: target.bounds.h / height }, rotation: target.layer.transform.rotation } }, + }); + store.dispatch(commandIds.toolExitMaskEdit, undefined); + store.dispatch(commandIds.toolSetActive, { tool: "magicWand" }); + return true; +} + +function resolveTarget(document: ImageDocument, editor: EditorState) { + const layerId = editor.selection.layerIds[0]; + if (!layerId || editor.selection.layerIds.length !== 1) return undefined; + const layer = findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId); + if (!layer || layer.type === "group") return undefined; + const asset = document.assets.find((candidate) => candidate.id === layer.assetId); + const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id }); + const layerMask = getLayerMask(layer); + const maskLayer = layerMask?.enabled ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerMask.maskLayerId) : undefined; + const maskAsset = maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined; + return asset && bounds ? { layer, asset, bounds, maskLayer, maskAsset } : undefined; +} + +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; +} diff --git a/operations/masks/rasterActions.ts b/operations/masks/rasterActions.ts new file mode 100644 index 0000000..feed84e --- /dev/null +++ b/operations/masks/rasterActions.ts @@ -0,0 +1,10 @@ +import { commandIds } from "@commands/ids"; +import type { Asset } from "@core/asset"; +import type { LayerId } from "@core/id"; +import type { AppStore } from "@editor/store"; +import { analyzeMaskSource, applyMaskRasterOperation, createSolidMaskSource, type MaskAnalysis, type MaskRasterOperation } from "@platform/browser/maskRaster"; + +export type { MaskAnalysis, MaskRasterOperation }; +export function analyzeMask(asset: Asset): Promise { return analyzeMaskSource(asset.source, asset.intrinsicSize.w, asset.intrinsicSize.h); } +export async function runMaskOperation(maskLayerId: LayerId, asset: Asset, operation: MaskRasterOperation, dispatch: AppStore["dispatch"]) { const source = await applyMaskRasterOperation(asset.source, asset.intrinsicSize.w, asset.intrinsicSize.h, operation); dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId, source, mimeType: "image/png", operation }); } +export function createRefinementMask(width: number, height: number) { return createSolidMaskSource(width, height, "white"); } diff --git a/view/canvas/brush.ts b/operations/paint/brush.ts similarity index 74% rename from view/canvas/brush.ts rename to operations/paint/brush.ts index 44b96cf..5f5fbca 100644 --- a/view/canvas/brush.ts +++ b/operations/paint/brush.ts @@ -6,14 +6,14 @@ import type { RasterLayer } from "@core/raster-layer"; import type { MaskEditState, SelectionState } from "@editor/state"; import { isPanInteractionMode, type ToolState } from "@editor/tools"; import type { AppStore } from "@editor/store"; +import { brushSurfaceDataUrl, brushSurfaceObjectUrl, cancelFrame, createBrushSurface, drawBrushSegment, releaseObjectUrl, scheduleFrame, type BrushSurface } from "@platform/browser/brushRaster"; export type BrushSession = { layerId: string; assetId: string; width: number; height: number; - canvas: HTMLCanvasElement; - context: CanvasRenderingContext2D; + surface: BrushSurface; ready: Promise; previousPoint: Vec2D; mode: "brush" | "eraser"; @@ -40,24 +40,19 @@ export function beginBrushSession(document: ImageDocument, editor: BrushTargetEd const asset = document.assets.find((candidate) => candidate.id === layer.assetId); if (!asset) return undefined; - const canvas = globalThis.document.createElement("canvas"); - canvas.width = Math.max(1, Math.round(asset.intrinsicSize.w)); - canvas.height = Math.max(1, Math.round(asset.intrinsicSize.h)); - const context = canvas.getContext("2d"); - if (!context) return undefined; + const surface = createBrushSurface(asset.intrinsicSize.w, asset.intrinsicSize.h, asset.source); + if (!surface) return undefined; const session: BrushSession = { layerId: layer.id, assetId: layer.assetId, - width: canvas.width, - height: canvas.height, - canvas, - context, - ready: Promise.resolve(false), + width: surface.width, + height: surface.height, + surface, + ready: surface.ready, previousPoint: point, mode: editor.tools.activeTool, }; - session.ready = initializeBrushSession(session, asset.source).catch(() => false); return session; } @@ -114,8 +109,7 @@ export function updateBrushSession(options: { if (options.session.cancelled) return; if (!(await options.session.ready) || options.session.cancelled) return; - drawStrokeSegment({ - context: options.session.context, + drawBrushSegment(options.session.surface, { from: documentPointToAssetPoint(from, layer, options.session.width, options.session.height), to: documentPointToAssetPoint(to, layer, options.session.width, options.session.height), color: state.editor.maskEdit ? "#ffffff" : options.color, @@ -137,7 +131,7 @@ export async function commitBrushSession(options: { store: AppStore; session: Br await options.session.pending; if (options.session.cancelled) return; - const source = options.session.changed ? canvasToDataUrl(options.session.canvas) : undefined; + const source = options.session.changed ? brushSurfaceDataUrl(options.session.surface) : undefined; if (source) { const state = options.store.getState(); const maskEdit = state.editor.maskEdit; @@ -164,48 +158,13 @@ function documentPointToAssetPoint(point: Vec2D, layer: RasterLayer, width: numb }; } -async function initializeBrushSession(session: BrushSession, source: string) { - const image = await loadImage(source); - if (session.cancelled) return false; - - session.context.clearRect(0, 0, session.width, session.height); - session.context.drawImage(image, 0, 0, session.width, session.height); - return true; -} - -function drawStrokeSegment(options: { - context: CanvasRenderingContext2D; - from: Vec2D; - to: Vec2D; - color: string; - size: number; - hardness: number; - mode: "brush" | "eraser"; -}) { - const { context } = options; - const hardness = Math.max(0, Math.min(100, options.hardness)) / 100; - context.save(); - context.globalCompositeOperation = options.mode === "eraser" ? "destination-out" : "source-over"; - context.strokeStyle = options.color; - context.shadowColor = options.mode === "eraser" ? "rgba(0,0,0,1)" : options.color; - context.shadowBlur = (1 - hardness) * options.size; - context.lineWidth = options.size; - context.lineCap = "round"; - context.lineJoin = "round"; - context.beginPath(); - context.moveTo(options.from.x, options.from.y); - context.lineTo(options.to.x, options.to.y); - context.stroke(); - context.restore(); -} - function requestBrushStrokePreview(options: { store: AppStore; session: BrushSession }) { if (options.session.cancelled || options.session.previewClosed) return; options.session.previewRequested = true; if (options.session.previewFrame !== undefined || options.session.previewInFlight) return; - options.session.previewFrame = requestAnimationFrame(() => { + options.session.previewFrame = scheduleFrame(() => { options.session.previewFrame = undefined; void publishBrushStrokePreview(options); }); @@ -216,7 +175,7 @@ async function publishBrushStrokePreview(options: { store: AppStore; session: Br options.session.previewRequested = false; options.session.previewInFlight = true; - const source = await canvasToObjectUrl(options.session.canvas).catch(() => undefined); + const source = await brushSurfaceObjectUrl(options.session.surface).catch(() => undefined); options.session.previewInFlight = false; if (!source) { @@ -225,14 +184,14 @@ async function publishBrushStrokePreview(options: { store: AppStore; session: Br } if (options.session.cancelled || options.session.previewClosed) { - URL.revokeObjectURL(source); + releaseObjectUrl(source); return; } const previousSource = options.session.previewSource; options.session.previewSource = source; options.store.dispatch(commandIds.toolSetBrushStrokePreview, { layerId: options.session.layerId, assetId: options.session.assetId, source }); - if (previousSource) URL.revokeObjectURL(previousSource); + releaseObjectUrl(previousSource); if (options.session.previewRequested) requestBrushStrokePreview(options); } @@ -240,40 +199,15 @@ async function publishBrushStrokePreview(options: { store: AppStore; session: Br function closeBrushStrokePreview(session: BrushSession) { session.previewClosed = true; if (session.previewFrame !== undefined) { - cancelAnimationFrame(session.previewFrame); + cancelFrame(session.previewFrame); session.previewFrame = undefined; } if (session.previewSource) { - URL.revokeObjectURL(session.previewSource); + releaseObjectUrl(session.previewSource); session.previewSource = undefined; } } -function canvasToObjectUrl(canvas: HTMLCanvasElement) { - return new Promise((resolve) => { - canvas.toBlob((blob) => { - resolve(blob ? URL.createObjectURL(blob) : undefined); - }, "image/png"); - }); -} - -function canvasToDataUrl(canvas: HTMLCanvasElement) { - try { - return canvas.toDataURL("image/png"); - } catch { - return undefined; - } -} - -function loadImage(source: string) { - return new Promise((resolve, reject) => { - const image = new Image(); - image.onload = () => resolve(image); - image.onerror = () => reject(new Error("Failed to load raster layer")); - image.src = source; - }); -} - function findRasterLayer(layers: Layer[], layerId: string): RasterLayer | undefined { const layer = findLayer(layers, layerId); return layer?.type === "raster" ? layer : undefined; diff --git a/platform/AGENTS.md b/platform/AGENTS.md new file mode 100644 index 0000000..1c3285a --- /dev/null +++ b/platform/AGENTS.md @@ -0,0 +1,6 @@ +# Platform Adapter Rules + +- `platform/` implements browser or runtime capabilities behind focused functions and interfaces. +- Browser raster, image decoding, object URLs, downloads, and HTTP clients belong here. +- Platform adapters do not own application state and do not dispatch commands. +- Keep business rules, document traversal, and editor workflow decisions out of this layer. diff --git a/platform/browser/brushRaster.ts b/platform/browser/brushRaster.ts new file mode 100644 index 0000000..1932021 --- /dev/null +++ b/platform/browser/brushRaster.ts @@ -0,0 +1,29 @@ +export type BrushSurface = { + readonly width: number; + readonly height: number; + readonly ready: Promise; + readonly resource: object; +}; + +type InternalSurface = BrushSurface & { canvas: HTMLCanvasElement; context: CanvasRenderingContext2D }; + +export function createBrushSurface(width: number, height: number, source: string): BrushSurface | undefined { + const canvas = document.createElement("canvas"); canvas.width = Math.max(1, Math.round(width)); canvas.height = Math.max(1, Math.round(height)); + const context = canvas.getContext("2d"); if (!context) return undefined; + const surface = { width: canvas.width, height: canvas.height, canvas, context, resource: {}, ready: Promise.resolve(false) } as InternalSurface; + (surface as { ready: Promise }).ready = loadImage(source).then((image) => { context.clearRect(0, 0, canvas.width, canvas.height); context.drawImage(image, 0, 0, canvas.width, canvas.height); return true; }).catch(() => false); + return surface; +} + +export function drawBrushSegment(surface: BrushSurface, options: { from: { x: number; y: number }; to: { x: number; y: number }; color: string; size: number; hardness: number; mode: "brush" | "eraser" }) { + const context = internal(surface).context; const hardness = Math.max(0, Math.min(100, options.hardness)) / 100; + context.save(); context.globalCompositeOperation = options.mode === "eraser" ? "destination-out" : "source-over"; context.strokeStyle = options.color; context.shadowColor = options.mode === "eraser" ? "rgba(0,0,0,1)" : options.color; context.shadowBlur = (1 - hardness) * options.size; context.lineWidth = options.size; context.lineCap = "round"; context.lineJoin = "round"; context.beginPath(); context.moveTo(options.from.x, options.from.y); context.lineTo(options.to.x, options.to.y); context.stroke(); context.restore(); +} + +export function brushSurfaceDataUrl(surface: BrushSurface) { try { return internal(surface).canvas.toDataURL("image/png"); } catch { return undefined; } } +export function brushSurfaceObjectUrl(surface: BrushSurface) { return new Promise((resolve) => internal(surface).canvas.toBlob((blob) => resolve(blob ? URL.createObjectURL(blob) : undefined), "image/png")); } +export function releaseObjectUrl(source?: string) { if (source) URL.revokeObjectURL(source); } +export function scheduleFrame(callback: () => void) { return requestAnimationFrame(callback); } +export function cancelFrame(id: number) { cancelAnimationFrame(id); } +function internal(surface: BrushSurface) { return surface as InternalSurface; } +function loadImage(source: string) { return new Promise((resolve, reject) => { const image = new Image(); image.onload = () => resolve(image); image.onerror = () => reject(new Error("Failed to load raster layer")); image.src = source; }); } diff --git a/platform/browser/chromaKey.ts b/platform/browser/chromaKey.ts new file mode 100644 index 0000000..ae488f8 --- /dev/null +++ b/platform/browser/chromaKey.ts @@ -0,0 +1,66 @@ +import { blurMaskValues, despeckleMaskValues, dilateMaskValues, erodeMaskValues } from "./maskRaster"; + +type ChromaKeySettings = { color: string; tolerance: number; softness: number; feather: number; choke: number; despeckle: number; spill: number }; + +export async function createChromaKeyPreview(source: string, width: number, height: number, settings: ChromaKeySettings) { + return render(source, width, height, settings, false); +} + +export async function createChromaKeyMask(source: string, width: number, height: number, settings: ChromaKeySettings) { + return render(source, width, height, settings, true); +} + +async function render(source: string, width: number, height: number, settings: ChromaKeySettings, maskOnly: boolean) { + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, Math.round(width)); + canvas.height = Math.max(1, Math.round(height)); + const context = canvas.getContext("2d"); + if (!context) return source; + context.drawImage(await loadImage(source), 0, 0, canvas.width, canvas.height); + const data = context.getImageData(0, 0, canvas.width, canvas.height); + const alpha = chromaKeyAlpha(data, canvas.width, canvas.height, settings); + for (let pixel = 0; pixel < alpha.length; pixel++) { + const index = pixel * 4; + if (maskOnly) data.data[index] = data.data[index + 1] = data.data[index + 2] = 255; + data.data[index + 3] = alpha[pixel] ?? 255; + } + context.putImageData(data, 0, 0); + return canvas.toDataURL("image/png"); +} + +function chromaKeyAlpha(data: ImageData, width: number, height: number, settings: ChromaKeySettings) { + const hex = settings.color.replace("#", ""); + const key = { r: Number.parseInt(hex.slice(0, 2), 16), g: Number.parseInt(hex.slice(2, 4), 16), b: Number.parseInt(hex.slice(4, 6), 16) }; + const alpha = new Uint8ClampedArray(width * height); + for (let pixel = 0; pixel < alpha.length; pixel++) { + const index = pixel * 4; + const red = data.data[index] ?? 0; + const green = data.data[index + 1] ?? 0; + const blue = data.data[index + 2] ?? 0; + const distance = Math.hypot(red - key.r, green - key.g, blue - key.b); + const tolerance = Math.max(0, Math.min(255, settings.tolerance)); + const softness = Math.max(0, Math.min(255, settings.softness)); + const edgeKeep = distance <= tolerance ? 0 : softness > 0 && distance < tolerance + softness ? (distance - tolerance) / softness : 1; + const dominant = key.g >= key.r && key.g >= key.b ? green : key.r >= key.b ? red : blue; + const neutral = key.g >= key.r && key.g >= key.b ? Math.max(red, blue) : key.r >= key.b ? Math.max(green, blue) : Math.max(red, green); + const spillKeep = 1 - Math.max(0, dominant - neutral) / 255 * Math.max(0, Math.min(100, settings.spill)) / 100; + alpha[pixel] = Math.round((data.data[index + 3] ?? 255) * Math.max(0, Math.min(edgeKeep, spillKeep))); + } + let next: Uint8ClampedArray = alpha; + const despeckle = Math.round(Math.max(0, Math.min(20, settings.despeckle))); + const choke = Math.round(Math.max(-20, Math.min(20, settings.choke))); + const feather = Math.round(Math.max(0, Math.min(20, settings.feather))); + if (despeckle > 0) next = despeckleMaskValues(next, width, height, despeckle); + if (choke > 0) next = erodeMaskValues(next, width, height, choke); + if (choke < 0) next = dilateMaskValues(next, width, height, -choke); + return feather > 0 ? blurMaskValues(next, width, height, feather) : next; +} + +function loadImage(source: string) { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => reject(new Error("Failed to load image")); + image.src = source; + }); +} diff --git a/view/exportArtboardPng.ts b/platform/browser/exportArtboardPng.ts similarity index 100% rename from view/exportArtboardPng.ts rename to platform/browser/exportArtboardPng.ts diff --git a/platform/browser/imageFiles.ts b/platform/browser/imageFiles.ts new file mode 100644 index 0000000..b3cbb90 --- /dev/null +++ b/platform/browser/imageFiles.ts @@ -0,0 +1,32 @@ +export type BrowserImageFile = { + name: string; + mimeType: string; + source: string; + intrinsicSize: { w: number; h: number }; + release(): void; +}; + +export async function decodeBrowserImageFile(file: File): Promise { + if (!file.type.startsWith("image/")) return undefined; + const source = await fileToDataUrl(file); + const intrinsicSize = await loadImageSize(source); + return { name: file.name, mimeType: file.type, source, intrinsicSize, release: () => undefined }; +} + +function fileToDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => typeof reader.result === "string" ? resolve(reader.result) : reject(new Error("Failed to read image file")); + reader.onerror = () => reject(reader.error ?? new Error("Failed to read image file")); + reader.readAsDataURL(file); + }); +} + +function loadImageSize(source: string): Promise<{ w: number; h: number }> { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve({ w: image.naturalWidth, h: image.naturalHeight }); + image.onerror = () => reject(new Error("Failed to load image")); + image.src = source; + }); +} diff --git a/platform/browser/imageRaster.ts b/platform/browser/imageRaster.ts new file mode 100644 index 0000000..d5b8d6d --- /dev/null +++ b/platform/browser/imageRaster.ts @@ -0,0 +1,24 @@ +export async function imageSourceToDataUrl(source: string): Promise { + if (source.startsWith("data:")) return source; + const image = await loadImage(source); + const canvas = document.createElement("canvas"); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const context = canvas.getContext("2d"); + if (!context) throw new Error("Unable to read selected image"); + context.drawImage(image, 0, 0); + return canvas.toDataURL("image/png"); +} + +export function loadImageSize(source: string): Promise<{ w: number; h: number }> { + return loadImage(source).then((image) => ({ w: image.naturalWidth, h: image.naturalHeight })); +} + +function loadImage(source: string): Promise { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => reject(new Error("Failed to load image")); + image.src = source; + }); +} diff --git a/platform/browser/magicWandRaster.ts b/platform/browser/magicWandRaster.ts new file mode 100644 index 0000000..1a39b6e --- /dev/null +++ b/platform/browser/magicWandRaster.ts @@ -0,0 +1,38 @@ +import { blurMaskValues, despeckleMaskValues, dilateMaskValues, erodeMaskValues, maskValueFromRgba } from "./maskRaster"; + +export type MagicWandRasterSettings = { tolerance: number; feather: number; choke: number; despeckle: number; contiguous: boolean; mode: "replace" | "add" | "subtract" }; + +export async function createWandMask(source: string, existingMaskSource: string | undefined, width: number, height: number, startX: number, startY: number, settings: MagicWandRasterSettings) { + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, width); canvas.height = Math.max(1, height); + const context = canvas.getContext("2d"); + if (!context) return source; + context.drawImage(await loadImage(source), 0, 0, canvas.width, canvas.height); + const data = context.getImageData(0, 0, canvas.width, canvas.height); + const start = (startY * canvas.width + startX) * 4; + const key = [data.data[start] ?? 0, data.data[start + 1] ?? 0, data.data[start + 2] ?? 0]; + const selected = settings.contiguous ? floodSelect(data, width, height, startX, startY, key, settings.tolerance) : globalSelect(data, key, settings.tolerance); + let values: Uint8ClampedArray = toValues(selected); + if (settings.despeckle > 0) values = despeckleMaskValues(values, width, height, Math.round(settings.despeckle)); + if (settings.choke > 0) values = erodeMaskValues(values, width, height, Math.round(settings.choke)); + if (settings.choke < 0) values = dilateMaskValues(values, width, height, Math.round(-settings.choke)); + if (settings.feather > 0) values = blurMaskValues(values, width, height, Math.round(settings.feather)); + const existing = existingMaskSource ? await loadMask(existingMaskSource, width, height) : undefined; + for (let pixel = 0; pixel < values.length; pixel++) { + const current = existing?.[pixel] ?? 255; const selectedValue = values[pixel] ?? 0; + const alpha = settings.mode === "add" ? Math.min(current, 255 - selectedValue) : settings.mode === "subtract" ? Math.max(current, selectedValue) : 255 - selectedValue; + const index = pixel * 4; data.data[index] = data.data[index + 1] = data.data[index + 2] = 255; data.data[index + 3] = alpha; + } + context.putImageData(data, 0, 0); return canvas.toDataURL("image/png"); +} + +function floodSelect(data: ImageData, width: number, height: number, x: number, y: number, key: number[], tolerance: number) { + const result = new Uint8Array(width * height); const queue: Array<[number, number]> = [[x, y]]; + while (queue.length) { const [px, py] = queue.pop()!; if (px < 0 || py < 0 || px >= width || py >= height) continue; const i = py * width + px; if (result[i] || !matches(data, i, key, tolerance)) continue; result[i] = 1; queue.push([px + 1, py], [px - 1, py], [px, py + 1], [px, py - 1]); } + return result; +} +function globalSelect(data: ImageData, key: number[], tolerance: number) { const result = new Uint8Array(data.width * data.height); for (let i = 0; i < result.length; i++) if (matches(data, i, key, tolerance)) result[i] = 1; return result; } +function matches(data: ImageData, pixel: number, key: number[], tolerance: number) { const i = pixel * 4; return Math.hypot((data.data[i] ?? 0) - (key[0] ?? 0), (data.data[i + 1] ?? 0) - (key[1] ?? 0), (data.data[i + 2] ?? 0) - (key[2] ?? 0)) <= tolerance; } +function toValues(selected: Uint8Array) { const values = new Uint8ClampedArray(selected.length); for (let i = 0; i < selected.length; i++) values[i] = selected[i] ? 255 : 0; return values; } +async function loadMask(source: string, width: number, height: number) { const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const context = canvas.getContext("2d"); if (!context) return undefined; context.drawImage(await loadImage(source), 0, 0, width, height); const data = context.getImageData(0, 0, width, height); const values = new Uint8ClampedArray(width * height); for (let i = 0; i < values.length; i++) values[i] = maskValueFromRgba(data.data, i * 4); return values; } +function loadImage(source: string) { return new Promise((resolve, reject) => { const image = new Image(); image.onload = () => resolve(image); image.onerror = () => reject(new Error("Failed to load image")); image.src = source; }); } diff --git a/view/mask/maskRaster.test.ts b/platform/browser/maskRaster.test.ts similarity index 100% rename from view/mask/maskRaster.test.ts rename to platform/browser/maskRaster.test.ts diff --git a/view/mask/maskRaster.ts b/platform/browser/maskRaster.ts similarity index 100% rename from view/mask/maskRaster.ts rename to platform/browser/maskRaster.ts diff --git a/platform/comfy/generationClient.ts b/platform/comfy/generationClient.ts new file mode 100644 index 0000000..e0bb41b --- /dev/null +++ b/platform/comfy/generationClient.ts @@ -0,0 +1,11 @@ +export async function fetchGenerationOptions(): Promise { + const response = await fetch("/api/comfy/models"); + if (!response.ok) throw new Error("Unable to load ComfyUI models"); + return response.json() as Promise; +} + +export async function requestGeneration(body: unknown): Promise<{ source: string; mimeType: string }> { + const response = await fetch("/api/comfy/generate", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); + if (!response.ok) throw new Error(await response.text()); + return response.json() as Promise<{ source: string; mimeType: string }>; +} diff --git a/renderer/image-texture-programs.ts b/renderer/image-texture-programs.ts new file mode 100644 index 0000000..c4b3186 --- /dev/null +++ b/renderer/image-texture-programs.ts @@ -0,0 +1,324 @@ +import type { MaskVisualizationMode } from "./image-textures"; + +export type MaskVisualizationResources = { + program: WebGLProgram; + positionLocation: number; + texCoordLocation: number; + samplerLocation: WebGLUniformLocation; + modeLocation: WebGLUniformLocation; + colorLocation: WebGLUniformLocation; +}; + +export function createProgram(gl: WebGL2RenderingContext) { + const vertexShader = compileShader( + gl, + gl.VERTEX_SHADER, + `#version 300 es + in vec2 a_position; + in vec2 a_texCoord; + out vec2 v_texCoord; + void main() { + gl_Position = vec4(a_position, 0.0, 1.0); + v_texCoord = a_texCoord; + }`, + ); + const fragmentShader = compileShader( + gl, + gl.FRAGMENT_SHADER, + `#version 300 es + precision mediump float; + uniform sampler2D u_image; + uniform float u_opacity; + in vec2 v_texCoord; + out vec4 outColor; + void main() { + outColor = texture(u_image, v_texCoord) * u_opacity; + }`, + ); + const program = gl.createProgram(); + if (!program) throw new Error("Failed to create image shader program"); + + gl.attachShader(program, vertexShader); + gl.attachShader(program, fragmentShader); + gl.linkProgram(program); + gl.deleteShader(vertexShader); + gl.deleteShader(fragmentShader); + + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + const message = gl.getProgramInfoLog(program) ?? "Unknown program link error"; + gl.deleteProgram(program); + throw new Error(message); + } + + return program; +} + +export function getMaskVisualizationResources( + gl: WebGL2RenderingContext, + getResources: () => MaskVisualizationResources | "failed" | undefined, + setResources: (resources: MaskVisualizationResources | "failed") => void, +): MaskVisualizationResources | undefined { + const currentResources = getResources(); + if (currentResources === "failed") return undefined; + if (currentResources) return currentResources; + + try { + const resources = createMaskVisualizationProgram(gl); + setResources(resources); + return resources; + } catch { + setResources("failed"); + return undefined; + } +} + +export function maskVisualizationModeValue(mode: MaskVisualizationMode) { + switch (mode) { + case "blackWhite": + return 0; + case "alpha": + return 1; + case "hiddenOverlay": + return 2; + } +} + +function createMaskVisualizationProgram(gl: WebGL2RenderingContext): MaskVisualizationResources { + const vertexShader = compileShader( + gl, + gl.VERTEX_SHADER, + `#version 300 es + in vec2 a_position; + in vec2 a_texCoord; + out vec2 v_texCoord; + void main() { + gl_Position = vec4(a_position, 0.0, 1.0); + v_texCoord = a_texCoord; + }`, + ); + const fragmentShader = compileShader( + gl, + gl.FRAGMENT_SHADER, + `#version 300 es + precision mediump float; + uniform sampler2D u_mask; + uniform int u_mode; + uniform vec4 u_color; + in vec2 v_texCoord; + out vec4 outColor; + void main() { + vec4 maskColor = texture(u_mask, v_texCoord); + float maskAlpha = maskColor.a * dot(maskColor.rgb, vec3(0.2126, 0.7152, 0.0722)); + if (u_mode == 0) { + float value = step(0.5, maskAlpha); + outColor = vec4(value, value, value, 1.0); + return; + } + if (u_mode == 1) { + outColor = vec4(maskAlpha, maskAlpha, maskAlpha, 1.0); + return; + } + + float alpha = (1.0 - maskAlpha) * u_color.a; + outColor = vec4(u_color.rgb * alpha, alpha); + }`, + ); + const program = gl.createProgram(); + if (!program) throw new Error("Failed to create mask visualization shader program"); + + gl.attachShader(program, vertexShader); + gl.attachShader(program, fragmentShader); + gl.linkProgram(program); + gl.deleteShader(vertexShader); + gl.deleteShader(fragmentShader); + + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + const message = gl.getProgramInfoLog(program) ?? "Unknown mask visualization program link error"; + gl.deleteProgram(program); + throw new Error(message); + } + + const samplerLocation = gl.getUniformLocation(program, "u_mask"); + const modeLocation = gl.getUniformLocation(program, "u_mode"); + const colorLocation = gl.getUniformLocation(program, "u_color"); + if (!samplerLocation || !modeLocation || !colorLocation) { + gl.deleteProgram(program); + throw new Error("Failed to resolve mask visualization shader uniforms"); + } + + return { + program, + positionLocation: gl.getAttribLocation(program, "a_position"), + texCoordLocation: gl.getAttribLocation(program, "a_texCoord"), + samplerLocation, + modeLocation, + colorLocation, + }; +} + +export function createTintedProgram(gl: WebGL2RenderingContext) { + const vertexShader = compileShader( + gl, + gl.VERTEX_SHADER, + `#version 300 es + in vec2 a_position; + in vec2 a_texCoord; + out vec2 v_texCoord; + void main() { + gl_Position = vec4(a_position, 0.0, 1.0); + v_texCoord = a_texCoord; + }`, + ); + const fragmentShader = compileShader( + gl, + gl.FRAGMENT_SHADER, + `#version 300 es + precision mediump float; + uniform sampler2D u_image; + uniform vec4 u_color; + in vec2 v_texCoord; + out vec4 outColor; + void main() { + float alpha = u_color.a * texture(u_image, v_texCoord).a; + outColor = vec4(u_color.rgb * alpha, alpha); + }`, + ); + const program = gl.createProgram(); + if (!program) throw new Error("Failed to create tinted image shader program"); + + gl.attachShader(program, vertexShader); + gl.attachShader(program, fragmentShader); + gl.linkProgram(program); + gl.deleteShader(vertexShader); + gl.deleteShader(fragmentShader); + + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + const message = gl.getProgramInfoLog(program) ?? "Unknown tinted program link error"; + gl.deleteProgram(program); + throw new Error(message); + } + + return program; +} + +export function createMaskRevealPreviewProgram(gl: WebGL2RenderingContext) { + const vertexShader = compileShader( + gl, + gl.VERTEX_SHADER, + `#version 300 es + in vec2 a_position; + in vec2 a_texCoord; + in vec2 a_maskTexCoord; + out vec2 v_texCoord; + out vec2 v_maskTexCoord; + void main() { + gl_Position = vec4(a_position, 0.0, 1.0); + v_texCoord = a_texCoord; + v_maskTexCoord = a_maskTexCoord; + }`, + ); + const fragmentShader = compileShader( + gl, + gl.FRAGMENT_SHADER, + `#version 300 es + precision mediump float; + uniform sampler2D u_image; + uniform sampler2D u_mask; + uniform float u_opacity; + in vec2 v_texCoord; + in vec2 v_maskTexCoord; + out vec4 outColor; + void main() { + vec4 color = texture(u_image, v_texCoord); + vec4 maskColor = texture(u_mask, v_maskTexCoord); + float maskAlpha = maskColor.a * dot(maskColor.rgb, vec3(0.2126, 0.7152, 0.0722)); + float hiddenMaskAlpha = 1.0 - maskAlpha; + float previewAlpha = clamp(hiddenMaskAlpha * u_opacity, 0.0, 1.0); + outColor = vec4(color.rgb * previewAlpha, color.a * previewAlpha); + }`, + ); + const program = gl.createProgram(); + if (!program) throw new Error("Failed to create mask reveal preview shader program"); + + gl.attachShader(program, vertexShader); + gl.attachShader(program, fragmentShader); + gl.linkProgram(program); + gl.deleteShader(vertexShader); + gl.deleteShader(fragmentShader); + + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + const message = gl.getProgramInfoLog(program) ?? "Unknown mask reveal preview program link error"; + gl.deleteProgram(program); + throw new Error(message); + } + + return program; +} + +export function createMaskedProgram(gl: WebGL2RenderingContext) { + const vertexShader = compileShader( + gl, + gl.VERTEX_SHADER, + `#version 300 es + in vec2 a_position; + in vec2 a_texCoord; + in vec2 a_maskTexCoord; + out vec2 v_texCoord; + out vec2 v_maskTexCoord; + void main() { + gl_Position = vec4(a_position, 0.0, 1.0); + v_texCoord = a_texCoord; + v_maskTexCoord = a_maskTexCoord; + }`, + ); + const fragmentShader = compileShader( + gl, + gl.FRAGMENT_SHADER, + `#version 300 es + precision mediump float; + uniform sampler2D u_image; + uniform sampler2D u_mask; + uniform float u_opacity; + in vec2 v_texCoord; + in vec2 v_maskTexCoord; + out vec4 outColor; + void main() { + vec4 color = texture(u_image, v_texCoord); + vec4 maskColor = texture(u_mask, v_maskTexCoord); + float maskAlpha = maskColor.a * dot(maskColor.rgb, vec3(0.2126, 0.7152, 0.0722)); + float alpha = color.a * maskAlpha; + outColor = vec4(color.rgb * maskAlpha, alpha) * u_opacity; + }`, + ); + const program = gl.createProgram(); + if (!program) throw new Error("Failed to create masked image shader program"); + + gl.attachShader(program, vertexShader); + gl.attachShader(program, fragmentShader); + gl.linkProgram(program); + gl.deleteShader(vertexShader); + gl.deleteShader(fragmentShader); + + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + const message = gl.getProgramInfoLog(program) ?? "Unknown masked program link error"; + gl.deleteProgram(program); + throw new Error(message); + } + + return program; +} + +function compileShader(gl: WebGL2RenderingContext, type: number, source: string) { + const shader = gl.createShader(type); + if (!shader) throw new Error("Failed to create shader"); + + gl.shaderSource(shader, source); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + const message = gl.getShaderInfoLog(shader) ?? "Unknown shader compile error"; + gl.deleteShader(shader); + throw new Error(message); + } + + return shader; +} diff --git a/renderer/image-textures.ts b/renderer/image-textures.ts index 56d5272..b9dc7b7 100644 --- a/renderer/image-textures.ts +++ b/renderer/image-textures.ts @@ -1,3 +1,4 @@ +import { createMaskedProgram, createMaskRevealPreviewProgram, createProgram, createTintedProgram, getMaskVisualizationResources, maskVisualizationModeValue, type MaskVisualizationResources } from "./image-texture-programs"; import type { Asset } from "@core/asset"; import type { RgbaColor, ScreenRect, WebGlRendererContext } from "./types"; @@ -24,15 +25,6 @@ type TextureEntry = { previousTexture?: WebGLTexture; }; -type MaskVisualizationResources = { - program: WebGLProgram; - positionLocation: number; - texCoordLocation: number; - samplerLocation: WebGLUniformLocation; - modeLocation: WebGLUniformLocation; - colorLocation: WebGLUniformLocation; -}; - export function createImageTextureRenderer(context: WebGlRendererContext, invalidate: () => void): ImageTextureRenderer { const { gl } = context; const program = createProgram(gl); @@ -438,317 +430,3 @@ function rectVertices(canvas: HTMLCanvasElement, rect: ScreenRect) { return new Float32Array([x1, y1, x2, y1, x1, y2, x1, y2, x2, y1, x2, y2]); } - -function createProgram(gl: WebGL2RenderingContext) { - const vertexShader = compileShader( - gl, - gl.VERTEX_SHADER, - `#version 300 es - in vec2 a_position; - in vec2 a_texCoord; - out vec2 v_texCoord; - void main() { - gl_Position = vec4(a_position, 0.0, 1.0); - v_texCoord = a_texCoord; - }`, - ); - const fragmentShader = compileShader( - gl, - gl.FRAGMENT_SHADER, - `#version 300 es - precision mediump float; - uniform sampler2D u_image; - uniform float u_opacity; - in vec2 v_texCoord; - out vec4 outColor; - void main() { - outColor = texture(u_image, v_texCoord) * u_opacity; - }`, - ); - const program = gl.createProgram(); - if (!program) throw new Error("Failed to create image shader program"); - - gl.attachShader(program, vertexShader); - gl.attachShader(program, fragmentShader); - gl.linkProgram(program); - gl.deleteShader(vertexShader); - gl.deleteShader(fragmentShader); - - if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { - const message = gl.getProgramInfoLog(program) ?? "Unknown program link error"; - gl.deleteProgram(program); - throw new Error(message); - } - - return program; -} - -function getMaskVisualizationResources( - gl: WebGL2RenderingContext, - getResources: () => MaskVisualizationResources | "failed" | undefined, - setResources: (resources: MaskVisualizationResources | "failed") => void, -): MaskVisualizationResources | undefined { - const currentResources = getResources(); - if (currentResources === "failed") return undefined; - if (currentResources) return currentResources; - - try { - const resources = createMaskVisualizationProgram(gl); - setResources(resources); - return resources; - } catch { - setResources("failed"); - return undefined; - } -} - -function maskVisualizationModeValue(mode: MaskVisualizationMode) { - switch (mode) { - case "blackWhite": - return 0; - case "alpha": - return 1; - case "hiddenOverlay": - return 2; - } -} - -function createMaskVisualizationProgram(gl: WebGL2RenderingContext): MaskVisualizationResources { - const vertexShader = compileShader( - gl, - gl.VERTEX_SHADER, - `#version 300 es - in vec2 a_position; - in vec2 a_texCoord; - out vec2 v_texCoord; - void main() { - gl_Position = vec4(a_position, 0.0, 1.0); - v_texCoord = a_texCoord; - }`, - ); - const fragmentShader = compileShader( - gl, - gl.FRAGMENT_SHADER, - `#version 300 es - precision mediump float; - uniform sampler2D u_mask; - uniform int u_mode; - uniform vec4 u_color; - in vec2 v_texCoord; - out vec4 outColor; - void main() { - vec4 maskColor = texture(u_mask, v_texCoord); - float maskAlpha = maskColor.a * dot(maskColor.rgb, vec3(0.2126, 0.7152, 0.0722)); - if (u_mode == 0) { - float value = step(0.5, maskAlpha); - outColor = vec4(value, value, value, 1.0); - return; - } - if (u_mode == 1) { - outColor = vec4(maskAlpha, maskAlpha, maskAlpha, 1.0); - return; - } - - float alpha = (1.0 - maskAlpha) * u_color.a; - outColor = vec4(u_color.rgb * alpha, alpha); - }`, - ); - const program = gl.createProgram(); - if (!program) throw new Error("Failed to create mask visualization shader program"); - - gl.attachShader(program, vertexShader); - gl.attachShader(program, fragmentShader); - gl.linkProgram(program); - gl.deleteShader(vertexShader); - gl.deleteShader(fragmentShader); - - if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { - const message = gl.getProgramInfoLog(program) ?? "Unknown mask visualization program link error"; - gl.deleteProgram(program); - throw new Error(message); - } - - const samplerLocation = gl.getUniformLocation(program, "u_mask"); - const modeLocation = gl.getUniformLocation(program, "u_mode"); - const colorLocation = gl.getUniformLocation(program, "u_color"); - if (!samplerLocation || !modeLocation || !colorLocation) { - gl.deleteProgram(program); - throw new Error("Failed to resolve mask visualization shader uniforms"); - } - - return { - program, - positionLocation: gl.getAttribLocation(program, "a_position"), - texCoordLocation: gl.getAttribLocation(program, "a_texCoord"), - samplerLocation, - modeLocation, - colorLocation, - }; -} - -function createTintedProgram(gl: WebGL2RenderingContext) { - const vertexShader = compileShader( - gl, - gl.VERTEX_SHADER, - `#version 300 es - in vec2 a_position; - in vec2 a_texCoord; - out vec2 v_texCoord; - void main() { - gl_Position = vec4(a_position, 0.0, 1.0); - v_texCoord = a_texCoord; - }`, - ); - const fragmentShader = compileShader( - gl, - gl.FRAGMENT_SHADER, - `#version 300 es - precision mediump float; - uniform sampler2D u_image; - uniform vec4 u_color; - in vec2 v_texCoord; - out vec4 outColor; - void main() { - float alpha = u_color.a * texture(u_image, v_texCoord).a; - outColor = vec4(u_color.rgb * alpha, alpha); - }`, - ); - const program = gl.createProgram(); - if (!program) throw new Error("Failed to create tinted image shader program"); - - gl.attachShader(program, vertexShader); - gl.attachShader(program, fragmentShader); - gl.linkProgram(program); - gl.deleteShader(vertexShader); - gl.deleteShader(fragmentShader); - - if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { - const message = gl.getProgramInfoLog(program) ?? "Unknown tinted program link error"; - gl.deleteProgram(program); - throw new Error(message); - } - - return program; -} - -function createMaskRevealPreviewProgram(gl: WebGL2RenderingContext) { - const vertexShader = compileShader( - gl, - gl.VERTEX_SHADER, - `#version 300 es - in vec2 a_position; - in vec2 a_texCoord; - in vec2 a_maskTexCoord; - out vec2 v_texCoord; - out vec2 v_maskTexCoord; - void main() { - gl_Position = vec4(a_position, 0.0, 1.0); - v_texCoord = a_texCoord; - v_maskTexCoord = a_maskTexCoord; - }`, - ); - const fragmentShader = compileShader( - gl, - gl.FRAGMENT_SHADER, - `#version 300 es - precision mediump float; - uniform sampler2D u_image; - uniform sampler2D u_mask; - uniform float u_opacity; - in vec2 v_texCoord; - in vec2 v_maskTexCoord; - out vec4 outColor; - void main() { - vec4 color = texture(u_image, v_texCoord); - vec4 maskColor = texture(u_mask, v_maskTexCoord); - float maskAlpha = maskColor.a * dot(maskColor.rgb, vec3(0.2126, 0.7152, 0.0722)); - float hiddenMaskAlpha = 1.0 - maskAlpha; - float previewAlpha = clamp(hiddenMaskAlpha * u_opacity, 0.0, 1.0); - outColor = vec4(color.rgb * previewAlpha, color.a * previewAlpha); - }`, - ); - const program = gl.createProgram(); - if (!program) throw new Error("Failed to create mask reveal preview shader program"); - - gl.attachShader(program, vertexShader); - gl.attachShader(program, fragmentShader); - gl.linkProgram(program); - gl.deleteShader(vertexShader); - gl.deleteShader(fragmentShader); - - if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { - const message = gl.getProgramInfoLog(program) ?? "Unknown mask reveal preview program link error"; - gl.deleteProgram(program); - throw new Error(message); - } - - return program; -} - -function createMaskedProgram(gl: WebGL2RenderingContext) { - const vertexShader = compileShader( - gl, - gl.VERTEX_SHADER, - `#version 300 es - in vec2 a_position; - in vec2 a_texCoord; - in vec2 a_maskTexCoord; - out vec2 v_texCoord; - out vec2 v_maskTexCoord; - void main() { - gl_Position = vec4(a_position, 0.0, 1.0); - v_texCoord = a_texCoord; - v_maskTexCoord = a_maskTexCoord; - }`, - ); - const fragmentShader = compileShader( - gl, - gl.FRAGMENT_SHADER, - `#version 300 es - precision mediump float; - uniform sampler2D u_image; - uniform sampler2D u_mask; - uniform float u_opacity; - in vec2 v_texCoord; - in vec2 v_maskTexCoord; - out vec4 outColor; - void main() { - vec4 color = texture(u_image, v_texCoord); - vec4 maskColor = texture(u_mask, v_maskTexCoord); - float maskAlpha = maskColor.a * dot(maskColor.rgb, vec3(0.2126, 0.7152, 0.0722)); - float alpha = color.a * maskAlpha; - outColor = vec4(color.rgb * maskAlpha, alpha) * u_opacity; - }`, - ); - const program = gl.createProgram(); - if (!program) throw new Error("Failed to create masked image shader program"); - - gl.attachShader(program, vertexShader); - gl.attachShader(program, fragmentShader); - gl.linkProgram(program); - gl.deleteShader(vertexShader); - gl.deleteShader(fragmentShader); - - if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { - const message = gl.getProgramInfoLog(program) ?? "Unknown masked program link error"; - gl.deleteProgram(program); - throw new Error(message); - } - - return program; -} - -function compileShader(gl: WebGL2RenderingContext, type: number, source: string) { - const shader = gl.createShader(type); - if (!shader) throw new Error("Failed to create shader"); - - gl.shaderSource(shader, source); - gl.compileShader(shader); - if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { - const message = gl.getShaderInfoLog(shader) ?? "Unknown shader compile error"; - gl.deleteShader(shader); - throw new Error(message); - } - - return shader; -} diff --git a/server/AGENTS.md b/server/AGENTS.md new file mode 100644 index 0000000..90c445c --- /dev/null +++ b/server/AGENTS.md @@ -0,0 +1,6 @@ +# Server Integration Rules + +- `server/` owns HTTP routes and external backend integrations. +- Keep route parsing, backend clients, environment configuration, polling, and server workflow construction here. +- Do not import React, view modules, editor stores, renderer modules, or browser-only adapters. +- Keep route wiring small and separate from backend-specific clients and workflow builders. diff --git a/server/comfy-routes.ts b/server/comfy-routes.ts new file mode 100644 index 0000000..f49112b --- /dev/null +++ b/server/comfy-routes.ts @@ -0,0 +1,16 @@ +import { generate, listGenerationOptions, type ComfyGenerateRequest } from "./comfy"; + +export async function handleComfyApi(request: Request) { + try { + const url = new URL(request.url); + if (url.pathname === "/api/comfy/models" && request.method === "GET") return json(await listGenerationOptions()); + if (url.pathname === "/api/comfy/generate" && request.method === "POST") return json(await generate(await request.json() as ComfyGenerateRequest)); + return new Response("Not found", { status: 404 }); + } catch (error) { + return new Response(error instanceof Error ? error.message : "ComfyUI request failed", { status: 500 }); + } +} + +function json(value: unknown) { + return new Response(JSON.stringify(value), { headers: { "content-type": "application/json" } }); +} diff --git a/app/comfy.test.ts b/server/comfy.test.ts similarity index 98% rename from app/comfy.test.ts rename to server/comfy.test.ts index 29a8e34..50550d9 100644 --- a/app/comfy.test.ts +++ b/server/comfy.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { buildAnimaWorkflow, buildSdxlWorkflow, buildZImageTurboWorkflow, buildZImageWorkflow, handleComfyApi, selectGeneratedOutputImage } from "./comfy"; +import { buildAnimaWorkflow, buildSdxlWorkflow, buildZImageTurboWorkflow, buildZImageWorkflow, selectGeneratedOutputImage } from "./comfy"; +import { handleComfyApi } from "./comfy-routes"; describe("Comfy adapter", () => { test("selects SaveImage output instead of uploaded input or mask images", () => { diff --git a/app/comfy.ts b/server/comfy.ts similarity index 96% rename from app/comfy.ts rename to server/comfy.ts index 952a0c1..bae4ba6 100644 --- a/app/comfy.ts +++ b/server/comfy.ts @@ -2,7 +2,7 @@ type GenerateArchitecture = "sdxl" | "z-image" | "z-image-turbo" | "anima"; type GenerateMode = "text-to-image" | "image-to-image" | "inpaint" | "outpaint"; type Workflow = Record }>; -type ComfyGenerateRequest = { +export type ComfyGenerateRequest = { architecture?: GenerateArchitecture; mode: GenerateMode; model: string; @@ -58,18 +58,7 @@ const defaultModels: Record = { anima: "anima-base-v1.0.safetensors", }; -export async function handleComfyApi(request: Request) { - try { - const url = new URL(request.url); - if (url.pathname === "/api/comfy/models" && request.method === "GET") return json(await listGenerationOptions()); - if (url.pathname === "/api/comfy/generate" && request.method === "POST") return json(await generate(await request.json() as ComfyGenerateRequest)); - return new Response("Not found", { status: 404 }); - } catch (error) { - return new Response(error instanceof Error ? error.message : "ComfyUI request failed", { status: 500 }); - } -} - -async function listGenerationOptions() { +export async function listGenerationOptions() { const response = await fetch(`${comfyBaseUrl}/object_info`); if (!response.ok) throw new Error(`ComfyUI option lookup failed: ${response.status}`); const info = await response.json() as ComfyObjectInfo; @@ -122,7 +111,7 @@ async function listCheckpointModels() { return (await listGenerationOptions()).models; } -async function generate(request: ComfyGenerateRequest) { +export async function generate(request: ComfyGenerateRequest) { if (!request.prompt?.trim()) throw new Error("Prompt is required"); const architecture = normalizeArchitecture(request.architecture); if (request.mode !== "text-to-image" && architecture !== "sdxl") throw new Error(`${architectureLabel(architecture)} currently supports text-to-image only`); @@ -469,7 +458,3 @@ function formatHistoryMessage(message: unknown): string | undefined { } return eventName; } - -function json(value: unknown) { - return new Response(JSON.stringify(value), { headers: { "content-type": "application/json" } }); -} diff --git a/tsconfig.json b/tsconfig.json index cbf7f6e..e2c80ad 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,7 +30,10 @@ "@commands/*": ["./commands/*"], "@editor/*": ["./editor/*"], "@input/*": ["./input/*"], - "@app/*": ["./app/*"] + "@app/*": ["./app/*"], + "@operations/*": ["./operations/*"], + "@platform/*": ["./platform/*"], + "@server/*": ["./server/*"] }, // Some stricter flags (disabled by default) diff --git a/view/App.tsx b/view/App.tsx index b1b23a8..2b0c1f5 100644 --- a/view/App.tsx +++ b/view/App.tsx @@ -1,23 +1,24 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect } from "react"; import { DownloadSimple, FolderOpen, Sparkle, Stack } from "@phosphor-icons/react"; import type { ImageStudioApp } from "@app/app"; import { commandIds } from "@commands/ids"; import { BottomControlsIsland } from "./BottomControlsIsland"; -import { brushUnavailableHint } from "./canvas/brush"; +import { brushUnavailableHint } from "@operations/paint/brush"; import { CanvasViewport } from "./CanvasViewport"; import { CommandPalette } from "./CommandPalette"; import { GenerateSheet } from "./GenerateSheet"; +import { GenerationJobStatus } from "./GenerationJobStatus"; import { LayersSheet } from "./LayersSheet"; import { ShortcutsDisplay } from "./ShortcutsDisplay"; import { ToolOverlay } from "./ToolOverlay"; import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets"; import type { AppState } from "@editor/state"; -import type { ToolId } from "@editor/tools"; import { handleCommandPaletteKey, handleDeleteSelectionKey, handleHistoryKey, handleToolKey, keybindEventFromKeyboardEvent } from "@input/index"; import { shallowEqual, useAppState } from "./useAppState"; -import { downloadArtboardPng } from "./exportArtboardPng"; +import { downloadArtboardPng } from "@operations/export/downloadArtboard"; import { useImageImport } from "./useImageImport"; import { useViewportActivityIsland } from "./useViewportActivityIsland"; +import { loadGenerationResources } from "@operations/generation/loadResources"; import "./index.css"; export type AppProps = { @@ -26,40 +27,25 @@ export type AppProps = { export function App({ app }: AppProps) { const shellState = useAppState(app.store, selectAppShellState, shallowEqual); - const { document, selection, viewport, tools, generation, commandPalette, transformSession, maskEdit } = shellState; + const { document, selection, viewport, tools, generation, commandPalette, transformSession, maskEdit, workspace } = shellState; const viewportActivityIsland = useViewportActivityIsland(viewport); const imageImport = useImageImport(app.store); - const [layersOpen, setLayersOpen] = useState(false); - const previousGenerateTool = useRef("select"); const transformTarget = transformSession?.target ?? selectedTransformTarget(document, selection); const activeArtboard = document.artboards.find((artboard) => artboard.id === selection.artboardId) ?? document.artboards[0]; - const generateOpen = tools.activeTool === "generate"; + const generateOpen = workspace.panel === "generate"; + const layersOpen = workspace.panel === "layers"; useEffect(() => { - if (tools.activeTool !== "generate") { - previousGenerateTool.current = tools.activeTool; - return; - } - - setLayersOpen(false); - }, [tools.activeTool]); - - const getGenerateReturnTool = useCallback((): ToolId => { - return previousGenerateTool.current === "generate" ? "select" : previousGenerateTool.current; - }, []); + if (generateOpen) void loadGenerationResources(app.store); + }, [app.store, generateOpen]); const openGenerate = useCallback(() => { - const activeTool = app.store.getState().editor.tools.activeTool; - if (activeTool !== "generate") previousGenerateTool.current = activeTool; - app.store.dispatch(commandIds.toolSetActive, { tool: "generate" }); - setLayersOpen(false); + app.store.dispatch(commandIds.workspaceSetPanel, { panel: "generate" }); }, [app.store]); const closeGenerate = useCallback(() => { - if (app.store.getState().editor.tools.activeTool === "generate") { - app.store.dispatch(commandIds.toolSetActive, { tool: getGenerateReturnTool() }); - } - }, [app.store, getGenerateReturnTool]); + app.store.dispatch(commandIds.workspaceSetPanel, { panel: "none" }); + }, [app.store]); const toggleGenerate = useCallback(() => { if (generateOpen) closeGenerate(); @@ -67,19 +53,16 @@ export function App({ app }: AppProps) { }, [closeGenerate, generateOpen, openGenerate]); const openLayers = useCallback(() => { - closeGenerate(); - setLayersOpen(true); - }, [closeGenerate]); + app.store.dispatch(commandIds.workspaceSetPanel, { panel: "layers" }); + }, [app.store]); const closeLayers = useCallback(() => { - setLayersOpen(false); - }, []); + app.store.dispatch(commandIds.workspaceSetPanel, { panel: "none" }); + }, [app.store]); const toggleLayers = useCallback(() => { - const nextOpen = !layersOpen; - if (nextOpen) closeGenerate(); - setLayersOpen(nextOpen); - }, [closeGenerate, layersOpen]); + app.store.dispatch(commandIds.workspaceSetPanel, { panel: layersOpen ? "none" : "layers" }); + }, [app.store, layersOpen]); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -167,6 +150,9 @@ export function App({ app }: AppProps) { />
+ @@ -190,6 +176,7 @@ export function App({ app }: AppProps) {
@@ -243,6 +230,7 @@ type AppShellState = { commandPalette: AppState["editor"]["commandPalette"]; transformSession: AppState["editor"]["transformSession"]; maskEdit: AppState["editor"]["maskEdit"]; + workspace: AppState["editor"]["workspace"]; }; function selectAppShellState(state: AppState): AppShellState { @@ -255,6 +243,7 @@ function selectAppShellState(state: AppState): AppShellState { commandPalette: state.editor.commandPalette, transformSession: state.editor.transformSession, maskEdit: state.editor.maskEdit, + workspace: state.editor.workspace, }; } diff --git a/view/CanvasViewport.tsx b/view/CanvasViewport.tsx index 980c59a..fb405c6 100644 --- a/view/CanvasViewport.tsx +++ b/view/CanvasViewport.tsx @@ -4,7 +4,7 @@ import type { AppState, MaskEditState } from "@editor/state"; import type { AppStore } from "@editor/store"; import type { InteractionMode } from "@editor/tools"; import type { GlobalKeybindConsumer, GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index"; -import { brushUnavailableHint, canPreviewBrush, type BrushTargetEditorState } from "./canvas/brush"; +import { brushUnavailableHint, canPreviewBrush, type BrushTargetEditorState } from "@operations/paint/brush"; import { canvasCursorClass } from "./canvas/cursor"; import { useCanvasInput } from "./canvas/useCanvasInput"; import { useCanvasRenderer } from "./canvas/useCanvasRenderer"; @@ -37,10 +37,10 @@ export function CanvasViewport({ useCanvasRenderer(canvasRef, store); useCanvasResize(canvasRef, store.dispatch); - const input = useCanvasInput(canvasRef, store, inputOptions); + useCanvasInput(canvasRef, store, inputOptions); const brushHint = brushUnavailableHint(cursorState.document, cursorState.editor); const hasBrushPreview = Boolean(cursorState.hasBrushPreview && !brushHint && canPreviewBrush(cursorState.document, cursorState.editor)); - const cursorClass = canvasCursorClass(cursorState.editor.tools.interactionMode, input, hasBrushPreview, !brushHint); + const cursorClass = canvasCursorClass(cursorState.editor.tools.interactionMode, cursorState.isPanning, hasBrushPreview, !brushHint); return ; } @@ -49,6 +49,7 @@ type CanvasCursorState = { document: ImageDocument; editor: BrushTargetEditorState; hasBrushPreview: boolean; + isPanning: boolean; }; function selectCanvasCursorState(state: AppState): CanvasCursorState { @@ -63,6 +64,7 @@ function selectCanvasCursorState(state: AppState): CanvasCursorState { maskEdit: state.editor.maskEdit, }, hasBrushPreview: Boolean(state.editor.brushPreview), + isPanning: state.editor.pointerSession?.type === "pan", }; } @@ -70,6 +72,7 @@ function canvasCursorStatesEqual(a: CanvasCursorState, b: CanvasCursorState): bo return ( a.document === b.document && a.hasBrushPreview === b.hasBrushPreview && + a.isPanning === b.isPanning && a.editor.selection === b.editor.selection && a.editor.tools.activeTool === b.editor.tools.activeTool && interactionModesEqual(a.editor.tools.interactionMode, b.editor.tools.interactionMode) && diff --git a/view/CommandPalette.tsx b/view/CommandPalette.tsx index e9f7d48..8c057d4 100644 --- a/view/CommandPalette.tsx +++ b/view/CommandPalette.tsx @@ -1,39 +1,13 @@ -import { useCallback, useEffect, useMemo, useRef, type KeyboardEvent, type ReactNode } from "react"; -import { - ArrowDown, - ArrowUp, - Command, - CornersOut, - Cursor, - DownloadSimple, - DropHalf, - Eraser, - Eye, - EyeSlash, - FolderOpen, - FolderPlus, - Hand, - Lock, - LockOpen, - MagicWand, - MagnifyingGlass, - Minus, - PaintBrush, - Plus, - Sparkle, - Stack, - Trash, -} from "@phosphor-icons/react"; +import { useCallback, useEffect, useMemo, useRef, type KeyboardEvent } from "react"; +import { Command, MagnifyingGlass } from "@phosphor-icons/react"; import { commandIds } from "@commands/ids"; import type { Artboard } from "@core/artboard"; import type { ImageDocument } from "@core/document"; -import type { GenerationCompareMode, GenerationState, SelectionState, ViewportState, CommandPaletteState } from "@editor/state"; +import type { GenerationState, SelectionState, ViewportState, CommandPaletteState } from "@editor/state"; import type { AppStore } from "@editor/store"; -import { availableToolIds, type GenerateMode, type ToolId, type ToolState } from "@editor/tools"; -import { createDocumentReadIndex, type DocumentReadIndex, type IndexedLayerInfo } from "@editor/document-indexes"; -import { downloadArtboardPng } from "./exportArtboardPng"; -import { addArtboard, addEmptyLayer, addGroupLayer, deleteSelection, groupLayers, moveLayer } from "./layerActions"; -import { labelForTool } from "./toolLabels"; +import type { ToolState } from "@editor/tools"; +import { createDocumentReadIndex } from "@editor/document-indexes"; +import { createPaletteItems, type PaletteItem } from "./paletteItems"; export type CommandPaletteProps = { state: CommandPaletteState; @@ -51,17 +25,6 @@ export type CommandPaletteProps = { closeLayers: () => void; }; -type PaletteItem = { - id: string; - title: string; - section: string; - subtitle?: string; - keywords?: string[]; - disabled?: boolean; - icon: ReactNode; - run: () => void; -}; - export function CommandPalette({ state, document, @@ -240,331 +203,6 @@ export function CommandPalette({ ); } -function createPaletteItems(options: { - document: ImageDocument; - documentIndex: DocumentReadIndex; - activeArtboard?: Artboard; - activeArtboardId?: string; - selectedLayer?: IndexedLayerInfo; - canGroup: boolean; - canUngroup: boolean; - selection: SelectionState; - viewport: ViewportState; - tools: ToolState; - generation: GenerationState; - layersOpen: boolean; - dispatch: AppStore["dispatch"]; - openFilePicker: () => void; - openGenerate: () => void; - openLayers: () => void; - closeLayers: () => void; -}): PaletteItem[] { - const { - document, - documentIndex, - activeArtboard, - activeArtboardId, - selectedLayer, - canGroup, - canUngroup, - selection, - viewport, - tools, - generation, - layersOpen, - dispatch, - openFilePicker, - openGenerate, - openLayers, - closeLayers, - } = options; - const hasCandidates = generation.candidates.length > 0; - const items: PaletteItem[] = []; - - items.push( - ...availableToolIds.map((tool) => ({ - id: `tool-${tool}`, - section: "Tools", - title: `Switch to ${labelForTool(tool)}`, - subtitle: tool === tools.activeTool ? "Current tool" : undefined, - keywords: [tool], - icon: toolIcon(tool), - run: () => { - if (tool === "generate") openGenerate(); - else dispatch(commandIds.toolSetActive, { tool }); - }, - })), - ); - - items.push( - { - id: "import-image", - section: "File", - title: "Import image", - subtitle: "Add an image as a layer", - keywords: ["open", "file", "layer"], - icon: , - run: openFilePicker, - }, - { - id: "export-artboard", - section: "File", - title: "Export artboard as PNG", - subtitle: activeArtboard ? activeArtboard.name : "No artboard selected", - keywords: ["download", "png"], - disabled: !activeArtboard, - icon: , - run: () => { - if (activeArtboard) void downloadArtboardPng(activeArtboard, document.assets); - }, - }, - ); - - items.push( - { - id: layersOpen ? "close-layers" : "open-layers", - section: "Layers", - title: layersOpen ? "Close layers panel" : "Open layers panel", - keywords: ["panel", "stack"], - icon: , - run: layersOpen ? closeLayers : openLayers, - }, - { - id: "add-artboard", - section: "Layers", - title: "Add artboard", - icon: , - run: () => addArtboard(document, dispatch), - }, - { - id: "add-empty-layer", - section: "Layers", - title: "Add empty layer", - subtitle: activeArtboardId ? undefined : "No artboard available", - keywords: ["new", "raster"], - disabled: !activeArtboardId, - icon: , - run: () => { - if (activeArtboardId) addEmptyLayer(document, activeArtboardId, selectedLayer, dispatch); - }, - }, - { - id: "add-group", - section: "Layers", - title: "Add group", - subtitle: activeArtboardId ? undefined : "No artboard available", - disabled: !activeArtboardId, - icon: , - run: () => { - if (activeArtboardId) addGroupLayer(activeArtboardId, dispatch); - }, - }, - { - id: "group-selection", - section: "Layers", - title: "Group selected layers", - subtitle: canGroup ? `${selection.layerIds.length} selected` : "Select one or more layers", - disabled: !canGroup, - icon: , - run: () => { - if (selection.artboardId) groupLayers(selection.artboardId, selection.layerIds, dispatch); - }, - }, - { - id: "ungroup-selection", - section: "Layers", - title: "Ungroup selected group", - subtitle: selectedLayer?.layer.name, - disabled: !canUngroup || !selectedLayer, - icon: , - run: () => { - if (selectedLayer?.layer.type === "group") dispatch(commandIds.documentUngroupLayer, { groupId: selectedLayer.layer.id }); - }, - }, - { - id: "move-layer-up", - section: "Layers", - title: "Move selected layer up", - subtitle: selectedLayer?.layer.name, - disabled: !selectedLayer, - icon: , - run: () => { - if (selectedLayer) moveLayer(documentIndex, selectedLayer, -1, dispatch); - }, - }, - { - id: "move-layer-down", - section: "Layers", - title: "Move selected layer down", - subtitle: selectedLayer?.layer.name, - disabled: !selectedLayer, - icon: , - run: () => { - if (selectedLayer) moveLayer(documentIndex, selectedLayer, 1, dispatch); - }, - }, - { - id: "toggle-layer-visible", - section: "Layers", - title: selectedLayer?.layer.visible === false ? "Show selected layer" : "Hide selected layer", - subtitle: selectedLayer?.layer.name, - disabled: !selectedLayer, - icon: selectedLayer?.layer.visible === false ? : , - run: () => { - if (selectedLayer) dispatch(commandIds.documentSetLayerVisible, { layerId: selectedLayer.layer.id, visible: !selectedLayer.layer.visible }); - }, - }, - { - id: "toggle-layer-lock", - section: "Layers", - title: selectedLayer?.layer.locked ? "Unlock selected layer" : "Lock selected layer", - subtitle: selectedLayer?.layer.name, - disabled: !selectedLayer, - icon: selectedLayer?.layer.locked ? : , - run: () => { - if (selectedLayer) dispatch(commandIds.documentSetLayerLocked, { layerId: selectedLayer.layer.id, locked: !selectedLayer.layer.locked }); - }, - }, - { - id: "delete-selection", - section: "Layers", - title: selectedLayer ? "Delete selected layer" : "Delete selected artboard", - subtitle: selectedLayer?.layer.name ?? activeArtboard?.name, - disabled: !selectedLayer && !selection.artboardId, - icon: , - run: () => deleteSelection(selection, selectedLayer, dispatch), - }, - ); - - items.push( - { - id: "open-generate", - section: "Generate", - title: "Open generate panel", - subtitle: tools.activeTool === "generate" ? "Current tool" : undefined, - keywords: ["ai"], - icon: , - run: openGenerate, - }, - ...generateModeItems.map((modeItem) => ({ - id: `generate-mode-${modeItem.mode}`, - section: "Generate", - title: modeItem.title, - subtitle: tools.generate.mode === modeItem.mode ? "Current mode" : undefined, - keywords: ["mode", modeItem.mode], - icon: , - run: () => { - openGenerate(); - dispatch(commandIds.toolSetGenerateSettings, { mode: modeItem.mode }); - }, - })), - { - id: "generate-random-seed", - section: "Generate", - title: "Use random seed", - subtitle: tools.generate.seed === -1 ? "Current seed" : `Seed ${tools.generate.seed}`, - keywords: ["seed"], - icon: , - run: () => dispatch(commandIds.toolSetGenerateSettings, { seed: -1 }), - }, - { - id: "clear-generation-candidates", - section: "Generate", - title: "Clear candidates", - subtitle: hasCandidates ? `${generation.candidates.length} candidate${generation.candidates.length === 1 ? "" : "s"}` : "No candidates", - disabled: !hasCandidates, - icon: , - run: () => dispatch(commandIds.generationClearCandidates, undefined), - }, - ...generationCompareItems.map((compareItem) => ({ - id: `generation-compare-${compareItem.mode}`, - section: "Generate", - title: compareItem.title, - subtitle: generation.compareMode === compareItem.mode ? "Current compare mode" : undefined, - disabled: !hasCandidates, - icon: , - run: () => dispatch(commandIds.generationSetCompareMode, { mode: compareItem.mode }), - })), - ); - - items.push( - { - id: "zoom-in", - section: "Zoom", - title: "Zoom in", - subtitle: `${Math.round(viewport.zoom * 100)}%`, - icon: , - run: () => dispatch(commandIds.viewportSetZoom, { zoom: viewport.zoom * 1.2 }), - }, - { - id: "zoom-out", - section: "Zoom", - title: "Zoom out", - subtitle: `${Math.round(viewport.zoom * 100)}%`, - icon: , - run: () => dispatch(commandIds.viewportSetZoom, { zoom: viewport.zoom / 1.2 }), - }, - { - id: "zoom-100", - section: "Zoom", - title: "Zoom to 100%", - icon: , - run: () => dispatch(commandIds.viewportSetZoom, { zoom: 1 }), - }, - { - id: "fit-artboard", - section: "Zoom", - title: "Fit artboard", - subtitle: activeArtboard?.name, - disabled: !activeArtboard, - icon: , - run: () => dispatch(commandIds.viewportFitArtboard, undefined), - }, - ); - - items.push( - { - id: "debug-reset-viewport", - section: "Debug", - title: "Reset viewport", - icon: , - run: () => dispatch(commandIds.viewportReset, undefined), - }, - { - id: "debug-clear-selection", - section: "Debug", - title: "Clear selection", - subtitle: selection.layerIds.length > 0 || selection.artboardId ? undefined : "Nothing selected", - disabled: selection.layerIds.length === 0 && !selection.artboardId, - icon: , - run: () => dispatch(commandIds.selectionClear, undefined), - }, - { - id: "debug-clear-candidates", - section: "Debug", - title: "Clear generation state", - disabled: !hasCandidates, - icon: , - run: () => dispatch(commandIds.generationClearCandidates, undefined), - }, - ); - - return items; -} - -const generateModeItems: Array<{ mode: GenerateMode; title: string }> = [ - { mode: "text-to-image", title: "Text-to-image mode" }, - { mode: "image-to-image", title: "Image-to-image mode" }, - { mode: "inpaint", title: "Inpaint mode" }, - { mode: "outpaint", title: "Outpaint mode" }, -]; - -const generationCompareItems: Array<{ mode: GenerationCompareMode; title: string }> = [ - { mode: "result", title: "Show result" }, - { mode: "before", title: "Show before" }, - { mode: "split", title: "Split compare" }, -]; - function filterItems(items: PaletteItem[], query: string) { const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean); if (terms.length === 0) return items; @@ -575,25 +213,6 @@ function filterItems(items: PaletteItem[], query: string) { }); } -function toolIcon(tool: ToolId) { - switch (tool) { - case "select": - return ; - case "generate": - return ; - case "brush": - return ; - case "eraser": - return ; - case "chromaKey": - return ; - case "magicWand": - return ; - case "pan": - return ; - } -} - function paletteItemClass(active: boolean, disabled: boolean) { const base = "flex min-h-14 w-full items-center gap-3 rounded-xl px-3 py-2 text-left transition focus:outline-none"; if (disabled) return `${base} cursor-not-allowed text-white/30 opacity-45`; diff --git a/view/GenerateSheet.tsx b/view/GenerateSheet.tsx index 5bb9f64..29fb834 100644 --- a/view/GenerateSheet.tsx +++ b/view/GenerateSheet.tsx @@ -1,14 +1,16 @@ import type { GenerateSettings } from "@editor/tools"; import type { AppStore } from "@editor/store"; +import type { GenerationResourcesState } from "@editor/state"; import { GenerateControls } from "./bottom-controls/GenerateControls"; export type GenerateSheetProps = { settings: GenerateSettings; + resources: GenerationResourcesState; open: boolean; dispatch: AppStore["dispatch"]; }; -export function GenerateSheet({ settings, open, dispatch }: GenerateSheetProps) { +export function GenerateSheet({ settings, resources, open, dispatch }: GenerateSheetProps) { return ( diff --git a/view/GenerationJobStatus.tsx b/view/GenerationJobStatus.tsx new file mode 100644 index 0000000..f21000b --- /dev/null +++ b/view/GenerationJobStatus.tsx @@ -0,0 +1,29 @@ +import { useEffect, useState } from "react"; +import type { GenerationJob, GenerationState } from "@editor/state"; + +export function currentGenerationJob(generation: GenerationState): GenerationJob | undefined { + return generation.jobs.find((job) => job.status === "running") ?? generation.jobs[0]; +} + +export function GenerationJobStatus({ generation, compact = false }: { generation: GenerationState; compact?: boolean }) { + const job = currentGenerationJob(generation); + const [, setTick] = useState(0); + + useEffect(() => { + if (job?.status !== "running") return; + const interval = window.setInterval(() => setTick((tick) => tick + 1), 1000); + return () => window.clearInterval(interval); + }, [job?.id, job?.status]); + + if (!job) return null; + const elapsed = Math.max(0, Math.floor(((job.finishedAt ?? Date.now()) - job.startedAt) / 1000)); + const label = job.status === "running" ? `${job.label} ${formatElapsed(elapsed)}` : job.status === "failed" ? job.error ?? `${job.label} failed` : `${job.label} complete`; + const tone = job.status === "failed" ? "bg-red-500/15 text-red-100" : job.status === "running" ? "bg-white/10 text-white/70" : "bg-emerald-500/15 text-emerald-100"; + + return {label}; +} + +function formatElapsed(seconds: number) { + const minutes = Math.floor(seconds / 60); + return `${minutes}:${(seconds % 60).toString().padStart(2, "0")}`; +} diff --git a/view/LayersSheet.tsx b/view/LayersSheet.tsx index ef1f47a..99f8a42 100644 --- a/view/LayersSheet.tsx +++ b/view/LayersSheet.tsx @@ -1,7 +1,6 @@ -import { useEffect, useMemo, useRef, useState, type DragEvent, type MutableRefObject } from "react"; +import { useMemo, useRef, useState, type DragEvent, type MutableRefObject } from "react"; import { ArrowDown, ArrowUp, DownloadSimple, Eye, EyeSlash, FolderPlus, Lock, LockOpen, Plus, Stack, Trash } from "@phosphor-icons/react"; import { commandIds } from "@commands/ids"; -import type { Asset } from "@core/asset"; import type { ImageDocument } from "@core/document"; import type { Layer } from "@core/layer"; import { getLayerMask } from "@core/layer-mask-utils"; @@ -10,9 +9,9 @@ import { createDocumentReadIndex, type DocumentReadIndex } from "@editor/documen import type { MaskEditState, SelectionState } from "@editor/state"; import type { AppStore } from "@editor/store"; import { resolveLayerDrop } from "@input/index"; -import { downloadArtboardPng } from "./exportArtboardPng"; -import { addArtboard, addEmptyLayer, addGroupLayer, addLayerMask, deleteSelection, groupLayers, moveLayer } from "./layerActions"; -import { analyzeMaskSource, applyMaskRasterOperation, type MaskAnalysis, type MaskRasterOperation } from "./mask/maskRaster"; +import { downloadArtboardPng } from "@operations/export/downloadArtboard"; +import { addArtboard, addEmptyLayer, addGroupLayer, addLayerMask, deleteSelection, groupLayers, moveLayer } from "@operations/document/layerActions"; +import { MaskOperationButtons, MaskStatus } from "./layers/MaskControls"; export type LayersSheetProps = { document: ImageDocument; @@ -370,83 +369,6 @@ function LayerRow({ ); } -function MaskStatus({ asset }: { asset: Asset }) { - const [analysis, setAnalysis] = useState(); - - useEffect(() => { - let cancelled = false; - void analyzeMaskSource(asset.source, asset.intrinsicSize.w, asset.intrinsicSize.h) - .then((nextAnalysis) => { - if (!cancelled) setAnalysis(nextAnalysis); - }) - .catch(() => { - if (!cancelled) setAnalysis(undefined); - }); - return () => { - cancelled = true; - }; - }, [asset.source, asset.intrinsicSize.w, asset.intrinsicSize.h]); - - if (!analysis) return Reading; - return ( - - - Reveal {formatPercent(analysis.coverage)} - Inpaint {formatPercent(analysis.hiddenCoverage)} - - ); -} - -function MaskOperationButtons({ maskLayerId, maskAsset, dispatch }: { maskLayerId: string; maskAsset: Asset; dispatch: AppStore["dispatch"] }) { - return ( - <> - - - - - - - - - - - ); -} - -function MaskOperationButton({ - label, - title, - maskLayerId, - maskAsset, - operation, - dispatch, -}: { - label: string; - title: string; - maskLayerId: string; - maskAsset: Asset; - operation: MaskRasterOperation; - dispatch: AppStore["dispatch"]; -}) { - const [busy, setBusy] = useState(false); - return ( - - ); -} - type EditingTitle = | { type: "artboard"; id: ArtboardId; draft: string } | { type: "layer"; id: string; draft: string }; @@ -497,9 +419,5 @@ function labeledToolbarButtonClass() { } function maskActionButtonClass() { - return "h-8 rounded-full bg-white/5 px-3 text-xs font-medium text-sky-100/65 transition hover:bg-white/10 hover:text-sky-50 disabled:pointer-events-none disabled:opacity-35"; -} - -function formatPercent(value: number) { - return `${Math.round(Math.max(0, Math.min(1, value)) * 100)}%`; + return "rounded-full bg-white/5 px-2.5 py-1 text-[0.7rem] font-semibold text-white/60 transition hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-35"; } diff --git a/view/bottom-controls/ChromaKeyControls.tsx b/view/bottom-controls/ChromaKeyControls.tsx index 2c83445..337c37b 100644 --- a/view/bottom-controls/ChromaKeyControls.tsx +++ b/view/bottom-controls/ChromaKeyControls.tsx @@ -2,13 +2,10 @@ import { useEffect } from "react"; import { DropHalf } from "@phosphor-icons/react"; import { commandIds } from "@commands/ids"; import type { ImageDocument } from "@core/document"; -import type { Layer } from "@core/layer"; -import { getLayerMask } from "@core/layer-mask-utils"; -import { resolveTransformTargetBounds } from "@editor/transform-targets"; import type { AppStore } from "@editor/store"; import type { ChromaKeySettings } from "@editor/tools"; import type { SelectionState } from "@editor/state"; -import { blurMaskValues, despeckleMaskValues, dilateMaskValues, erodeMaskValues } from "../mask/maskRaster"; +import { applyChromaKeyMask, previewChromaKey, resolveChromaKeyTarget } from "@operations/masks/chromaKey"; import { BottomControlColorPicker } from "./ColorPicker"; import { BottomControlDivider } from "./Divider"; import { BottomControlSlider } from "./Slider"; @@ -38,7 +35,7 @@ export function ChromaKeyControls({ document, selection, settings, dispatch }: C return; } - void chromaKeySource(target.asset.source, target.asset.intrinsicSize.w, target.asset.intrinsicSize.h, settings).then((source) => { + void previewChromaKey(target.asset.source, target.asset.intrinsicSize.w, target.asset.intrinsicSize.h, settings).then((source) => { if (cancelled) return; dispatch(commandIds.toolSetBrushStrokePreview, { layerId: target.layer.id, assetId: target.asset.id, source }); }); @@ -153,161 +150,3 @@ export function ChromaKeyControls({ document, selection, settings, dispatch }: C ); } - -function resolveChromaKeyTarget(document: ImageDocument, selection: SelectionState) { - const layerId = selection.layerIds[0]; - if (selection.layerIds.length !== 1 || !layerId) return undefined; - const layer = findLayer(document.artboards.find((artboard) => artboard.id === selection.artboardId)?.layers ?? [], layerId); - if (!layer || layer.type === "group") return undefined; - const asset = document.assets.find((candidate) => candidate.id === layer.assetId); - const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id }); - const layerMask = getLayerMask(layer); - const maskLayer = layerMask?.enabled ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerMask.maskLayerId) : undefined; - const maskAsset = maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined; - return asset && bounds ? { layer, asset, bounds, maskLayer, maskAsset } : undefined; -} - -function findLayer(layers: readonly Layer[], layerId: string): Layer | undefined { - for (const layer of layers) { - if (layer.id === layerId) return layer; - if (layer.type === "group") { - const found = findLayer(layer.children, layerId); - if (found) return found; - } - } - return undefined; -} - -async function applyChromaKeyMask(target: NonNullable>, settings: ChromaKeySettings, dispatch: AppStore["dispatch"]) { - const source = await chromaKeyMaskSource(target.asset.source, target.asset.intrinsicSize.w, target.asset.intrinsicSize.h, settings); - dispatch(commandIds.toolSetBrushStrokePreview, undefined); - - if (target.maskAsset && target.maskLayer && target.maskLayer.type !== "group") { - dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId: target.maskLayer.id, source, mimeType: "image/png", operation: { type: "chromaKey" } }); - return; - } - - const assetId = crypto.randomUUID(); - const maskLayerId = crypto.randomUUID(); - const width = Math.max(1, Math.round(target.asset.intrinsicSize.w)); - const height = Math.max(1, Math.round(target.asset.intrinsicSize.h)); - dispatch(commandIds.documentAddLayerMask, { - layerId: target.layer.id, - asset: { - id: assetId, - name: `${target.layer.name} Chroma Mask`, - mimeType: "image/png", - source, - intrinsicSize: { w: width, h: height }, - }, - maskLayer: { - id: maskLayerId, - type: "raster", - name: `${target.layer.name} Chroma Mask`, - visible: true, - locked: false, - opacity: 1, - assetId, - transform: { - position: { x: target.bounds.x, y: target.bounds.y }, - scale: { x: target.bounds.w / width, y: target.bounds.h / height }, - rotation: target.layer.transform.rotation, - }, - }, - }); - dispatch(commandIds.toolExitMaskEdit, undefined); - dispatch(commandIds.toolSetActive, { tool: "chromaKey" }); -} - -async function chromaKeySource(source: string, width: number, height: number, settings: ChromaKeySettings) { - const canvas = document.createElement("canvas"); - canvas.width = Math.max(1, Math.round(width)); - canvas.height = Math.max(1, Math.round(height)); - const context = canvas.getContext("2d"); - if (!context) return source; - const image = await loadImage(source); - context.drawImage(image, 0, 0, canvas.width, canvas.height); - const data = context.getImageData(0, 0, canvas.width, canvas.height); - const alpha = chromaKeyAlpha(data, canvas.width, canvas.height, settings); - for (let pixel = 0; pixel < alpha.length; pixel++) data.data[pixel * 4 + 3] = alpha[pixel] ?? 255; - context.putImageData(data, 0, 0); - return canvas.toDataURL("image/png"); -} - -async function chromaKeyMaskSource(source: string, width: number, height: number, settings: ChromaKeySettings) { - const canvas = document.createElement("canvas"); - canvas.width = Math.max(1, Math.round(width)); - canvas.height = Math.max(1, Math.round(height)); - const context = canvas.getContext("2d"); - if (!context) return source; - const image = await loadImage(source); - context.drawImage(image, 0, 0, canvas.width, canvas.height); - const data = context.getImageData(0, 0, canvas.width, canvas.height); - const alpha = chromaKeyAlpha(data, canvas.width, canvas.height, settings); - - for (let pixel = 0; pixel < alpha.length; pixel++) { - const index = pixel * 4; - data.data[index] = 255; - data.data[index + 1] = 255; - data.data[index + 2] = 255; - data.data[index + 3] = alpha[pixel] ?? 255; - } - - context.putImageData(data, 0, 0); - return canvas.toDataURL("image/png"); -} - -function hexToRgb(color: string) { - const hex = color.replace("#", ""); - return { r: Number.parseInt(hex.slice(0, 2), 16), g: Number.parseInt(hex.slice(2, 4), 16), b: Number.parseInt(hex.slice(4, 6), 16) }; -} - -function chromaKeyAlpha(data: ImageData, width: number, height: number, settings: ChromaKeySettings) { - const key = hexToRgb(settings.color); - const alpha = new Uint8ClampedArray(width * height); - for (let pixel = 0; pixel < alpha.length; pixel++) { - const index = pixel * 4; - const red = data.data[index] ?? 0; - const green = data.data[index + 1] ?? 0; - const blue = data.data[index + 2] ?? 0; - const sourceAlpha = data.data[index + 3] ?? 255; - alpha[pixel] = Math.round(sourceAlpha * chromaKeyKeepFactor(red, green, blue, key, settings)); - } - return postProcessAlpha(alpha, width, height, settings); -} - -function chromaKeyKeepFactor(red: number, green: number, blue: number, key: { r: number; g: number; b: number }, settings: ChromaKeySettings) { - const tolerance = Math.max(0, Math.min(255, settings.tolerance)); - const softness = Math.max(0, Math.min(255, settings.softness)); - const spill = Math.max(0, Math.min(100, settings.spill)) / 100; - const distance = Math.hypot(red - key.r, green - key.g, blue - key.b); - const edgeKeep = distance <= tolerance ? 0 : softness > 0 && distance < tolerance + softness ? (distance - tolerance) / softness : 1; - if (spill <= 0) return edgeKeep; - - const dominant = key.g >= key.r && key.g >= key.b ? green : key.r >= key.b ? red : blue; - const neutral = key.g >= key.r && key.g >= key.b ? Math.max(red, blue) : key.r >= key.b ? Math.max(green, blue) : Math.max(red, green); - const spillAmount = Math.max(0, dominant - neutral) / 255; - return Math.max(0, Math.min(edgeKeep, 1 - spillAmount * spill)); -} - -function postProcessAlpha(alpha: Uint8ClampedArray, width: number, height: number, settings: ChromaKeySettings) { - let next = alpha; - const despeckle = Math.round(Math.max(0, Math.min(20, settings.despeckle))); - const choke = Math.round(Math.max(-20, Math.min(20, settings.choke))); - const feather = Math.round(Math.max(0, Math.min(20, settings.feather))); - - if (despeckle > 0) next = despeckleMaskValues(next, width, height, despeckle); - if (choke > 0) next = erodeMaskValues(next, width, height, choke); - if (choke < 0) next = dilateMaskValues(next, width, height, -choke); - if (feather > 0) next = blurMaskValues(next, width, height, feather); - return next; -} - -function loadImage(source: string) { - return new Promise((resolve, reject) => { - const image = new Image(); - image.onload = () => resolve(image); - image.onerror = () => reject(new Error("Failed to load image")); - image.src = source; - }); -} diff --git a/view/bottom-controls/GenerateActionControls.tsx b/view/bottom-controls/GenerateActionControls.tsx index bd6fb69..e4d7ce6 100644 --- a/view/bottom-controls/GenerateActionControls.tsx +++ b/view/bottom-controls/GenerateActionControls.tsx @@ -1,12 +1,13 @@ -import { useEffect, useState } from "react"; import { commandIds } from "@commands/ids"; import type { ImageDocument } from "@core/document"; import type { GenerationCandidate, GenerationCompareMode, GenerationState, SelectionState, ViewportState } from "@editor/state"; import type { GenerateSettings } from "@editor/tools"; import type { AppStore } from "@editor/store"; -import { createMaskedPixelReplacementSource } from "../generate/candidateActions"; -import { runGenerate, runGenerateFromCandidate } from "../generate/runGenerate"; -import { createSolidMaskSource } from "../mask/maskRaster"; +import { createMaskedPixelReplacementSource } from "@operations/generation/candidateActions"; +import { runGenerate, runGenerateFromCandidate } from "@operations/generation/runGenerate"; +import { runGenerationJob } from "@operations/generation/generationJob"; +import { currentGenerationJob, GenerationJobStatus } from "../GenerationJobStatus"; +import { createRefinementMask } from "@operations/masks/rasterActions"; export type GenerateActionControlsProps = { document: ImageDocument; @@ -18,44 +19,25 @@ export type GenerateActionControlsProps = { }; export function GenerateActionControls({ document, selection, viewport, settings, generation, dispatch }: GenerateActionControlsProps) { - const [busy, setBusy] = useState(); - const [error, setError] = useState(); - const [elapsedSeconds, setElapsedSeconds] = useState(0); + const job = currentGenerationJob(generation); + const busy = job?.status === "running"; const candidate = selectedCandidate(generation); const canGenerate = Boolean(settings.prompt.trim()) && !busy; - useEffect(() => { - if (!busy) { - setElapsedSeconds(0); - return; - } - - setElapsedSeconds(0); - const startedAt = Date.now(); - const interval = window.setInterval(() => { - setElapsedSeconds(Math.floor((Date.now() - startedAt) / 1000)); - }, 1000); - return () => window.clearInterval(interval); - }, [busy]); - return (
- {busy ? {busy} {formatElapsed(elapsedSeconds)} : null} + {candidate ? ( <> @@ -65,13 +47,10 @@ export function GenerateActionControls({ document, selection, viewport, settings compareMode={generation.compareMode ?? "result"} settings={settings} busy={busy} - setBusy={setBusy} - setError={setError} dispatch={dispatch} /> ) : null} - {error ? {error} : null}
); } @@ -104,70 +83,53 @@ function CandidateControls({ compareMode, settings, busy, - setBusy, - setError, dispatch, }: { document: ImageDocument; candidate: GenerationCandidate; compareMode: GenerationCompareMode; settings: GenerateSettings; - busy?: string; - setBusy: (busy: string | undefined) => void; - setError: (error: string | undefined) => void; + busy: boolean; dispatch: AppStore["dispatch"]; }) { const rerun = (label: string, nextSettings: GenerateSettings) => { - setBusy(label); - setError(undefined); dispatch(commandIds.toolSetGenerateSettings, nextSettings); - void runGenerateFromCandidate({ candidate, settings: nextSettings, dispatch }) - .catch((reason: unknown) => setError(reason instanceof Error ? reason.message : `${label} failed`)) - .finally(() => setBusy(undefined)); + void runGenerationJob({ kind: "regenerate", label, dispatch, task: () => runGenerateFromCandidate({ candidate, settings: nextSettings, dispatch }) }); }; - const disabled = Boolean(busy); + const disabled = busy; return (
Seed {candidate.seed} - rerun("Regenerate", candidate.settings)} /> + rerun("Regenerate", candidate.settings)} /> rerun("Lower", { ...candidate.settings, strength: Math.max(0, candidate.settings.strength - 10), seed: candidate.seed })} /> - rerun("Reuse seed", { ...candidate.settings, seed: candidate.seed })} /> - rerun("New seed", { ...candidate.settings, seed: -1 })} /> + rerun("Reuse seed", { ...candidate.settings, seed: candidate.seed })} /> + rerun("New seed", { ...candidate.settings, seed: -1 })} /> applyCandidateAsLayer(candidate, dispatch)} /> { - setBusy("Refine"); - setError(undefined); - void applyCandidateAsRefinementLayer(candidate, dispatch) - .catch((reason: unknown) => setError(reason instanceof Error ? reason.message : "Refine setup failed")) - .finally(() => setBusy(undefined)); + void runGenerationJob({ kind: "refine", label: "Adding refinement mask", dispatch, task: () => applyCandidateAsRefinementLayer(candidate, dispatch) }); }} /> { - setBusy("Replace"); - setError(undefined); - void createMaskedPixelReplacementSource(document, candidate) - .then((source) => dispatch(commandIds.generationReplaceCandidatePixels, { candidateId: candidate.id, source, mimeType: "image/png" })) - .catch((reason: unknown) => setError(reason instanceof Error ? reason.message : "Replace failed")) - .finally(() => setBusy(undefined)); + void runGenerationJob({ kind: "replace", label: "Replacing pixels", dispatch, task: async () => { + const source = await createMaskedPixelReplacementSource(document, candidate); + dispatch(commandIds.generationReplaceCandidatePixels, { candidateId: candidate.id, source, mimeType: "image/png" }); + } }); }} /> (); +export function GenerateControls({ settings, resources, dispatch }: GenerateControlsProps) { + const comfyOptions = resources.options; const [advancedOpen, setAdvancedOpen] = useState(false); const [outpaintOpen, setOutpaintOpen] = useState(false); const [inpaintOpen, setInpaintOpen] = useState(false); const [sizeOpen, setSizeOpen] = useState(false); const sizeRef = useRef(null); - const [error, setError] = useState(); const modelOptions = resolveModelOptions(settings, comfyOptions); const supportOptions = resolveSupportOptions(settings, comfyOptions); const samplerOptions = resolveStringOptions(comfyOptions?.samplers, settings.sampler); const schedulerOptions = resolveStringOptions(comfyOptions?.schedulers, settings.scheduler); const modeOptions = resolveModeOptions(settings, comfyOptions); - useEffect(() => { - let cancelled = false; - void fetch("/api/comfy/models") - .then((response) => response.ok ? response.json() : Promise.reject(new Error("Unable to load ComfyUI models"))) - .then((body: ComfyOptionsResponse) => { - if (cancelled) return; - setComfyOptions(body); - }) - .catch((reason: unknown) => { - if (!cancelled) setError(reason instanceof Error ? reason.message : "Unable to load ComfyUI models"); - }); - return () => { - cancelled = true; - }; - }, []); - useEffect(() => { if (!sizeOpen) return; const close = (event: PointerEvent) => { @@ -104,7 +72,7 @@ export function GenerateControls({ settings, dispatch }: GenerateControlsProps) return (
- {error ?

{error}

: null} + {resources.error ?

{resources.error}

: null}