feat: add ComfyUI integration for image generation and management
- Implemented ComfyGenerateRequest type and associated functions for generating images using various architectures and modes. - Added functions for listing generation options and handling image uploads. - Created workflows for different generation modes including SDXL, Z-Image, Z-Image Turbo, and Anima. - Introduced GenerationJobStatus component to display the status of ongoing generation jobs. - Developed MaskControls for managing mask operations and displaying mask analysis. - Created palette items for tool selection, layer management, and generation settings.
This commit is contained in:
@@ -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.
|
- `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.
|
- `renderer/`: renders the current `ImageDocument` using the graphics backend, e.g. WebGL.
|
||||||
- `view/`: React UI shell and controls only.
|
- `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
|
## State Ownership
|
||||||
- All persistent document state is represented by `ImageDocument` and related `core/` models.
|
- All persistent document state is represented by `ImageDocument` and related `core/` models.
|
||||||
|
|||||||
@@ -8,13 +8,15 @@ import { selectionCommands } from "@commands/selection";
|
|||||||
import { toolCommands } from "@commands/tool";
|
import { toolCommands } from "@commands/tool";
|
||||||
import { transformCommands } from "@commands/transform";
|
import { transformCommands } from "@commands/transform";
|
||||||
import { viewportCommands } from "@commands/viewport";
|
import { viewportCommands } from "@commands/viewport";
|
||||||
|
import { workspaceCommands } from "@commands/workspace";
|
||||||
|
import { editorCommands } from "@commands/editor";
|
||||||
import { createInitialAppState } from "@editor/initial-state";
|
import { createInitialAppState } from "@editor/initial-state";
|
||||||
import { createAppStore } from "@editor/store";
|
import { createAppStore } from "@editor/store";
|
||||||
|
|
||||||
export type ImageStudioApp = ReturnType<typeof createImageStudioApp>;
|
export type ImageStudioApp = ReturnType<typeof createImageStudioApp>;
|
||||||
|
|
||||||
export function createImageStudioApp(options?: { documentName?: string; createDefaultArtboard?: boolean }) {
|
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);
|
const store = createAppStore(createInitialAppState(options?.documentName), registry);
|
||||||
|
|
||||||
if (options?.createDefaultArtboard !== false) {
|
if (options?.createDefaultArtboard !== false) {
|
||||||
|
|||||||
302
commands/document-tree.ts
Normal file
302
commands/document-tree.ts
Normal file
@@ -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<LayerId>, group: LayerGroup): Layer[] {
|
||||||
|
const next: Layer[] = [];
|
||||||
|
let inserted = false;
|
||||||
|
for (const layer of layers) {
|
||||||
|
if (!selectedLayerIds.has(layer.id)) {
|
||||||
|
next.push(layer);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!inserted) {
|
||||||
|
next.push(group);
|
||||||
|
inserted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeLayerFromDocument(document: ImageDocument, layerId: LayerId): { document: ImageDocument; layer?: Layer } {
|
||||||
|
let removed: Layer | undefined;
|
||||||
|
return {
|
||||||
|
document: {
|
||||||
|
...document,
|
||||||
|
artboards: document.artboards.map((artboard) => {
|
||||||
|
const result = removeLayerFromTree(artboard.layers, layerId);
|
||||||
|
if (result.layer) removed = result.layer;
|
||||||
|
return { ...artboard, layers: result.layers };
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
layer: removed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeLayerFromTree(layers: Layer[], layerId: LayerId): { layers: Layer[]; layer?: Layer } {
|
||||||
|
let removed: Layer | undefined;
|
||||||
|
const next: Layer[] = [];
|
||||||
|
for (const layer of layers) {
|
||||||
|
if (layer.id === layerId) {
|
||||||
|
removed = layer;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (layer.type === "group") {
|
||||||
|
const result = removeLayerFromTree(layer.children, layerId);
|
||||||
|
if (result.layer) removed = result.layer;
|
||||||
|
next.push({ ...layer, children: result.layers });
|
||||||
|
} else {
|
||||||
|
next.push(layer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { layers: next, layer: removed };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ungroupLayerInDocument(document: ImageDocument, groupId: LayerId): { document: ImageDocument; changed: boolean; artboardId?: ArtboardId; children: Layer[] } {
|
||||||
|
let changed = false;
|
||||||
|
let artboardId: ArtboardId | undefined;
|
||||||
|
let children: Layer[] = [];
|
||||||
|
const next = {
|
||||||
|
...document,
|
||||||
|
artboards: document.artboards.map((artboard) => {
|
||||||
|
const result = ungroupLayerInTree(artboard.layers, groupId);
|
||||||
|
if (result.changed) {
|
||||||
|
changed = true;
|
||||||
|
artboardId = artboard.id;
|
||||||
|
children = result.children;
|
||||||
|
}
|
||||||
|
return { ...artboard, layers: result.layers };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return { document: next, changed, artboardId, children };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ungroupLayerInTree(layers: Layer[], groupId: LayerId): { layers: Layer[]; changed: boolean; children: Layer[] } {
|
||||||
|
const next: Layer[] = [];
|
||||||
|
for (const layer of layers) {
|
||||||
|
if (layer.type === "group" && layer.id === groupId) return { layers: [...next, ...layer.children, ...layers.slice(next.length + 1)], changed: true, children: layer.children };
|
||||||
|
if (layer.type === "group") {
|
||||||
|
const result = ungroupLayerInTree(layer.children, groupId);
|
||||||
|
if (result.changed) return { layers: [...next, { ...layer, children: result.layers }, ...layers.slice(next.length + 1)], changed: true, children: result.children };
|
||||||
|
}
|
||||||
|
next.push(layer);
|
||||||
|
}
|
||||||
|
return { layers, changed: false, children: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertAt(layers: Layer[], layer: Layer, index = layers.length) {
|
||||||
|
const clamped = Math.max(0, Math.min(index, layers.length));
|
||||||
|
return [...layers.slice(0, clamped), layer, ...layers.slice(clamped)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findGroup(document: ImageDocument, groupId: LayerId): LayerGroup | undefined {
|
||||||
|
for (const artboard of document.artboards) {
|
||||||
|
const group = findGroupInTree(artboard.layers, groupId);
|
||||||
|
if (group) return group;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findGroupInTree(layers: Layer[], groupId: LayerId): LayerGroup | undefined {
|
||||||
|
for (const layer of layers) {
|
||||||
|
if (layer.type === "group" && layer.id === groupId) return layer;
|
||||||
|
if (layer.type === "group") {
|
||||||
|
const child = findGroupInTree(layer.children, groupId);
|
||||||
|
if (child) return child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeLayerMaskReference(layer: Layer): Layer {
|
||||||
|
const next = { ...layer };
|
||||||
|
delete next.layerMask;
|
||||||
|
delete next.clippingMask;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withLayerMask(layer: Layer, maskLayerId: LayerId): Layer {
|
||||||
|
return {
|
||||||
|
...layer,
|
||||||
|
layerMask: {
|
||||||
|
kind: "raster",
|
||||||
|
maskLayerId,
|
||||||
|
enabled: true,
|
||||||
|
inverted: false,
|
||||||
|
},
|
||||||
|
clippingMask: { maskLayerId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeUnreferencedMaskLayer(document: ImageDocument, maskLayerId: LayerId): ImageDocument {
|
||||||
|
if (isMaskLayerReferenced(document, maskLayerId)) return document;
|
||||||
|
return removeLayerFromDocument(document, maskLayerId).document;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMaskLayerReferenced(document: ImageDocument, maskLayerId: LayerId): boolean {
|
||||||
|
return collectClippingMaskIds(document.artboards.flatMap((artboard) => artboard.layers)).has(maskLayerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeMissingMaskReferences(document: ImageDocument): ImageDocument {
|
||||||
|
const existingLayerIds = collectDocumentLayerIds(document);
|
||||||
|
return mapAllLayersInDocument(document, (layer) => {
|
||||||
|
const mask = getLayerMask(layer);
|
||||||
|
if (!mask || existingLayerIds.has(mask.maskLayerId)) return layer;
|
||||||
|
return removeLayerMaskReference(layer);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMaskEditFor(maskEdit: { 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<LayerId> {
|
||||||
|
const ids = new Set<LayerId>();
|
||||||
|
for (const artboard of document.artboards) collectLayerIdsFromTree(artboard.layers, ids);
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectLayerIds(layer: Layer, ids = new Set<LayerId>()): Set<LayerId> {
|
||||||
|
ids.add(layer.id);
|
||||||
|
if (layer.type === "group") collectLayerIdsFromTree(layer.children, ids);
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectLayerIdsFromTree(layers: readonly Layer[], ids = new Set<LayerId>()): Set<LayerId> {
|
||||||
|
for (const layer of layers) collectLayerIds(layer, ids);
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectClippingMaskIds(layers: readonly Layer[], ids = new Set<LayerId>()): Set<LayerId> {
|
||||||
|
for (const layer of layers) {
|
||||||
|
const mask = getLayerMask(layer);
|
||||||
|
if (mask) ids.add(mask.maskLayerId);
|
||||||
|
if (layer.type === "group") collectClippingMaskIds(layer.children, ids);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectAttachedMaskIds(layers: readonly Layer[], layerIds: readonly LayerId[]): LayerId[] {
|
||||||
|
const layerIdSet = new Set(layerIds);
|
||||||
|
return layers.flatMap((layer) => {
|
||||||
|
const mask = getLayerMask(layer);
|
||||||
|
return layerIdSet.has(layer.id) && mask ? [mask.maskLayerId] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapAllLayersInDocument(document: ImageDocument, mapLayer: (layer: Layer) => Layer): ImageDocument {
|
||||||
|
return {
|
||||||
|
...document,
|
||||||
|
artboards: document.artboards.map((artboard) => ({ ...artboard, layers: mapAllLayersInTree(artboard.layers, mapLayer) })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapAllLayersInTree(layers: Layer[], mapLayer: (layer: Layer) => Layer): Layer[] {
|
||||||
|
return layers.map((layer) => {
|
||||||
|
const mapped = layer.type === "group" ? { ...layer, children: mapAllLayersInTree(layer.children, mapLayer) } : layer;
|
||||||
|
return mapLayer(mapped);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 { Asset } from "@core/asset";
|
||||||
import type { ImageDocument } from "@core/document";
|
import type { ImageDocument } from "@core/document";
|
||||||
import type { Rect } from "@core/geometry";
|
import type { Rect } from "@core/geometry";
|
||||||
import type { ArtboardId, AssetId, LayerId } from "@core/id";
|
import type { ArtboardId, AssetId, LayerId } from "@core/id";
|
||||||
import type { ImageLayer } from "@core/image-layer";
|
import type { ImageLayer } from "@core/image-layer";
|
||||||
import type { Layer } from "@core/layer";
|
|
||||||
import { getLayerMask } from "@core/layer-mask-utils";
|
import { getLayerMask } from "@core/layer-mask-utils";
|
||||||
import type { RasterLayer } from "@core/raster-layer";
|
import type { RasterLayer } from "@core/raster-layer";
|
||||||
import type { LayerGroup } from "@core/layer-group";
|
import type { LayerGroup } from "@core/layer-group";
|
||||||
@@ -625,300 +625,3 @@ export const documentCommands = [
|
|||||||
documentApplyLayerMaskOperationCommand,
|
documentApplyLayerMaskOperationCommand,
|
||||||
documentRemoveLayerMaskCommand,
|
documentRemoveLayerMaskCommand,
|
||||||
] satisfies Command<unknown>[];
|
] satisfies Command<unknown>[];
|
||||||
|
|
||||||
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<LayerId>, group: LayerGroup): Layer[] {
|
|
||||||
const next: Layer[] = [];
|
|
||||||
let inserted = false;
|
|
||||||
for (const layer of layers) {
|
|
||||||
if (!selectedLayerIds.has(layer.id)) {
|
|
||||||
next.push(layer);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!inserted) {
|
|
||||||
next.push(group);
|
|
||||||
inserted = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
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<LayerId> {
|
|
||||||
const ids = new Set<LayerId>();
|
|
||||||
for (const artboard of document.artboards) collectLayerIdsFromTree(artboard.layers, ids);
|
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectLayerIds(layer: Layer, ids = new Set<LayerId>()): Set<LayerId> {
|
|
||||||
ids.add(layer.id);
|
|
||||||
if (layer.type === "group") collectLayerIdsFromTree(layer.children, ids);
|
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectLayerIdsFromTree(layers: readonly Layer[], ids = new Set<LayerId>()): Set<LayerId> {
|
|
||||||
for (const layer of layers) collectLayerIds(layer, ids);
|
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectClippingMaskIds(layers: readonly Layer[], ids = new Set<LayerId>()): Set<LayerId> {
|
|
||||||
for (const layer of layers) {
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
16
commands/editor.ts
Normal file
16
commands/editor.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Command } from "./command";
|
||||||
|
import { commandIds } from "./ids";
|
||||||
|
|
||||||
|
export type EditorSetPointerSessionPayload = { type: "pan" } | undefined;
|
||||||
|
|
||||||
|
export const editorSetPointerSessionCommand: Command<EditorSetPointerSessionPayload> = {
|
||||||
|
id: commandIds.editorSetPointerSession,
|
||||||
|
name: "Set pointer session",
|
||||||
|
history: { mode: "ignore" },
|
||||||
|
execute({ state }, payload) {
|
||||||
|
if (state.editor.pointerSession?.type === payload?.type) return state;
|
||||||
|
return { ...state, editor: { ...state.editor, pointerSession: payload } };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const editorCommands = [editorSetPointerSessionCommand] satisfies Command<unknown>[];
|
||||||
@@ -7,6 +7,9 @@ import {
|
|||||||
generationRemoveCandidateCommand,
|
generationRemoveCandidateCommand,
|
||||||
generationReplaceCandidatePixelsCommand,
|
generationReplaceCandidatePixelsCommand,
|
||||||
generationSetCompareModeCommand,
|
generationSetCompareModeCommand,
|
||||||
|
generationFailJobCommand,
|
||||||
|
generationStartJobCommand,
|
||||||
|
generationSucceedJobCommand,
|
||||||
} from "./generation";
|
} from "./generation";
|
||||||
|
|
||||||
describe("generation commands", () => {
|
describe("generation commands", () => {
|
||||||
@@ -64,6 +67,8 @@ describe("generation commands", () => {
|
|||||||
candidates: [generationCandidate("candidate-2")],
|
candidates: [generationCandidate("candidate-2")],
|
||||||
selectedCandidateId: "candidate-2",
|
selectedCandidateId: "candidate-2",
|
||||||
compareMode: "split",
|
compareMode: "split",
|
||||||
|
jobs: [],
|
||||||
|
resources: { status: "idle" },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -99,8 +104,27 @@ describe("generation commands", () => {
|
|||||||
candidates: [generationCandidate("candidate-2", true)],
|
candidates: [generationCandidate("candidate-2", true)],
|
||||||
selectedCandidateId: "candidate-2",
|
selectedCandidateId: "candidate-2",
|
||||||
compareMode: "before",
|
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() {
|
function documentWithSourceLayer() {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { Asset } from "@core/asset";
|
import type { Asset } from "@core/asset";
|
||||||
import type { AssetGenerationProvenance, GeneratedAssetAcceptance } from "@core/asset-provenance";
|
import type { AssetGenerationProvenance, GeneratedAssetAcceptance } from "@core/asset-provenance";
|
||||||
import type { ImageDocument } from "@core/document";
|
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 { ImageLayer } from "@core/image-layer";
|
||||||
import type { Layer } from "@core/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 type { Command } from "./command";
|
||||||
import { commandIds } from "./ids";
|
import { commandIds } from "./ids";
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ export type GenerationAddCandidatePayload = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type GenerationSelectCandidatePayload = {
|
export type GenerationSelectCandidatePayload = {
|
||||||
candidateId?: string;
|
candidateId?: GenerationCandidateId;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GenerationSetCompareModePayload = {
|
export type GenerationSetCompareModePayload = {
|
||||||
@@ -21,22 +21,29 @@ export type GenerationSetCompareModePayload = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type GenerationRemoveCandidatePayload = {
|
export type GenerationRemoveCandidatePayload = {
|
||||||
candidateId: string;
|
candidateId: GenerationCandidateId;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GenerationApplyCandidateAsLayerPayload = {
|
export type GenerationApplyCandidateAsLayerPayload = {
|
||||||
candidateId: string;
|
candidateId: GenerationCandidateId;
|
||||||
assetId: AssetId;
|
assetId: AssetId;
|
||||||
layerId: LayerId;
|
layerId: LayerId;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GenerationReplaceCandidatePixelsPayload = {
|
export type GenerationReplaceCandidatePixelsPayload = {
|
||||||
candidateId: string;
|
candidateId: GenerationCandidateId;
|
||||||
source: string;
|
source: string;
|
||||||
mimeType?: 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 maxCandidates = 12;
|
||||||
|
const maxJobs = 20;
|
||||||
const generationCompareModes = new Set<GenerationCompareMode>(["result", "before", "split"]);
|
const generationCompareModes = new Set<GenerationCompareMode>(["result", "before", "split"]);
|
||||||
|
|
||||||
export const generationAddCandidateCommand: Command<GenerationAddCandidatePayload> = {
|
export const generationAddCandidateCommand: Command<GenerationAddCandidatePayload> = {
|
||||||
@@ -50,6 +57,7 @@ export const generationAddCandidateCommand: Command<GenerationAddCandidatePayloa
|
|||||||
editor: {
|
editor: {
|
||||||
...state.editor,
|
...state.editor,
|
||||||
generation: {
|
generation: {
|
||||||
|
...state.editor.generation,
|
||||||
candidates,
|
candidates,
|
||||||
selectedCandidateId: payload.candidate.id,
|
selectedCandidateId: payload.candidate.id,
|
||||||
compareMode: "result",
|
compareMode: "result",
|
||||||
@@ -126,7 +134,7 @@ export const generationClearCandidatesCommand: Command = {
|
|||||||
...state,
|
...state,
|
||||||
editor: {
|
editor: {
|
||||||
...state.editor,
|
...state.editor,
|
||||||
generation: { candidates: [], selectedCandidateId: undefined, compareMode: "result" },
|
generation: { ...state.editor.generation, candidates: [], selectedCandidateId: undefined, compareMode: "result" },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -210,6 +218,66 @@ export const generationReplaceCandidatePixelsCommand: Command<GenerationReplaceC
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const generationStartJobCommand: Command<GenerationStartJobPayload> = {
|
||||||
|
id: commandIds.generationStartJob,
|
||||||
|
name: "Start generation job",
|
||||||
|
history: { mode: "ignore" },
|
||||||
|
execute({ state }, payload) {
|
||||||
|
if (!payload.jobId || !payload.label.trim() || !Number.isFinite(payload.startedAt)) return state;
|
||||||
|
if (state.editor.generation.jobs.some((job) => job.status === "running" || job.id === payload.jobId)) return state;
|
||||||
|
const job: GenerationState["jobs"][number] = { id: payload.jobId, kind: payload.kind, label: payload.label.trim(), status: "running", startedAt: payload.startedAt };
|
||||||
|
return updateJobs(state, [job, ...state.editor.generation.jobs].slice(0, maxJobs));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generationSucceedJobCommand: Command<GenerationSucceedJobPayload> = {
|
||||||
|
id: commandIds.generationSucceedJob,
|
||||||
|
name: "Complete generation job",
|
||||||
|
history: { mode: "ignore" },
|
||||||
|
execute({ state }, payload) {
|
||||||
|
return settleJob(state, payload.jobId, payload.finishedAt, "succeeded");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generationFailJobCommand: Command<GenerationFailJobPayload> = {
|
||||||
|
id: commandIds.generationFailJob,
|
||||||
|
name: "Fail generation job",
|
||||||
|
history: { mode: "ignore" },
|
||||||
|
execute({ state }, payload) {
|
||||||
|
if (!payload.error.trim()) return state;
|
||||||
|
return settleJob(state, payload.jobId, payload.finishedAt, "failed", payload.error.trim());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generationLoadResourcesCommand: Command = {
|
||||||
|
id: commandIds.generationLoadResources,
|
||||||
|
name: "Load generation resources",
|
||||||
|
history: { mode: "ignore" },
|
||||||
|
execute({ state }) {
|
||||||
|
if (state.editor.generation.resources.status === "loading") return state;
|
||||||
|
return updateResources(state, { status: "loading" });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generationSetResourcesCommand: Command<GenerationSetResourcesPayload> = {
|
||||||
|
id: commandIds.generationSetResources,
|
||||||
|
name: "Set generation resources",
|
||||||
|
history: { mode: "ignore" },
|
||||||
|
execute({ state }, payload) {
|
||||||
|
return updateResources(state, { status: "ready", options: payload.options });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generationFailResourcesCommand: Command<GenerationFailResourcesPayload> = {
|
||||||
|
id: commandIds.generationFailResources,
|
||||||
|
name: "Fail generation resources",
|
||||||
|
history: { mode: "ignore" },
|
||||||
|
execute({ state }, payload) {
|
||||||
|
if (!payload.error.trim()) return state;
|
||||||
|
return updateResources(state, { status: "failed", error: payload.error.trim() });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const generationCommands = [
|
export const generationCommands = [
|
||||||
generationAddCandidateCommand,
|
generationAddCandidateCommand,
|
||||||
generationSelectCandidateCommand,
|
generationSelectCandidateCommand,
|
||||||
@@ -218,8 +286,29 @@ export const generationCommands = [
|
|||||||
generationClearCandidatesCommand,
|
generationClearCandidatesCommand,
|
||||||
generationApplyCandidateAsLayerCommand,
|
generationApplyCandidateAsLayerCommand,
|
||||||
generationReplaceCandidatePixelsCommand,
|
generationReplaceCandidatePixelsCommand,
|
||||||
|
generationStartJobCommand,
|
||||||
|
generationSucceedJobCommand,
|
||||||
|
generationFailJobCommand,
|
||||||
|
generationLoadResourcesCommand,
|
||||||
|
generationSetResourcesCommand,
|
||||||
|
generationFailResourcesCommand,
|
||||||
] satisfies Command<unknown>[];
|
] satisfies Command<unknown>[];
|
||||||
|
|
||||||
|
function updateJobs(state: AppState, jobs: GenerationState["jobs"]): AppState {
|
||||||
|
return { ...state, editor: { ...state.editor, generation: { ...state.editor.generation, jobs } } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateResources(state: AppState, resources: GenerationState["resources"]): AppState {
|
||||||
|
return { ...state, editor: { ...state.editor, generation: { ...state.editor.generation, resources } } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function settleJob(state: AppState, jobId: GenerationJobId, finishedAt: number, status: "succeeded" | "failed", 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 = {
|
type LayerLocation = {
|
||||||
artboardId: ArtboardId;
|
artboardId: ArtboardId;
|
||||||
layer: Layer;
|
layer: Layer;
|
||||||
@@ -251,7 +340,7 @@ function findLayerInTree(layers: readonly Layer[], layerId: LayerId): Layer | un
|
|||||||
return undefined;
|
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);
|
const removedIndex = generation.candidates.findIndex((candidate) => candidate.id === candidateId);
|
||||||
if (removedIndex < 0) return generation;
|
if (removedIndex < 0) return generation;
|
||||||
|
|
||||||
@@ -264,6 +353,7 @@ function removeGenerationCandidate(generation: GenerationState, candidateId: str
|
|||||||
: candidates[Math.min(removedIndex, candidates.length - 1)]?.id;
|
: candidates[Math.min(removedIndex, candidates.length - 1)]?.id;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
...generation,
|
||||||
candidates,
|
candidates,
|
||||||
selectedCandidateId,
|
selectedCandidateId,
|
||||||
compareMode: candidates.length > 0 ? generation.compareMode : "result",
|
compareMode: candidates.length > 0 ? generation.compareMode : "result",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { createInitialAppState } from "@editor/initial-state";
|
import { createInitialAppState } from "@editor/initial-state";
|
||||||
|
import { generationStartJobCommand, generationSucceedJobCommand } from "./generation";
|
||||||
import { createAppStore } from "@editor/store";
|
import { createAppStore } from "@editor/store";
|
||||||
import { documentAddArtboardCommand } from "./document";
|
import { documentAddArtboardCommand } from "./document";
|
||||||
import { historyCommands } from "./history";
|
import { historyCommands } from "./history";
|
||||||
@@ -7,7 +8,7 @@ import { commandIds } from "./ids";
|
|||||||
import { createCommandRegistry } from "./registry";
|
import { createCommandRegistry } from "./registry";
|
||||||
import { transformCommands } from "./transform";
|
import { transformCommands } from "./transform";
|
||||||
|
|
||||||
const registry = createCommandRegistry([documentAddArtboardCommand, ...historyCommands, ...transformCommands]);
|
const registry = createCommandRegistry([documentAddArtboardCommand, generationStartJobCommand, generationSucceedJobCommand, ...historyCommands, ...transformCommands]);
|
||||||
|
|
||||||
describe("history commands", () => {
|
describe("history commands", () => {
|
||||||
test("records document changes and undoes/redoes them", () => {
|
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().document.artboards[0]?.bounds).toEqual({ x: 0, y: 0, w: 100, h: 80 });
|
||||||
expect(store.getState().history.past).toHaveLength(0);
|
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() {
|
function artboardState() {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Command } from "./command";
|
import type { Command } from "./command";
|
||||||
|
import type { EditorState } from "@editor/state";
|
||||||
import { commandIds } from "./ids";
|
import { commandIds } from "./ids";
|
||||||
|
|
||||||
export const historyUndoCommand: Command = {
|
export const historyUndoCommand: Command = {
|
||||||
@@ -12,7 +13,7 @@ export const historyUndoCommand: Command = {
|
|||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
document: previous.document,
|
document: previous.document,
|
||||||
editor: previous.editor,
|
editor: preserveGenerationJobs(previous.editor, state.editor),
|
||||||
history: {
|
history: {
|
||||||
past: state.history.past.slice(0, -1),
|
past: state.history.past.slice(0, -1),
|
||||||
future: [{ document: state.document, editor: state.editor }, ...state.history.future],
|
future: [{ document: state.document, editor: state.editor }, ...state.history.future],
|
||||||
@@ -32,7 +33,7 @@ export const historyRedoCommand: Command = {
|
|||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
document: next.document,
|
document: next.document,
|
||||||
editor: next.editor,
|
editor: preserveGenerationJobs(next.editor, state.editor),
|
||||||
history: {
|
history: {
|
||||||
past: [...state.history.past, { document: state.document, editor: state.editor }],
|
past: [...state.history.past, { document: state.document, editor: state.editor }],
|
||||||
future: state.history.future.slice(1),
|
future: state.history.future.slice(1),
|
||||||
@@ -42,3 +43,13 @@ export const historyRedoCommand: Command = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const historyCommands = [historyUndoCommand, historyRedoCommand] satisfies Command<unknown>[];
|
export const historyCommands = [historyUndoCommand, historyRedoCommand] satisfies Command<unknown>[];
|
||||||
|
|
||||||
|
function preserveGenerationJobs(target: EditorState, current: EditorState): EditorState {
|
||||||
|
return {
|
||||||
|
...target,
|
||||||
|
generation: {
|
||||||
|
...target.generation,
|
||||||
|
jobs: current.generation.jobs,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ export const commandIds = {
|
|||||||
generationClearCandidates: "generation.clearCandidates",
|
generationClearCandidates: "generation.clearCandidates",
|
||||||
generationApplyCandidateAsLayer: "generation.applyCandidateAsLayer",
|
generationApplyCandidateAsLayer: "generation.applyCandidateAsLayer",
|
||||||
generationReplaceCandidatePixels: "generation.replaceCandidatePixels",
|
generationReplaceCandidatePixels: "generation.replaceCandidatePixels",
|
||||||
|
generationStartJob: "generation.startJob",
|
||||||
|
generationSucceedJob: "generation.succeedJob",
|
||||||
|
generationFailJob: "generation.failJob",
|
||||||
|
generationLoadResources: "generation.loadResources",
|
||||||
|
generationSetResources: "generation.setResources",
|
||||||
|
generationFailResources: "generation.failResources",
|
||||||
transformBegin: "transform.begin",
|
transformBegin: "transform.begin",
|
||||||
transformUpdate: "transform.update",
|
transformUpdate: "transform.update",
|
||||||
transformSetBounds: "transform.setBounds",
|
transformSetBounds: "transform.setBounds",
|
||||||
@@ -59,4 +65,6 @@ export const commandIds = {
|
|||||||
commandPaletteClose: "commandPalette.close",
|
commandPaletteClose: "commandPalette.close",
|
||||||
commandPaletteSetQuery: "commandPalette.setQuery",
|
commandPaletteSetQuery: "commandPalette.setQuery",
|
||||||
commandPaletteSetSelectedIndex: "commandPalette.setSelectedIndex",
|
commandPaletteSetSelectedIndex: "commandPalette.setSelectedIndex",
|
||||||
|
workspaceSetPanel: "workspace.setPanel",
|
||||||
|
editorSetPointerSession: "editor.setPointerSession",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ import type {
|
|||||||
GenerationReplaceCandidatePixelsPayload,
|
GenerationReplaceCandidatePixelsPayload,
|
||||||
GenerationSelectCandidatePayload,
|
GenerationSelectCandidatePayload,
|
||||||
GenerationSetCompareModePayload,
|
GenerationSetCompareModePayload,
|
||||||
|
GenerationStartJobPayload,
|
||||||
|
GenerationSucceedJobPayload,
|
||||||
|
GenerationFailJobPayload,
|
||||||
|
GenerationSetResourcesPayload,
|
||||||
|
GenerationFailResourcesPayload,
|
||||||
} from "./generation";
|
} from "./generation";
|
||||||
import type {
|
import type {
|
||||||
CommandPaletteOpenPayload,
|
CommandPaletteOpenPayload,
|
||||||
@@ -39,6 +44,8 @@ import type {
|
|||||||
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
||||||
import type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool";
|
import type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool";
|
||||||
import type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform";
|
import type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform";
|
||||||
|
import type { WorkspaceSetPanelPayload } from "./workspace";
|
||||||
|
import type { EditorSetPointerSessionPayload } from "./editor";
|
||||||
import type {
|
import type {
|
||||||
ViewportFitArtboardPayload,
|
ViewportFitArtboardPayload,
|
||||||
ViewportPanPayload,
|
ViewportPanPayload,
|
||||||
@@ -92,6 +99,12 @@ export type CommandPayloads = {
|
|||||||
[commandIds.generationClearCandidates]: void;
|
[commandIds.generationClearCandidates]: void;
|
||||||
[commandIds.generationApplyCandidateAsLayer]: GenerationApplyCandidateAsLayerPayload;
|
[commandIds.generationApplyCandidateAsLayer]: GenerationApplyCandidateAsLayerPayload;
|
||||||
[commandIds.generationReplaceCandidatePixels]: GenerationReplaceCandidatePixelsPayload;
|
[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.transformBegin]: TransformBeginPayload;
|
||||||
[commandIds.transformUpdate]: TransformUpdatePayload;
|
[commandIds.transformUpdate]: TransformUpdatePayload;
|
||||||
[commandIds.transformSetBounds]: TransformSetBoundsPayload;
|
[commandIds.transformSetBounds]: TransformSetBoundsPayload;
|
||||||
@@ -108,6 +121,8 @@ export type CommandPayloads = {
|
|||||||
[commandIds.commandPaletteClose]: void;
|
[commandIds.commandPaletteClose]: void;
|
||||||
[commandIds.commandPaletteSetQuery]: CommandPaletteSetQueryPayload;
|
[commandIds.commandPaletteSetQuery]: CommandPaletteSetQueryPayload;
|
||||||
[commandIds.commandPaletteSetSelectedIndex]: CommandPaletteSetSelectedIndexPayload;
|
[commandIds.commandPaletteSetSelectedIndex]: CommandPaletteSetSelectedIndexPayload;
|
||||||
|
[commandIds.workspaceSetPanel]: WorkspaceSetPanelPayload;
|
||||||
|
[commandIds.editorSetPointerSession]: EditorSetPointerSessionPayload;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CommandId = keyof CommandPayloads;
|
export type CommandId = keyof CommandPayloads;
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ export const toolSetActiveCommand: Command<ToolSetActivePayload> = {
|
|||||||
id: commandIds.toolSetActive,
|
id: commandIds.toolSetActive,
|
||||||
name: "Set active tool",
|
name: "Set active tool",
|
||||||
execute({ state }, payload) {
|
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 {
|
return {
|
||||||
...state,
|
...state,
|
||||||
editor: {
|
editor: {
|
||||||
@@ -55,6 +57,7 @@ export const toolSetActiveCommand: Command<ToolSetActivePayload> = {
|
|||||||
},
|
},
|
||||||
brushPreview: undefined,
|
brushPreview: undefined,
|
||||||
brushStrokePreview: undefined,
|
brushStrokePreview: undefined,
|
||||||
|
workspace: { panel, previousNonGenerateTool },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
64
commands/transform-document.ts
Normal file
64
commands/transform-document.ts
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Rect, Vec2D } from "@core/geometry";
|
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 { TransformHandle, TransformTarget } from "@editor/transform";
|
||||||
import type { Command } from "./command";
|
import type { Command } from "./command";
|
||||||
import { commandIds } from "./ids";
|
import { commandIds } from "./ids";
|
||||||
|
|||||||
32
commands/workspace.ts
Normal file
32
commands/workspace.ts
Normal file
@@ -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<WorkspaceSetPanelPayload> = {
|
||||||
|
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<WorkspacePanel>(["none", "generate", "layers"]);
|
||||||
|
|
||||||
|
export const workspaceCommands = [workspaceSetPanelCommand] satisfies Command<unknown>[];
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Rect, Size } from "./geometry";
|
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";
|
export type GeneratedAssetMode = "text-to-image" | "image-to-image" | "inpaint" | "outpaint";
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ export type GeneratedAssetAcceptance = "layer" | "replacement";
|
|||||||
|
|
||||||
export type AssetGenerationProvenance = {
|
export type AssetGenerationProvenance = {
|
||||||
kind: "generated";
|
kind: "generated";
|
||||||
candidateId: string;
|
candidateId: GenerationCandidateId;
|
||||||
mode: GeneratedAssetMode;
|
mode: GeneratedAssetMode;
|
||||||
acceptance: GeneratedAssetAcceptance;
|
acceptance: GeneratedAssetAcceptance;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
|
|||||||
13
core/id.ts
13
core/id.ts
@@ -1,4 +1,9 @@
|
|||||||
export type DocumentId = string;
|
declare const idBrand: unique symbol;
|
||||||
export type ArtboardId = string;
|
type OpaqueId<Brand extends string> = string & { readonly [idBrand]?: Brand };
|
||||||
export type LayerId = string;
|
|
||||||
export type AssetId = string;
|
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">;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export type {
|
|||||||
Transform,
|
Transform,
|
||||||
Vec2D,
|
Vec2D,
|
||||||
} from "./geometry";
|
} 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 { ImageLayer } from "./image-layer";
|
||||||
export type { Layer } from "./layer";
|
export type { Layer } from "./layer";
|
||||||
export type { LayerMask } from "./layer-mask";
|
export type { LayerMask } from "./layer-mask";
|
||||||
|
|||||||
@@ -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.
|
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.
|
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.
|
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
|
### P2 — the workspace lacks stable information architecture
|
||||||
|
|
||||||
The current shell is a canvas surrounded by floating islands:
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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
|
### 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.
|
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.
|
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:
|
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.
|
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
|
### 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.
|
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.
|
||||||
|
|||||||
@@ -16,16 +16,23 @@ export const initialEditorState: EditorState = {
|
|||||||
candidates: [],
|
candidates: [],
|
||||||
selectedCandidateId: undefined,
|
selectedCandidateId: undefined,
|
||||||
compareMode: "result",
|
compareMode: "result",
|
||||||
|
jobs: [],
|
||||||
|
resources: { status: "idle" },
|
||||||
},
|
},
|
||||||
commandPalette: {
|
commandPalette: {
|
||||||
open: false,
|
open: false,
|
||||||
query: "",
|
query: "",
|
||||||
selectedIndex: 0,
|
selectedIndex: 0,
|
||||||
},
|
},
|
||||||
|
workspace: {
|
||||||
|
panel: "none",
|
||||||
|
previousNonGenerateTool: "select",
|
||||||
|
},
|
||||||
transformSession: undefined,
|
transformSession: undefined,
|
||||||
maskEdit: undefined,
|
maskEdit: undefined,
|
||||||
brushPreview: undefined,
|
brushPreview: undefined,
|
||||||
brushStrokePreview: undefined,
|
brushStrokePreview: undefined,
|
||||||
|
pointerSession: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createInitialAppState(name = "Untitled"): AppState {
|
export function createInitialAppState(name = "Untitled"): AppState {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ImageDocument } from "@core/document";
|
import type { ImageDocument } from "@core/document";
|
||||||
import type { Angle, Rect, Size, Transform, Vec2D } from "@core/geometry";
|
import type { Angle, Rect, Size, Transform, Vec2D } from "@core/geometry";
|
||||||
import type { ArtboardId, AssetId, LayerId } from "@core/id";
|
import type { ArtboardId, AssetId, GenerationCandidateId, GenerationJobId, LayerId } from "@core/id";
|
||||||
import type { GenerateSettings, ToolState } from "./tools";
|
import type { GenerateArchitecture, GenerateMode, GenerateSettings, ToolId, ToolState } from "./tools";
|
||||||
import type { TransformSession } from "./transform";
|
import type { TransformSession } from "./transform";
|
||||||
|
|
||||||
export type ViewportState = {
|
export type ViewportState = {
|
||||||
@@ -35,7 +35,7 @@ export type BrushStrokePreviewState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type GenerationCandidate = {
|
export type GenerationCandidate = {
|
||||||
id: string;
|
id: GenerationCandidateId;
|
||||||
source: string;
|
source: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
intrinsicSize: Size;
|
intrinsicSize: Size;
|
||||||
@@ -81,28 +81,71 @@ export type GenerationCandidate = {
|
|||||||
|
|
||||||
export type GenerationCompareMode = "result" | "before" | "split";
|
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 = {
|
export type GenerationState = {
|
||||||
candidates: GenerationCandidate[];
|
candidates: GenerationCandidate[];
|
||||||
selectedCandidateId?: string;
|
selectedCandidateId?: string;
|
||||||
compareMode: GenerationCompareMode;
|
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 = {
|
export type CommandPaletteState = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
query: string;
|
query: string;
|
||||||
selectedIndex: number;
|
selectedIndex: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WorkspacePanel = "none" | "generate" | "layers";
|
||||||
|
|
||||||
|
export type WorkspaceState = {
|
||||||
|
panel: WorkspacePanel;
|
||||||
|
previousNonGenerateTool: ToolId;
|
||||||
|
};
|
||||||
|
|
||||||
export type EditorState = {
|
export type EditorState = {
|
||||||
viewport: ViewportState;
|
viewport: ViewportState;
|
||||||
selection: SelectionState;
|
selection: SelectionState;
|
||||||
tools: ToolState;
|
tools: ToolState;
|
||||||
generation: GenerationState;
|
generation: GenerationState;
|
||||||
commandPalette: CommandPaletteState;
|
commandPalette: CommandPaletteState;
|
||||||
|
workspace: WorkspaceState;
|
||||||
transformSession?: TransformSession;
|
transformSession?: TransformSession;
|
||||||
maskEdit?: MaskEditState;
|
maskEdit?: MaskEditState;
|
||||||
brushPreview?: BrushPreviewState;
|
brushPreview?: BrushPreviewState;
|
||||||
brushStrokePreview?: BrushStrokePreviewState;
|
brushStrokePreview?: BrushStrokePreviewState;
|
||||||
|
pointerSession?: { type: "pan" };
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HistorySnapshot = {
|
export type HistorySnapshot = {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import type { ImageDocument } from "@core/document";
|
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 = {
|
const document: ImageDocument = {
|
||||||
id: "d1",
|
id: "d1",
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { ImageDocument } from "@core/document";
|
import type { ImageDocument } from "@core/document";
|
||||||
import type { Rect } from "@core/geometry";
|
import type { Rect } from "@core/geometry";
|
||||||
import type { Layer } from "@core/layer";
|
import type { Layer } from "@core/layer";
|
||||||
import { getLayerMask } from "@core/layer-mask-utils";
|
|
||||||
import type { ArtboardId, LayerId } from "@core/id";
|
import type { ArtboardId, LayerId } from "@core/id";
|
||||||
import type { TransformTarget } from "./transform";
|
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 {
|
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.layerIds.length === 1 && selection.layerIds[0]) return { type: "layer", id: selection.layerIds[0] };
|
||||||
if (selection.artboardId) return { type: "artboard", id: selection.artboardId };
|
if (selection.artboardId) return { type: "artboard", id: selection.artboardId };
|
||||||
return undefined;
|
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 {
|
function findLayer(document: ImageDocument, layerId: LayerId): Layer | undefined {
|
||||||
for (const artboard of document.artboards) {
|
for (const artboard of document.artboards) {
|
||||||
const layer = findLayerInTree(artboard.layers, layerId);
|
const layer = findLayerInTree(artboard.layers, layerId);
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ const appLayerImports = [
|
|||||||
"../commands/*",
|
"../commands/*",
|
||||||
"../editor/*",
|
"../editor/*",
|
||||||
"../input/*",
|
"../input/*",
|
||||||
|
"@operations/*",
|
||||||
|
"@platform/*",
|
||||||
|
"@server/*",
|
||||||
];
|
];
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
@@ -40,7 +43,7 @@ export default tseslint.config(
|
|||||||
rules: {
|
rules: {
|
||||||
"no-restricted-imports": [
|
"no-restricted-imports": [
|
||||||
"error",
|
"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: {
|
rules: {
|
||||||
"no-restricted-imports": [
|
"no-restricted-imports": [
|
||||||
"error",
|
"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/*",
|
"../editor/*",
|
||||||
"react",
|
"react",
|
||||||
"react-dom",
|
"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"] }],
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
2
index.ts
2
index.ts
@@ -1,5 +1,5 @@
|
|||||||
import { serve } from "bun";
|
import { serve } from "bun";
|
||||||
import { handleComfyApi } from "./app/comfy";
|
import { handleComfyApi } from "./server/comfy-routes";
|
||||||
import index from "./view/index.html";
|
import index from "./view/index.html";
|
||||||
|
|
||||||
const server = serve({
|
const server = serve({
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import type { ImageDocument } from "@core/document";
|
|
||||||
import type { Rect, Size, Vec2D } from "@core/geometry";
|
import type { Rect, Size, Vec2D } from "@core/geometry";
|
||||||
import type { ArtboardId, LayerId } from "@core/id";
|
import type { InputArtboardId, InputDocument, InputLayer, InputLayerId } from "./read-model";
|
||||||
import type { Layer } from "@core/layer";
|
|
||||||
|
|
||||||
export type InputViewportState = {
|
export type InputViewportState = {
|
||||||
center: Vec2D;
|
center: Vec2D;
|
||||||
@@ -11,21 +9,21 @@ export type InputViewportState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type InputSelectionState = {
|
export type InputSelectionState = {
|
||||||
artboardId?: ArtboardId;
|
artboardId?: InputArtboardId;
|
||||||
layerIds: LayerId[];
|
layerIds: InputLayerId[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type InputTransformTarget =
|
export type InputTransformTarget =
|
||||||
| { type: "artboard"; id: ArtboardId }
|
| { type: "artboard"; id: InputArtboardId }
|
||||||
| { type: "layer"; id: LayerId };
|
| { 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.layerIds.length === 1 && selection.layerIds[0]) return { type: "layer", id: selection.layerIds[0] };
|
||||||
if (selection.artboardId) return { type: "artboard", id: selection.artboardId };
|
if (selection.artboardId) return { type: "artboard", id: selection.artboardId };
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveTransformTargetBounds(document: ImageDocument, target: InputTransformTarget): Rect | undefined {
|
export function resolveTransformTargetBounds(document: InputDocument, target: InputTransformTarget): Rect | undefined {
|
||||||
switch (target.type) {
|
switch (target.type) {
|
||||||
case "artboard":
|
case "artboard":
|
||||||
return document.artboards.find((artboard) => artboard.id === target.id)?.bounds;
|
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) {
|
for (const artboard of document.artboards) {
|
||||||
const layer = findLayerInTree(artboard.layers, layerId);
|
const layer = findLayerInTree(artboard.layers, layerId);
|
||||||
if (layer) return layer;
|
if (layer) return layer;
|
||||||
@@ -61,7 +59,7 @@ function findLayer(document: ImageDocument, layerId: LayerId): Layer | undefined
|
|||||||
return 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) {
|
for (const layer of layers) {
|
||||||
if (layer.id === layerId) return layer;
|
if (layer.id === layerId) return layer;
|
||||||
if (layer.type === "group") {
|
if (layer.type === "group") {
|
||||||
@@ -73,7 +71,7 @@ function findLayerInTree(layers: readonly Layer[], layerId: LayerId): Layer | un
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveLayerBounds(document: ImageDocument, layer: Layer): Rect | undefined {
|
function resolveLayerBounds(document: InputDocument, layer: InputLayer): Rect | undefined {
|
||||||
switch (layer.type) {
|
switch (layer.type) {
|
||||||
case "group":
|
case "group":
|
||||||
return unionRects(layer.children.flatMap((child) => {
|
return unionRects(layer.children.flatMap((child) => {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { commandIds } from "@commands/ids";
|
import { commandIds } from "@commands/ids";
|
||||||
import type { ImageDocument } from "@core/document";
|
import type { InputDocument as ImageDocument, InputLayer as Layer } from "./read-model";
|
||||||
import type { Layer } from "@core/layer";
|
|
||||||
import { handleDeleteSelectionKey, resolveLayerDrop } from "./layers-panel";
|
import { handleDeleteSelectionKey, resolveLayerDrop } from "./layers-panel";
|
||||||
|
|
||||||
const document: ImageDocument = {
|
const document: ImageDocument = {
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { commandIds } from "@commands/ids";
|
import { commandIds } from "@commands/ids";
|
||||||
import type { Dispatch } from "@commands/dispatcher";
|
import type { Dispatch } from "@commands/dispatcher";
|
||||||
import type { ImageDocument } from "@core/document";
|
import type { InputArtboardId as ArtboardId, InputDocument, InputLayer as Layer, InputLayerId as LayerId } from "./read-model";
|
||||||
import type { ArtboardId, LayerId } from "@core/id";
|
|
||||||
import type { Layer } from "@core/layer";
|
|
||||||
import type { KeybindEvent } from "./keyboard";
|
import type { KeybindEvent } from "./keyboard";
|
||||||
|
|
||||||
export type LayerInfo = {
|
export type LayerInfo = {
|
||||||
@@ -33,7 +31,7 @@ export function handleDeleteSelectionKey(options: { event: KeybindEvent; selecti
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolveLayerDrop(options: {
|
export function resolveLayerDrop(options: {
|
||||||
document: ImageDocument;
|
document: InputDocument;
|
||||||
sourceLayerId: LayerId;
|
sourceLayerId: LayerId;
|
||||||
target: LayerDropTarget;
|
target: LayerDropTarget;
|
||||||
verticalRatio: number;
|
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;
|
if (!layerId) return undefined;
|
||||||
for (const artboard of document.artboards) {
|
for (const artboard of document.artboards) {
|
||||||
const found = findLayerInfo(artboard.layers, layerId, artboard.id);
|
const found = findLayerInfo(artboard.layers, layerId, artboard.id);
|
||||||
@@ -84,7 +82,7 @@ export function findLayerInfoInDocument(document: ImageDocument, layerId?: Layer
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findGroup(document: ImageDocument, groupId: LayerId): Extract<Layer, { type: "group" }> | undefined {
|
export function findGroup(document: InputDocument, groupId: LayerId): Extract<Layer, { type: "group" }> | undefined {
|
||||||
const info = findLayerInfoInDocument(document, groupId);
|
const info = findLayerInfoInDocument(document, groupId);
|
||||||
return info?.layer.type === "group" ? info.layer : undefined;
|
return info?.layer.type === "group" ? info.layer : undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
27
input/read-model.ts
Normal file
27
input/read-model.ts
Normal file
@@ -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[] }>;
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { commandIds } from "@commands/ids";
|
import { commandIds } from "@commands/ids";
|
||||||
import type { ImageDocument } from "@core/document";
|
import type { InputDocument as ImageDocument } from "./read-model";
|
||||||
import { handleArtboardSelection } from "./selection";
|
import { handleArtboardSelection } from "./selection";
|
||||||
import type { PointerInputEvent } from "./pointer";
|
import type { PointerInputEvent } from "./pointer";
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { commandIds } from "@commands/ids";
|
import { commandIds } from "@commands/ids";
|
||||||
import type { Dispatch } from "@commands/dispatcher";
|
import type { Dispatch } from "@commands/dispatcher";
|
||||||
import type { ImageDocument } from "@core/document";
|
import type { InputDocument, InputLayer } from "./read-model";
|
||||||
import type { Layer } from "@core/layer";
|
|
||||||
import { getLayerMask } from "@core/layer-mask-utils";
|
|
||||||
import { resolveTransformTargetBounds, viewportPointToDocumentPoint, type InputViewportState } from "./document-geometry";
|
import { resolveTransformTargetBounds, viewportPointToDocumentPoint, type InputViewportState } from "./document-geometry";
|
||||||
import type { PointerInputEvent } from "./pointer";
|
import type { PointerInputEvent } from "./pointer";
|
||||||
|
|
||||||
export function handleArtboardSelection(options: {
|
export function handleArtboardSelection(options: {
|
||||||
event: PointerInputEvent;
|
event: PointerInputEvent;
|
||||||
document: ImageDocument;
|
document: InputDocument;
|
||||||
viewport: InputViewportState;
|
viewport: InputViewportState;
|
||||||
dispatch: Dispatch;
|
dispatch: Dispatch;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
@@ -36,7 +34,7 @@ export function handleArtboardSelection(options: {
|
|||||||
return true;
|
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));
|
const maskLayerIds = collectMaskLayerIds(document.artboards.flatMap((artboard) => artboard.layers));
|
||||||
for (const artboard of [...document.artboards].reverse()) {
|
for (const artboard of [...document.artboards].reverse()) {
|
||||||
if (!artboard.visible || artboard.locked) continue;
|
if (!artboard.visible || artboard.locked) continue;
|
||||||
@@ -47,7 +45,7 @@ function findTopmostLayerAtPoint(document: ImageDocument, point: { x: number; y:
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findTopmostLayerInTreeAtPoint(document: ImageDocument, layers: Layer[], point: { x: number; y: number }, maskLayerIds: ReadonlySet<string>): string | undefined {
|
function findTopmostLayerInTreeAtPoint(document: InputDocument, layers: InputLayer[], point: { x: number; y: number }, maskLayerIds: ReadonlySet<string>): string | undefined {
|
||||||
for (const layer of layers) {
|
for (const layer of layers) {
|
||||||
if (!layer.visible || layer.locked || maskLayerIds.has(layer.id)) continue;
|
if (!layer.visible || layer.locked || maskLayerIds.has(layer.id)) continue;
|
||||||
if (layer.type === "group") {
|
if (layer.type === "group") {
|
||||||
@@ -64,9 +62,9 @@ function findTopmostLayerInTreeAtPoint(document: ImageDocument, layers: Layer[],
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectMaskLayerIds(layers: readonly Layer[], ids = new Set<string>()): Set<string> {
|
function collectMaskLayerIds(layers: readonly InputLayer[], ids = new Set<string>()): Set<string> {
|
||||||
for (const layer of layers) {
|
for (const layer of layers) {
|
||||||
const layerMask = getLayerMask(layer);
|
const layerMask = layer.layerMask ?? layer.clippingMask;
|
||||||
if (layerMask) ids.add(layerMask.maskLayerId);
|
if (layerMask) ids.add(layerMask.maskLayerId);
|
||||||
if (layer.type === "group") collectMaskLayerIds(layer.children, ids);
|
if (layer.type === "group") collectMaskLayerIds(layer.children, ids);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { commandIds } from "@commands/ids";
|
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 type { PointerInputEvent } from "./pointer";
|
||||||
import { createTransformControlsInputController, hitTestArtboardTransformHandle, type TransformControlsEditorState } from "./transform-controls";
|
import { createTransformControlsInputController, hitTestArtboardTransformHandle, type TransformControlsEditorState } from "./transform-controls";
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { commandIds } from "@commands/ids";
|
import { commandIds } from "@commands/ids";
|
||||||
import type { Dispatch } from "@commands/dispatcher";
|
import type { Dispatch } from "@commands/dispatcher";
|
||||||
import type { ImageDocument } from "@core/document";
|
|
||||||
import type { Rect, Vec2D } from "@core/geometry";
|
import type { Rect, Vec2D } from "@core/geometry";
|
||||||
|
import type { InputDocument } from "./read-model";
|
||||||
import {
|
import {
|
||||||
documentRectToViewportRect,
|
documentRectToViewportRect,
|
||||||
resolveTransformTargetBounds,
|
resolveTransformTargetBounds,
|
||||||
@@ -39,7 +39,7 @@ export type TransformControlsInputController = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function createTransformControlsInputController(options: {
|
export function createTransformControlsInputController(options: {
|
||||||
getDocument: () => ImageDocument;
|
getDocument: () => InputDocument;
|
||||||
getEditor: () => TransformControlsEditorState;
|
getEditor: () => TransformControlsEditorState;
|
||||||
dispatch: Dispatch;
|
dispatch: Dispatch;
|
||||||
}): TransformControlsInputController {
|
}): TransformControlsInputController {
|
||||||
@@ -93,7 +93,7 @@ export function hitTestArtboardTransformHandle(position: Vec2D, bounds: Rect, vi
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTransformTargetLocked(document: ImageDocument, target: InputTransformTarget) {
|
function isTransformTargetLocked(document: InputDocument, target: InputTransformTarget) {
|
||||||
if (target.type === "artboard") {
|
if (target.type === "artboard") {
|
||||||
const artboard = document.artboards.find((candidate) => candidate.id === target.id);
|
const artboard = document.artboards.find((candidate) => candidate.id === target.id);
|
||||||
return !artboard || !artboard.visible || artboard.locked;
|
return !artboard || !artboard.visible || artboard.locked;
|
||||||
|
|||||||
7
operations/AGENTS.md
Normal file
7
operations/AGENTS.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Application Operation Rules
|
||||||
|
|
||||||
|
- `operations/` coordinates asynchronous editor use cases such as generation, import, raster processing, and export.
|
||||||
|
- Operations may read immutable state snapshots, call injected platform capabilities, and dispatch commands.
|
||||||
|
- Operations must never mutate `ImageDocument` or `EditorState` directly.
|
||||||
|
- Keep React, DOM elements, WebGL objects, server-only APIs, and concrete network details out of operation contracts.
|
||||||
|
- Prefer dependency injection for platform behavior so operation control flow remains testable.
|
||||||
4
operations/export/downloadArtboard.ts
Normal file
4
operations/export/downloadArtboard.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import type { Artboard } from "@core/artboard";
|
||||||
|
import type { Asset } from "@core/asset";
|
||||||
|
import { downloadArtboardPng as download } from "@platform/browser/exportArtboardPng";
|
||||||
|
export function downloadArtboardPng(artboard: Artboard, assets: readonly Asset[]) { return download(artboard, assets); }
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ImageDocument } from "@core/document";
|
import type { ImageDocument } from "@core/document";
|
||||||
import type { GenerationCandidate } from "@editor/state";
|
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<string> {
|
export async function createMaskedPixelReplacementSource(document: ImageDocument, candidate: GenerationCandidate): Promise<string> {
|
||||||
if (!candidate.inpaint) throw new Error("Only inpaint candidates can replace masked pixels.");
|
if (!candidate.inpaint) throw new Error("Only inpaint candidates can replace masked pixels.");
|
||||||
26
operations/generation/generationJob.ts
Normal file
26
operations/generation/generationJob.ts
Normal file
@@ -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<void>;
|
||||||
|
}): Promise<void> {
|
||||||
|
const jobId = crypto.randomUUID();
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const nextState = options.dispatch(commandIds.generationStartJob, { jobId, kind: options.kind, label: options.label, startedAt });
|
||||||
|
if (!nextState.editor.generation.jobs.some((job) => job.id === jobId && job.status === "running")) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await options.task();
|
||||||
|
options.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`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import { getLayerMask } from "@core/layer-mask-utils";
|
|||||||
import type { SelectionState } from "@editor/state";
|
import type { SelectionState } from "@editor/state";
|
||||||
import type { GenerateSettings } from "@editor/tools";
|
import type { GenerateSettings } from "@editor/tools";
|
||||||
import { createDocumentReadIndex, resolveIndexedLayerBounds } from "@editor/document-indexes";
|
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 = {
|
export type InpaintBundle = {
|
||||||
inputImage: string;
|
inputImage: string;
|
||||||
16
operations/generation/loadResources.ts
Normal file
16
operations/generation/loadResources.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { commandIds } from "@commands/ids";
|
||||||
|
import type { AppStore } from "@editor/store";
|
||||||
|
import { fetchGenerationOptions } from "@platform/comfy/generationClient";
|
||||||
|
import type { GenerationOptions } from "@editor/state";
|
||||||
|
|
||||||
|
export async function loadGenerationResources(store: AppStore): Promise<void> {
|
||||||
|
const current = store.getState().editor.generation.resources.status;
|
||||||
|
if (current === "loading" || current === "ready") return;
|
||||||
|
store.dispatch(commandIds.generationLoadResources, undefined);
|
||||||
|
try {
|
||||||
|
const options = await fetchGenerationOptions() as GenerationOptions;
|
||||||
|
store.dispatch(commandIds.generationSetResources, { options });
|
||||||
|
} catch (reason: unknown) {
|
||||||
|
store.dispatch(commandIds.generationFailResources, { error: reason instanceof Error ? reason.message : "Unable to load ComfyUI models" });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import type { AppStore } from "@editor/store";
|
|||||||
import type { GenerationCandidate, SelectionState, ViewportState } from "@editor/state";
|
import type { GenerationCandidate, SelectionState, ViewportState } from "@editor/state";
|
||||||
import type { GenerateSettings } from "@editor/tools";
|
import type { GenerateSettings } from "@editor/tools";
|
||||||
import { buildInpaintBundle, type InpaintBundle } from "./inpaintPrep";
|
import { buildInpaintBundle, type InpaintBundle } from "./inpaintPrep";
|
||||||
|
import { imageSourceToDataUrl, loadImageSize } from "@platform/browser/imageRaster";
|
||||||
|
import { requestGeneration } from "@platform/comfy/generationClient";
|
||||||
|
|
||||||
export async function runGenerate(options: {
|
export async function runGenerate(options: {
|
||||||
document: ImageDocument;
|
document: ImageDocument;
|
||||||
@@ -142,10 +144,7 @@ async function requestGenerate(options: {
|
|||||||
inpaintBundle?: InpaintBundle;
|
inpaintBundle?: InpaintBundle;
|
||||||
inpaintCandidate?: GenerationCandidate;
|
inpaintCandidate?: GenerationCandidate;
|
||||||
}) {
|
}) {
|
||||||
const response = await fetch("/api/comfy/generate", {
|
return requestGeneration({
|
||||||
method: "POST",
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
architecture: options.settings.architecture,
|
architecture: options.settings.architecture,
|
||||||
mode: options.settings.mode,
|
mode: options.settings.mode,
|
||||||
model: options.settings.model,
|
model: options.settings.model,
|
||||||
@@ -165,10 +164,7 @@ async function requestGenerate(options: {
|
|||||||
inpaint: resolveInpaintRequest(options.inpaintBundle, options.inpaintCandidate, options.settings),
|
inpaint: resolveInpaintRequest(options.inpaintBundle, options.inpaintCandidate, options.settings),
|
||||||
inputImage: options.inputImage,
|
inputImage: options.inputImage,
|
||||||
maskImage: options.maskImage,
|
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) {
|
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;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function imageSourceToDataUrl(source: string) {
|
|
||||||
if (source.startsWith("data:")) return source;
|
|
||||||
const image = await loadImage(source);
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
canvas.width = image.naturalWidth;
|
|
||||||
canvas.height = image.naturalHeight;
|
|
||||||
const context = canvas.getContext("2d");
|
|
||||||
if (!context) throw new Error("Unable to read selected image");
|
|
||||||
context.drawImage(image, 0, 0);
|
|
||||||
return canvas.toDataURL("image/png");
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadImageSize(source: string): Promise<{ w: number; h: number }> {
|
|
||||||
return loadImage(source).then((image) => ({ w: image.naturalWidth, h: image.naturalHeight }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadImage(source: string): Promise<HTMLImageElement> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const image = new Image();
|
|
||||||
image.onload = () => resolve(image);
|
|
||||||
image.onerror = () => reject(new Error("Failed to load image"));
|
|
||||||
image.src = source;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
34
operations/import/importImage.ts
Normal file
34
operations/import/importImage.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { commandIds } from "@commands/ids";
|
||||||
|
import type { AppStore } from "@editor/store";
|
||||||
|
import type { BrowserImageFile } from "@platform/browser/imageFiles";
|
||||||
|
|
||||||
|
export function importImageAsLayer(store: AppStore, image: BrowserImageFile): boolean {
|
||||||
|
const state = store.getState();
|
||||||
|
const artboard = state.editor.selection.artboardId
|
||||||
|
? state.document.artboards.find((candidate) => candidate.id === state.editor.selection.artboardId)
|
||||||
|
: state.document.artboards[0];
|
||||||
|
if (!artboard) {
|
||||||
|
image.release();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetId = crypto.randomUUID();
|
||||||
|
const layerId = crypto.randomUUID();
|
||||||
|
const center = state.editor.viewport.center;
|
||||||
|
store.dispatch(commandIds.documentAddAsset, { asset: { id: assetId, name: image.name, mimeType: image.mimeType, source: image.source, intrinsicSize: image.intrinsicSize } });
|
||||||
|
store.dispatch(commandIds.documentAddImageLayer, {
|
||||||
|
artboardId: artboard.id,
|
||||||
|
layer: {
|
||||||
|
id: layerId,
|
||||||
|
type: "image",
|
||||||
|
name: image.name,
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
opacity: 1,
|
||||||
|
assetId,
|
||||||
|
transform: { position: { x: center.x - image.intrinsicSize.w / 2, y: center.y - image.intrinsicSize.h / 2 }, scale: { x: 1, y: 1 }, rotation: 0 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
store.dispatch(commandIds.selectionSet, { artboardId: artboard.id, layerIds: [layerId] });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
51
operations/masks/chromaKey.ts
Normal file
51
operations/masks/chromaKey.ts
Normal file
@@ -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<ReturnType<typeof resolveChromaKeyTarget>>, settings: ChromaKeySettings, dispatch: AppStore["dispatch"]) {
|
||||||
|
const source = await createChromaKeyMask(target.asset.source, target.asset.intrinsicSize.w, target.asset.intrinsicSize.h, settings);
|
||||||
|
dispatch(commandIds.toolSetBrushStrokePreview, undefined);
|
||||||
|
if (target.maskAsset && target.maskLayer && target.maskLayer.type !== "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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
60
operations/masks/magic-wand.ts
Normal file
60
operations/masks/magic-wand.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
10
operations/masks/rasterActions.ts
Normal file
10
operations/masks/rasterActions.ts
Normal file
@@ -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<MaskAnalysis> { 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"); }
|
||||||
@@ -6,14 +6,14 @@ import type { RasterLayer } from "@core/raster-layer";
|
|||||||
import type { MaskEditState, SelectionState } from "@editor/state";
|
import type { MaskEditState, SelectionState } from "@editor/state";
|
||||||
import { isPanInteractionMode, type ToolState } from "@editor/tools";
|
import { isPanInteractionMode, type ToolState } from "@editor/tools";
|
||||||
import type { AppStore } from "@editor/store";
|
import type { AppStore } from "@editor/store";
|
||||||
|
import { brushSurfaceDataUrl, brushSurfaceObjectUrl, cancelFrame, createBrushSurface, drawBrushSegment, releaseObjectUrl, scheduleFrame, type BrushSurface } from "@platform/browser/brushRaster";
|
||||||
|
|
||||||
export type BrushSession = {
|
export type BrushSession = {
|
||||||
layerId: string;
|
layerId: string;
|
||||||
assetId: string;
|
assetId: string;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
canvas: HTMLCanvasElement;
|
surface: BrushSurface;
|
||||||
context: CanvasRenderingContext2D;
|
|
||||||
ready: Promise<boolean>;
|
ready: Promise<boolean>;
|
||||||
previousPoint: Vec2D;
|
previousPoint: Vec2D;
|
||||||
mode: "brush" | "eraser";
|
mode: "brush" | "eraser";
|
||||||
@@ -40,24 +40,19 @@ export function beginBrushSession(document: ImageDocument, editor: BrushTargetEd
|
|||||||
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
||||||
if (!asset) return undefined;
|
if (!asset) return undefined;
|
||||||
|
|
||||||
const canvas = globalThis.document.createElement("canvas");
|
const surface = createBrushSurface(asset.intrinsicSize.w, asset.intrinsicSize.h, asset.source);
|
||||||
canvas.width = Math.max(1, Math.round(asset.intrinsicSize.w));
|
if (!surface) return undefined;
|
||||||
canvas.height = Math.max(1, Math.round(asset.intrinsicSize.h));
|
|
||||||
const context = canvas.getContext("2d");
|
|
||||||
if (!context) return undefined;
|
|
||||||
|
|
||||||
const session: BrushSession = {
|
const session: BrushSession = {
|
||||||
layerId: layer.id,
|
layerId: layer.id,
|
||||||
assetId: layer.assetId,
|
assetId: layer.assetId,
|
||||||
width: canvas.width,
|
width: surface.width,
|
||||||
height: canvas.height,
|
height: surface.height,
|
||||||
canvas,
|
surface,
|
||||||
context,
|
ready: surface.ready,
|
||||||
ready: Promise.resolve(false),
|
|
||||||
previousPoint: point,
|
previousPoint: point,
|
||||||
mode: editor.tools.activeTool,
|
mode: editor.tools.activeTool,
|
||||||
};
|
};
|
||||||
session.ready = initializeBrushSession(session, asset.source).catch(() => false);
|
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,8 +109,7 @@ export function updateBrushSession(options: {
|
|||||||
if (options.session.cancelled) return;
|
if (options.session.cancelled) return;
|
||||||
if (!(await options.session.ready) || options.session.cancelled) return;
|
if (!(await options.session.ready) || options.session.cancelled) return;
|
||||||
|
|
||||||
drawStrokeSegment({
|
drawBrushSegment(options.session.surface, {
|
||||||
context: options.session.context,
|
|
||||||
from: documentPointToAssetPoint(from, layer, options.session.width, options.session.height),
|
from: documentPointToAssetPoint(from, layer, options.session.width, options.session.height),
|
||||||
to: documentPointToAssetPoint(to, layer, options.session.width, options.session.height),
|
to: documentPointToAssetPoint(to, layer, options.session.width, options.session.height),
|
||||||
color: state.editor.maskEdit ? "#ffffff" : options.color,
|
color: state.editor.maskEdit ? "#ffffff" : options.color,
|
||||||
@@ -137,7 +131,7 @@ export async function commitBrushSession(options: { store: AppStore; session: Br
|
|||||||
await options.session.pending;
|
await options.session.pending;
|
||||||
if (options.session.cancelled) return;
|
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) {
|
if (source) {
|
||||||
const state = options.store.getState();
|
const state = options.store.getState();
|
||||||
const maskEdit = state.editor.maskEdit;
|
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 }) {
|
function requestBrushStrokePreview(options: { store: AppStore; session: BrushSession }) {
|
||||||
if (options.session.cancelled || options.session.previewClosed) return;
|
if (options.session.cancelled || options.session.previewClosed) return;
|
||||||
|
|
||||||
options.session.previewRequested = true;
|
options.session.previewRequested = true;
|
||||||
if (options.session.previewFrame !== undefined || options.session.previewInFlight) return;
|
if (options.session.previewFrame !== undefined || options.session.previewInFlight) return;
|
||||||
|
|
||||||
options.session.previewFrame = requestAnimationFrame(() => {
|
options.session.previewFrame = scheduleFrame(() => {
|
||||||
options.session.previewFrame = undefined;
|
options.session.previewFrame = undefined;
|
||||||
void publishBrushStrokePreview(options);
|
void publishBrushStrokePreview(options);
|
||||||
});
|
});
|
||||||
@@ -216,7 +175,7 @@ async function publishBrushStrokePreview(options: { store: AppStore; session: Br
|
|||||||
|
|
||||||
options.session.previewRequested = false;
|
options.session.previewRequested = false;
|
||||||
options.session.previewInFlight = true;
|
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;
|
options.session.previewInFlight = false;
|
||||||
|
|
||||||
if (!source) {
|
if (!source) {
|
||||||
@@ -225,14 +184,14 @@ async function publishBrushStrokePreview(options: { store: AppStore; session: Br
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (options.session.cancelled || options.session.previewClosed) {
|
if (options.session.cancelled || options.session.previewClosed) {
|
||||||
URL.revokeObjectURL(source);
|
releaseObjectUrl(source);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const previousSource = options.session.previewSource;
|
const previousSource = options.session.previewSource;
|
||||||
options.session.previewSource = source;
|
options.session.previewSource = source;
|
||||||
options.store.dispatch(commandIds.toolSetBrushStrokePreview, { layerId: options.session.layerId, assetId: options.session.assetId, 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);
|
if (options.session.previewRequested) requestBrushStrokePreview(options);
|
||||||
}
|
}
|
||||||
@@ -240,40 +199,15 @@ async function publishBrushStrokePreview(options: { store: AppStore; session: Br
|
|||||||
function closeBrushStrokePreview(session: BrushSession) {
|
function closeBrushStrokePreview(session: BrushSession) {
|
||||||
session.previewClosed = true;
|
session.previewClosed = true;
|
||||||
if (session.previewFrame !== undefined) {
|
if (session.previewFrame !== undefined) {
|
||||||
cancelAnimationFrame(session.previewFrame);
|
cancelFrame(session.previewFrame);
|
||||||
session.previewFrame = undefined;
|
session.previewFrame = undefined;
|
||||||
}
|
}
|
||||||
if (session.previewSource) {
|
if (session.previewSource) {
|
||||||
URL.revokeObjectURL(session.previewSource);
|
releaseObjectUrl(session.previewSource);
|
||||||
session.previewSource = undefined;
|
session.previewSource = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function canvasToObjectUrl(canvas: HTMLCanvasElement) {
|
|
||||||
return new Promise<string | undefined>((resolve) => {
|
|
||||||
canvas.toBlob((blob) => {
|
|
||||||
resolve(blob ? URL.createObjectURL(blob) : undefined);
|
|
||||||
}, "image/png");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function canvasToDataUrl(canvas: HTMLCanvasElement) {
|
|
||||||
try {
|
|
||||||
return canvas.toDataURL("image/png");
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadImage(source: string) {
|
|
||||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
|
||||||
const image = new Image();
|
|
||||||
image.onload = () => resolve(image);
|
|
||||||
image.onerror = () => reject(new Error("Failed to load raster layer"));
|
|
||||||
image.src = source;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function findRasterLayer(layers: Layer[], layerId: string): RasterLayer | undefined {
|
function findRasterLayer(layers: Layer[], layerId: string): RasterLayer | undefined {
|
||||||
const layer = findLayer(layers, layerId);
|
const layer = findLayer(layers, layerId);
|
||||||
return layer?.type === "raster" ? layer : undefined;
|
return layer?.type === "raster" ? layer : undefined;
|
||||||
6
platform/AGENTS.md
Normal file
6
platform/AGENTS.md
Normal file
@@ -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.
|
||||||
29
platform/browser/brushRaster.ts
Normal file
29
platform/browser/brushRaster.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export type BrushSurface = {
|
||||||
|
readonly width: number;
|
||||||
|
readonly height: number;
|
||||||
|
readonly ready: Promise<boolean>;
|
||||||
|
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<boolean> }).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<string | undefined>((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<HTMLImageElement>((resolve, reject) => { const image = new Image(); image.onload = () => resolve(image); image.onerror = () => reject(new Error("Failed to load raster layer")); image.src = source; }); }
|
||||||
66
platform/browser/chromaKey.ts
Normal file
66
platform/browser/chromaKey.ts
Normal file
@@ -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<ArrayBufferLike> = 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<HTMLImageElement>((resolve, reject) => {
|
||||||
|
const image = new Image();
|
||||||
|
image.onload = () => resolve(image);
|
||||||
|
image.onerror = () => reject(new Error("Failed to load image"));
|
||||||
|
image.src = source;
|
||||||
|
});
|
||||||
|
}
|
||||||
32
platform/browser/imageFiles.ts
Normal file
32
platform/browser/imageFiles.ts
Normal file
@@ -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<BrowserImageFile | undefined> {
|
||||||
|
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<string> {
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
24
platform/browser/imageRaster.ts
Normal file
24
platform/browser/imageRaster.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
export async function imageSourceToDataUrl(source: string): Promise<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");
|
||||||
|
}
|
||||||
|
|
||||||
|
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<HTMLImageElement> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const image = new Image();
|
||||||
|
image.onload = () => resolve(image);
|
||||||
|
image.onerror = () => reject(new Error("Failed to load image"));
|
||||||
|
image.src = source;
|
||||||
|
});
|
||||||
|
}
|
||||||
38
platform/browser/magicWandRaster.ts
Normal file
38
platform/browser/magicWandRaster.ts
Normal file
@@ -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<ArrayBufferLike> = 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<HTMLImageElement>((resolve, reject) => { const image = new Image(); image.onload = () => resolve(image); image.onerror = () => reject(new Error("Failed to load image")); image.src = source; }); }
|
||||||
11
platform/comfy/generationClient.ts
Normal file
11
platform/comfy/generationClient.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export async function fetchGenerationOptions(): Promise<unknown> {
|
||||||
|
const response = await fetch("/api/comfy/models");
|
||||||
|
if (!response.ok) throw new Error("Unable to load ComfyUI models");
|
||||||
|
return response.json() as Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }>;
|
||||||
|
}
|
||||||
324
renderer/image-texture-programs.ts
Normal file
324
renderer/image-texture-programs.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 { Asset } from "@core/asset";
|
||||||
import type { RgbaColor, ScreenRect, WebGlRendererContext } from "./types";
|
import type { RgbaColor, ScreenRect, WebGlRendererContext } from "./types";
|
||||||
|
|
||||||
@@ -24,15 +25,6 @@ type TextureEntry = {
|
|||||||
previousTexture?: WebGLTexture;
|
previousTexture?: WebGLTexture;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MaskVisualizationResources = {
|
|
||||||
program: WebGLProgram;
|
|
||||||
positionLocation: number;
|
|
||||||
texCoordLocation: number;
|
|
||||||
samplerLocation: WebGLUniformLocation;
|
|
||||||
modeLocation: WebGLUniformLocation;
|
|
||||||
colorLocation: WebGLUniformLocation;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function createImageTextureRenderer(context: WebGlRendererContext, invalidate: () => void): ImageTextureRenderer {
|
export function createImageTextureRenderer(context: WebGlRendererContext, invalidate: () => void): ImageTextureRenderer {
|
||||||
const { gl } = context;
|
const { gl } = context;
|
||||||
const program = createProgram(gl);
|
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]);
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
6
server/AGENTS.md
Normal file
6
server/AGENTS.md
Normal file
@@ -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.
|
||||||
16
server/comfy-routes.ts
Normal file
16
server/comfy-routes.ts
Normal file
@@ -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" } });
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
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", () => {
|
describe("Comfy adapter", () => {
|
||||||
test("selects SaveImage output instead of uploaded input or mask images", () => {
|
test("selects SaveImage output instead of uploaded input or mask images", () => {
|
||||||
@@ -2,7 +2,7 @@ type GenerateArchitecture = "sdxl" | "z-image" | "z-image-turbo" | "anima";
|
|||||||
type GenerateMode = "text-to-image" | "image-to-image" | "inpaint" | "outpaint";
|
type GenerateMode = "text-to-image" | "image-to-image" | "inpaint" | "outpaint";
|
||||||
type Workflow = Record<string, { class_type: string; inputs: Record<string, unknown> }>;
|
type Workflow = Record<string, { class_type: string; inputs: Record<string, unknown> }>;
|
||||||
|
|
||||||
type ComfyGenerateRequest = {
|
export type ComfyGenerateRequest = {
|
||||||
architecture?: GenerateArchitecture;
|
architecture?: GenerateArchitecture;
|
||||||
mode: GenerateMode;
|
mode: GenerateMode;
|
||||||
model: string;
|
model: string;
|
||||||
@@ -58,18 +58,7 @@ const defaultModels: Record<GenerateArchitecture, string> = {
|
|||||||
anima: "anima-base-v1.0.safetensors",
|
anima: "anima-base-v1.0.safetensors",
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function handleComfyApi(request: Request) {
|
export async function listGenerationOptions() {
|
||||||
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() {
|
|
||||||
const response = await fetch(`${comfyBaseUrl}/object_info`);
|
const response = await fetch(`${comfyBaseUrl}/object_info`);
|
||||||
if (!response.ok) throw new Error(`ComfyUI option lookup failed: ${response.status}`);
|
if (!response.ok) throw new Error(`ComfyUI option lookup failed: ${response.status}`);
|
||||||
const info = await response.json() as ComfyObjectInfo;
|
const info = await response.json() as ComfyObjectInfo;
|
||||||
@@ -122,7 +111,7 @@ async function listCheckpointModels() {
|
|||||||
return (await listGenerationOptions()).models;
|
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");
|
if (!request.prompt?.trim()) throw new Error("Prompt is required");
|
||||||
const architecture = normalizeArchitecture(request.architecture);
|
const architecture = normalizeArchitecture(request.architecture);
|
||||||
if (request.mode !== "text-to-image" && architecture !== "sdxl") throw new Error(`${architectureLabel(architecture)} currently supports text-to-image only`);
|
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;
|
return eventName;
|
||||||
}
|
}
|
||||||
|
|
||||||
function json(value: unknown) {
|
|
||||||
return new Response(JSON.stringify(value), { headers: { "content-type": "application/json" } });
|
|
||||||
}
|
|
||||||
@@ -30,7 +30,10 @@
|
|||||||
"@commands/*": ["./commands/*"],
|
"@commands/*": ["./commands/*"],
|
||||||
"@editor/*": ["./editor/*"],
|
"@editor/*": ["./editor/*"],
|
||||||
"@input/*": ["./input/*"],
|
"@input/*": ["./input/*"],
|
||||||
"@app/*": ["./app/*"]
|
"@app/*": ["./app/*"],
|
||||||
|
"@operations/*": ["./operations/*"],
|
||||||
|
"@platform/*": ["./platform/*"],
|
||||||
|
"@server/*": ["./server/*"]
|
||||||
},
|
},
|
||||||
|
|
||||||
// Some stricter flags (disabled by default)
|
// Some stricter flags (disabled by default)
|
||||||
|
|||||||
61
view/App.tsx
61
view/App.tsx
@@ -1,23 +1,24 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { DownloadSimple, FolderOpen, Sparkle, Stack } from "@phosphor-icons/react";
|
import { DownloadSimple, FolderOpen, Sparkle, Stack } from "@phosphor-icons/react";
|
||||||
import type { ImageStudioApp } from "@app/app";
|
import type { ImageStudioApp } from "@app/app";
|
||||||
import { commandIds } from "@commands/ids";
|
import { commandIds } from "@commands/ids";
|
||||||
import { BottomControlsIsland } from "./BottomControlsIsland";
|
import { BottomControlsIsland } from "./BottomControlsIsland";
|
||||||
import { brushUnavailableHint } from "./canvas/brush";
|
import { brushUnavailableHint } from "@operations/paint/brush";
|
||||||
import { CanvasViewport } from "./CanvasViewport";
|
import { CanvasViewport } from "./CanvasViewport";
|
||||||
import { CommandPalette } from "./CommandPalette";
|
import { CommandPalette } from "./CommandPalette";
|
||||||
import { GenerateSheet } from "./GenerateSheet";
|
import { GenerateSheet } from "./GenerateSheet";
|
||||||
|
import { GenerationJobStatus } from "./GenerationJobStatus";
|
||||||
import { LayersSheet } from "./LayersSheet";
|
import { LayersSheet } from "./LayersSheet";
|
||||||
import { ShortcutsDisplay } from "./ShortcutsDisplay";
|
import { ShortcutsDisplay } from "./ShortcutsDisplay";
|
||||||
import { ToolOverlay } from "./ToolOverlay";
|
import { ToolOverlay } from "./ToolOverlay";
|
||||||
import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets";
|
import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets";
|
||||||
import type { AppState } from "@editor/state";
|
import type { AppState } from "@editor/state";
|
||||||
import type { ToolId } from "@editor/tools";
|
|
||||||
import { handleCommandPaletteKey, handleDeleteSelectionKey, handleHistoryKey, handleToolKey, keybindEventFromKeyboardEvent } from "@input/index";
|
import { handleCommandPaletteKey, handleDeleteSelectionKey, handleHistoryKey, handleToolKey, keybindEventFromKeyboardEvent } from "@input/index";
|
||||||
import { shallowEqual, useAppState } from "./useAppState";
|
import { shallowEqual, useAppState } from "./useAppState";
|
||||||
import { downloadArtboardPng } from "./exportArtboardPng";
|
import { downloadArtboardPng } from "@operations/export/downloadArtboard";
|
||||||
import { useImageImport } from "./useImageImport";
|
import { useImageImport } from "./useImageImport";
|
||||||
import { useViewportActivityIsland } from "./useViewportActivityIsland";
|
import { useViewportActivityIsland } from "./useViewportActivityIsland";
|
||||||
|
import { loadGenerationResources } from "@operations/generation/loadResources";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
export type AppProps = {
|
export type AppProps = {
|
||||||
@@ -26,40 +27,25 @@ export type AppProps = {
|
|||||||
|
|
||||||
export function App({ app }: AppProps) {
|
export function App({ app }: AppProps) {
|
||||||
const shellState = useAppState(app.store, selectAppShellState, shallowEqual);
|
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 viewportActivityIsland = useViewportActivityIsland(viewport);
|
||||||
const imageImport = useImageImport(app.store);
|
const imageImport = useImageImport(app.store);
|
||||||
const [layersOpen, setLayersOpen] = useState(false);
|
|
||||||
const previousGenerateTool = useRef<ToolId>("select");
|
|
||||||
const transformTarget = transformSession?.target ?? selectedTransformTarget(document, selection);
|
const transformTarget = transformSession?.target ?? selectedTransformTarget(document, selection);
|
||||||
const activeArtboard = document.artboards.find((artboard) => artboard.id === selection.artboardId) ?? document.artboards[0];
|
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(() => {
|
useEffect(() => {
|
||||||
if (tools.activeTool !== "generate") {
|
if (generateOpen) void loadGenerationResources(app.store);
|
||||||
previousGenerateTool.current = tools.activeTool;
|
}, [app.store, generateOpen]);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLayersOpen(false);
|
|
||||||
}, [tools.activeTool]);
|
|
||||||
|
|
||||||
const getGenerateReturnTool = useCallback((): ToolId => {
|
|
||||||
return previousGenerateTool.current === "generate" ? "select" : previousGenerateTool.current;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const openGenerate = useCallback(() => {
|
const openGenerate = useCallback(() => {
|
||||||
const activeTool = app.store.getState().editor.tools.activeTool;
|
app.store.dispatch(commandIds.workspaceSetPanel, { panel: "generate" });
|
||||||
if (activeTool !== "generate") previousGenerateTool.current = activeTool;
|
|
||||||
app.store.dispatch(commandIds.toolSetActive, { tool: "generate" });
|
|
||||||
setLayersOpen(false);
|
|
||||||
}, [app.store]);
|
}, [app.store]);
|
||||||
|
|
||||||
const closeGenerate = useCallback(() => {
|
const closeGenerate = useCallback(() => {
|
||||||
if (app.store.getState().editor.tools.activeTool === "generate") {
|
app.store.dispatch(commandIds.workspaceSetPanel, { panel: "none" });
|
||||||
app.store.dispatch(commandIds.toolSetActive, { tool: getGenerateReturnTool() });
|
}, [app.store]);
|
||||||
}
|
|
||||||
}, [app.store, getGenerateReturnTool]);
|
|
||||||
|
|
||||||
const toggleGenerate = useCallback(() => {
|
const toggleGenerate = useCallback(() => {
|
||||||
if (generateOpen) closeGenerate();
|
if (generateOpen) closeGenerate();
|
||||||
@@ -67,19 +53,16 @@ export function App({ app }: AppProps) {
|
|||||||
}, [closeGenerate, generateOpen, openGenerate]);
|
}, [closeGenerate, generateOpen, openGenerate]);
|
||||||
|
|
||||||
const openLayers = useCallback(() => {
|
const openLayers = useCallback(() => {
|
||||||
closeGenerate();
|
app.store.dispatch(commandIds.workspaceSetPanel, { panel: "layers" });
|
||||||
setLayersOpen(true);
|
}, [app.store]);
|
||||||
}, [closeGenerate]);
|
|
||||||
|
|
||||||
const closeLayers = useCallback(() => {
|
const closeLayers = useCallback(() => {
|
||||||
setLayersOpen(false);
|
app.store.dispatch(commandIds.workspaceSetPanel, { panel: "none" });
|
||||||
}, []);
|
}, [app.store]);
|
||||||
|
|
||||||
const toggleLayers = useCallback(() => {
|
const toggleLayers = useCallback(() => {
|
||||||
const nextOpen = !layersOpen;
|
app.store.dispatch(commandIds.workspaceSetPanel, { panel: layersOpen ? "none" : "layers" });
|
||||||
if (nextOpen) closeGenerate();
|
}, [app.store, layersOpen]);
|
||||||
setLayersOpen(nextOpen);
|
|
||||||
}, [closeGenerate, layersOpen]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
@@ -167,6 +150,9 @@ export function App({ app }: AppProps) {
|
|||||||
/>
|
/>
|
||||||
<header className="pointer-events-none absolute inset-x-3 top-3 z-10 flex h-20 items-center justify-end gap-4 rounded-full px-4 text-white backdrop-blur-xl">
|
<header className="pointer-events-none absolute inset-x-3 top-3 z-10 flex h-20 items-center justify-end gap-4 rounded-full px-4 text-white backdrop-blur-xl">
|
||||||
<div className="pointer-events-auto flex items-center gap-2">
|
<div className="pointer-events-auto flex items-center gap-2">
|
||||||
|
<button type="button" className="border-0 bg-transparent p-0" onClick={openGenerate} title="Open generation activity">
|
||||||
|
<GenerationJobStatus generation={generation} compact />
|
||||||
|
</button>
|
||||||
<button type="button" className={topBarButtonClass()} onClick={imageImport.openFilePicker}>
|
<button type="button" className={topBarButtonClass()} onClick={imageImport.openFilePicker}>
|
||||||
<FolderOpen size={24} />
|
<FolderOpen size={24} />
|
||||||
</button>
|
</button>
|
||||||
@@ -190,6 +176,7 @@ export function App({ app }: AppProps) {
|
|||||||
</div>
|
</div>
|
||||||
<GenerateSheet
|
<GenerateSheet
|
||||||
settings={tools.generate}
|
settings={tools.generate}
|
||||||
|
resources={generation.resources}
|
||||||
open={generateOpen}
|
open={generateOpen}
|
||||||
dispatch={app.store.dispatch}
|
dispatch={app.store.dispatch}
|
||||||
/>
|
/>
|
||||||
@@ -243,6 +230,7 @@ type AppShellState = {
|
|||||||
commandPalette: AppState["editor"]["commandPalette"];
|
commandPalette: AppState["editor"]["commandPalette"];
|
||||||
transformSession: AppState["editor"]["transformSession"];
|
transformSession: AppState["editor"]["transformSession"];
|
||||||
maskEdit: AppState["editor"]["maskEdit"];
|
maskEdit: AppState["editor"]["maskEdit"];
|
||||||
|
workspace: AppState["editor"]["workspace"];
|
||||||
};
|
};
|
||||||
|
|
||||||
function selectAppShellState(state: AppState): AppShellState {
|
function selectAppShellState(state: AppState): AppShellState {
|
||||||
@@ -255,6 +243,7 @@ function selectAppShellState(state: AppState): AppShellState {
|
|||||||
commandPalette: state.editor.commandPalette,
|
commandPalette: state.editor.commandPalette,
|
||||||
transformSession: state.editor.transformSession,
|
transformSession: state.editor.transformSession,
|
||||||
maskEdit: state.editor.maskEdit,
|
maskEdit: state.editor.maskEdit,
|
||||||
|
workspace: state.editor.workspace,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { AppState, MaskEditState } from "@editor/state";
|
|||||||
import type { AppStore } from "@editor/store";
|
import type { AppStore } from "@editor/store";
|
||||||
import type { InteractionMode } from "@editor/tools";
|
import type { InteractionMode } from "@editor/tools";
|
||||||
import type { GlobalKeybindConsumer, GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index";
|
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 { canvasCursorClass } from "./canvas/cursor";
|
||||||
import { useCanvasInput } from "./canvas/useCanvasInput";
|
import { useCanvasInput } from "./canvas/useCanvasInput";
|
||||||
import { useCanvasRenderer } from "./canvas/useCanvasRenderer";
|
import { useCanvasRenderer } from "./canvas/useCanvasRenderer";
|
||||||
@@ -37,10 +37,10 @@ export function CanvasViewport({
|
|||||||
|
|
||||||
useCanvasRenderer(canvasRef, store);
|
useCanvasRenderer(canvasRef, store);
|
||||||
useCanvasResize(canvasRef, store.dispatch);
|
useCanvasResize(canvasRef, store.dispatch);
|
||||||
const input = useCanvasInput(canvasRef, store, inputOptions);
|
useCanvasInput(canvasRef, store, inputOptions);
|
||||||
const brushHint = brushUnavailableHint(cursorState.document, cursorState.editor);
|
const brushHint = brushUnavailableHint(cursorState.document, cursorState.editor);
|
||||||
const hasBrushPreview = Boolean(cursorState.hasBrushPreview && !brushHint && canPreviewBrush(cursorState.document, cursorState.editor));
|
const hasBrushPreview = Boolean(cursorState.hasBrushPreview && !brushHint && canPreviewBrush(cursorState.document, cursorState.editor));
|
||||||
const cursorClass = canvasCursorClass(cursorState.editor.tools.interactionMode, input, hasBrushPreview, !brushHint);
|
const cursorClass = canvasCursorClass(cursorState.editor.tools.interactionMode, cursorState.isPanning, hasBrushPreview, !brushHint);
|
||||||
|
|
||||||
return <canvas ref={canvasRef} className={`h-full w-full ${cursorClass}`} />;
|
return <canvas ref={canvasRef} className={`h-full w-full ${cursorClass}`} />;
|
||||||
}
|
}
|
||||||
@@ -49,6 +49,7 @@ type CanvasCursorState = {
|
|||||||
document: ImageDocument;
|
document: ImageDocument;
|
||||||
editor: BrushTargetEditorState;
|
editor: BrushTargetEditorState;
|
||||||
hasBrushPreview: boolean;
|
hasBrushPreview: boolean;
|
||||||
|
isPanning: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function selectCanvasCursorState(state: AppState): CanvasCursorState {
|
function selectCanvasCursorState(state: AppState): CanvasCursorState {
|
||||||
@@ -63,6 +64,7 @@ function selectCanvasCursorState(state: AppState): CanvasCursorState {
|
|||||||
maskEdit: state.editor.maskEdit,
|
maskEdit: state.editor.maskEdit,
|
||||||
},
|
},
|
||||||
hasBrushPreview: Boolean(state.editor.brushPreview),
|
hasBrushPreview: Boolean(state.editor.brushPreview),
|
||||||
|
isPanning: state.editor.pointerSession?.type === "pan",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +72,7 @@ function canvasCursorStatesEqual(a: CanvasCursorState, b: CanvasCursorState): bo
|
|||||||
return (
|
return (
|
||||||
a.document === b.document &&
|
a.document === b.document &&
|
||||||
a.hasBrushPreview === b.hasBrushPreview &&
|
a.hasBrushPreview === b.hasBrushPreview &&
|
||||||
|
a.isPanning === b.isPanning &&
|
||||||
a.editor.selection === b.editor.selection &&
|
a.editor.selection === b.editor.selection &&
|
||||||
a.editor.tools.activeTool === b.editor.tools.activeTool &&
|
a.editor.tools.activeTool === b.editor.tools.activeTool &&
|
||||||
interactionModesEqual(a.editor.tools.interactionMode, b.editor.tools.interactionMode) &&
|
interactionModesEqual(a.editor.tools.interactionMode, b.editor.tools.interactionMode) &&
|
||||||
|
|||||||
@@ -1,39 +1,13 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, type KeyboardEvent, type ReactNode } from "react";
|
import { useCallback, useEffect, useMemo, useRef, type KeyboardEvent } from "react";
|
||||||
import {
|
import { Command, MagnifyingGlass } from "@phosphor-icons/react";
|
||||||
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 { commandIds } from "@commands/ids";
|
import { commandIds } from "@commands/ids";
|
||||||
import type { Artboard } from "@core/artboard";
|
import type { Artboard } from "@core/artboard";
|
||||||
import type { ImageDocument } from "@core/document";
|
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 type { AppStore } from "@editor/store";
|
||||||
import { availableToolIds, type GenerateMode, type ToolId, type ToolState } from "@editor/tools";
|
import type { ToolState } from "@editor/tools";
|
||||||
import { createDocumentReadIndex, type DocumentReadIndex, type IndexedLayerInfo } from "@editor/document-indexes";
|
import { createDocumentReadIndex } from "@editor/document-indexes";
|
||||||
import { downloadArtboardPng } from "./exportArtboardPng";
|
import { createPaletteItems, type PaletteItem } from "./paletteItems";
|
||||||
import { addArtboard, addEmptyLayer, addGroupLayer, deleteSelection, groupLayers, moveLayer } from "./layerActions";
|
|
||||||
import { labelForTool } from "./toolLabels";
|
|
||||||
|
|
||||||
export type CommandPaletteProps = {
|
export type CommandPaletteProps = {
|
||||||
state: CommandPaletteState;
|
state: CommandPaletteState;
|
||||||
@@ -51,17 +25,6 @@ export type CommandPaletteProps = {
|
|||||||
closeLayers: () => void;
|
closeLayers: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PaletteItem = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
section: string;
|
|
||||||
subtitle?: string;
|
|
||||||
keywords?: string[];
|
|
||||||
disabled?: boolean;
|
|
||||||
icon: ReactNode;
|
|
||||||
run: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function CommandPalette({
|
export function CommandPalette({
|
||||||
state,
|
state,
|
||||||
document,
|
document,
|
||||||
@@ -240,331 +203,6 @@ export function CommandPalette({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPaletteItems(options: {
|
|
||||||
document: ImageDocument;
|
|
||||||
documentIndex: DocumentReadIndex;
|
|
||||||
activeArtboard?: Artboard;
|
|
||||||
activeArtboardId?: string;
|
|
||||||
selectedLayer?: IndexedLayerInfo;
|
|
||||||
canGroup: boolean;
|
|
||||||
canUngroup: boolean;
|
|
||||||
selection: SelectionState;
|
|
||||||
viewport: ViewportState;
|
|
||||||
tools: ToolState;
|
|
||||||
generation: GenerationState;
|
|
||||||
layersOpen: boolean;
|
|
||||||
dispatch: AppStore["dispatch"];
|
|
||||||
openFilePicker: () => void;
|
|
||||||
openGenerate: () => void;
|
|
||||||
openLayers: () => void;
|
|
||||||
closeLayers: () => void;
|
|
||||||
}): PaletteItem[] {
|
|
||||||
const {
|
|
||||||
document,
|
|
||||||
documentIndex,
|
|
||||||
activeArtboard,
|
|
||||||
activeArtboardId,
|
|
||||||
selectedLayer,
|
|
||||||
canGroup,
|
|
||||||
canUngroup,
|
|
||||||
selection,
|
|
||||||
viewport,
|
|
||||||
tools,
|
|
||||||
generation,
|
|
||||||
layersOpen,
|
|
||||||
dispatch,
|
|
||||||
openFilePicker,
|
|
||||||
openGenerate,
|
|
||||||
openLayers,
|
|
||||||
closeLayers,
|
|
||||||
} = options;
|
|
||||||
const hasCandidates = generation.candidates.length > 0;
|
|
||||||
const items: PaletteItem[] = [];
|
|
||||||
|
|
||||||
items.push(
|
|
||||||
...availableToolIds.map((tool) => ({
|
|
||||||
id: `tool-${tool}`,
|
|
||||||
section: "Tools",
|
|
||||||
title: `Switch to ${labelForTool(tool)}`,
|
|
||||||
subtitle: tool === tools.activeTool ? "Current tool" : undefined,
|
|
||||||
keywords: [tool],
|
|
||||||
icon: toolIcon(tool),
|
|
||||||
run: () => {
|
|
||||||
if (tool === "generate") openGenerate();
|
|
||||||
else dispatch(commandIds.toolSetActive, { tool });
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
items.push(
|
|
||||||
{
|
|
||||||
id: "import-image",
|
|
||||||
section: "File",
|
|
||||||
title: "Import image",
|
|
||||||
subtitle: "Add an image as a layer",
|
|
||||||
keywords: ["open", "file", "layer"],
|
|
||||||
icon: <FolderOpen size={20} />,
|
|
||||||
run: openFilePicker,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "export-artboard",
|
|
||||||
section: "File",
|
|
||||||
title: "Export artboard as PNG",
|
|
||||||
subtitle: activeArtboard ? activeArtboard.name : "No artboard selected",
|
|
||||||
keywords: ["download", "png"],
|
|
||||||
disabled: !activeArtboard,
|
|
||||||
icon: <DownloadSimple size={20} />,
|
|
||||||
run: () => {
|
|
||||||
if (activeArtboard) void downloadArtboardPng(activeArtboard, document.assets);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
items.push(
|
|
||||||
{
|
|
||||||
id: layersOpen ? "close-layers" : "open-layers",
|
|
||||||
section: "Layers",
|
|
||||||
title: layersOpen ? "Close layers panel" : "Open layers panel",
|
|
||||||
keywords: ["panel", "stack"],
|
|
||||||
icon: <Stack size={20} weight={layersOpen ? "fill" : "regular"} />,
|
|
||||||
run: layersOpen ? closeLayers : openLayers,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "add-artboard",
|
|
||||||
section: "Layers",
|
|
||||||
title: "Add artboard",
|
|
||||||
icon: <Plus size={20} />,
|
|
||||||
run: () => addArtboard(document, dispatch),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "add-empty-layer",
|
|
||||||
section: "Layers",
|
|
||||||
title: "Add empty layer",
|
|
||||||
subtitle: activeArtboardId ? undefined : "No artboard available",
|
|
||||||
keywords: ["new", "raster"],
|
|
||||||
disabled: !activeArtboardId,
|
|
||||||
icon: <Plus size={20} />,
|
|
||||||
run: () => {
|
|
||||||
if (activeArtboardId) addEmptyLayer(document, activeArtboardId, selectedLayer, dispatch);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "add-group",
|
|
||||||
section: "Layers",
|
|
||||||
title: "Add group",
|
|
||||||
subtitle: activeArtboardId ? undefined : "No artboard available",
|
|
||||||
disabled: !activeArtboardId,
|
|
||||||
icon: <FolderPlus size={20} />,
|
|
||||||
run: () => {
|
|
||||||
if (activeArtboardId) addGroupLayer(activeArtboardId, dispatch);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "group-selection",
|
|
||||||
section: "Layers",
|
|
||||||
title: "Group selected layers",
|
|
||||||
subtitle: canGroup ? `${selection.layerIds.length} selected` : "Select one or more layers",
|
|
||||||
disabled: !canGroup,
|
|
||||||
icon: <Stack size={20} />,
|
|
||||||
run: () => {
|
|
||||||
if (selection.artboardId) groupLayers(selection.artboardId, selection.layerIds, dispatch);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "ungroup-selection",
|
|
||||||
section: "Layers",
|
|
||||||
title: "Ungroup selected group",
|
|
||||||
subtitle: selectedLayer?.layer.name,
|
|
||||||
disabled: !canUngroup || !selectedLayer,
|
|
||||||
icon: <Stack size={20} weight="fill" />,
|
|
||||||
run: () => {
|
|
||||||
if (selectedLayer?.layer.type === "group") dispatch(commandIds.documentUngroupLayer, { groupId: selectedLayer.layer.id });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "move-layer-up",
|
|
||||||
section: "Layers",
|
|
||||||
title: "Move selected layer up",
|
|
||||||
subtitle: selectedLayer?.layer.name,
|
|
||||||
disabled: !selectedLayer,
|
|
||||||
icon: <ArrowUp size={20} />,
|
|
||||||
run: () => {
|
|
||||||
if (selectedLayer) moveLayer(documentIndex, selectedLayer, -1, dispatch);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "move-layer-down",
|
|
||||||
section: "Layers",
|
|
||||||
title: "Move selected layer down",
|
|
||||||
subtitle: selectedLayer?.layer.name,
|
|
||||||
disabled: !selectedLayer,
|
|
||||||
icon: <ArrowDown size={20} />,
|
|
||||||
run: () => {
|
|
||||||
if (selectedLayer) moveLayer(documentIndex, selectedLayer, 1, dispatch);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "toggle-layer-visible",
|
|
||||||
section: "Layers",
|
|
||||||
title: selectedLayer?.layer.visible === false ? "Show selected layer" : "Hide selected layer",
|
|
||||||
subtitle: selectedLayer?.layer.name,
|
|
||||||
disabled: !selectedLayer,
|
|
||||||
icon: selectedLayer?.layer.visible === false ? <Eye size={20} /> : <EyeSlash size={20} />,
|
|
||||||
run: () => {
|
|
||||||
if (selectedLayer) dispatch(commandIds.documentSetLayerVisible, { layerId: selectedLayer.layer.id, visible: !selectedLayer.layer.visible });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "toggle-layer-lock",
|
|
||||||
section: "Layers",
|
|
||||||
title: selectedLayer?.layer.locked ? "Unlock selected layer" : "Lock selected layer",
|
|
||||||
subtitle: selectedLayer?.layer.name,
|
|
||||||
disabled: !selectedLayer,
|
|
||||||
icon: selectedLayer?.layer.locked ? <LockOpen size={20} /> : <Lock size={20} />,
|
|
||||||
run: () => {
|
|
||||||
if (selectedLayer) dispatch(commandIds.documentSetLayerLocked, { layerId: selectedLayer.layer.id, locked: !selectedLayer.layer.locked });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "delete-selection",
|
|
||||||
section: "Layers",
|
|
||||||
title: selectedLayer ? "Delete selected layer" : "Delete selected artboard",
|
|
||||||
subtitle: selectedLayer?.layer.name ?? activeArtboard?.name,
|
|
||||||
disabled: !selectedLayer && !selection.artboardId,
|
|
||||||
icon: <Trash size={20} />,
|
|
||||||
run: () => deleteSelection(selection, selectedLayer, dispatch),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
items.push(
|
|
||||||
{
|
|
||||||
id: "open-generate",
|
|
||||||
section: "Generate",
|
|
||||||
title: "Open generate panel",
|
|
||||||
subtitle: tools.activeTool === "generate" ? "Current tool" : undefined,
|
|
||||||
keywords: ["ai"],
|
|
||||||
icon: <Sparkle size={20} />,
|
|
||||||
run: openGenerate,
|
|
||||||
},
|
|
||||||
...generateModeItems.map((modeItem) => ({
|
|
||||||
id: `generate-mode-${modeItem.mode}`,
|
|
||||||
section: "Generate",
|
|
||||||
title: modeItem.title,
|
|
||||||
subtitle: tools.generate.mode === modeItem.mode ? "Current mode" : undefined,
|
|
||||||
keywords: ["mode", modeItem.mode],
|
|
||||||
icon: <Sparkle size={20} />,
|
|
||||||
run: () => {
|
|
||||||
openGenerate();
|
|
||||||
dispatch(commandIds.toolSetGenerateSettings, { mode: modeItem.mode });
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
{
|
|
||||||
id: "generate-random-seed",
|
|
||||||
section: "Generate",
|
|
||||||
title: "Use random seed",
|
|
||||||
subtitle: tools.generate.seed === -1 ? "Current seed" : `Seed ${tools.generate.seed}`,
|
|
||||||
keywords: ["seed"],
|
|
||||||
icon: <Sparkle size={20} />,
|
|
||||||
run: () => dispatch(commandIds.toolSetGenerateSettings, { seed: -1 }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "clear-generation-candidates",
|
|
||||||
section: "Generate",
|
|
||||||
title: "Clear candidates",
|
|
||||||
subtitle: hasCandidates ? `${generation.candidates.length} candidate${generation.candidates.length === 1 ? "" : "s"}` : "No candidates",
|
|
||||||
disabled: !hasCandidates,
|
|
||||||
icon: <Trash size={20} />,
|
|
||||||
run: () => dispatch(commandIds.generationClearCandidates, undefined),
|
|
||||||
},
|
|
||||||
...generationCompareItems.map((compareItem) => ({
|
|
||||||
id: `generation-compare-${compareItem.mode}`,
|
|
||||||
section: "Generate",
|
|
||||||
title: compareItem.title,
|
|
||||||
subtitle: generation.compareMode === compareItem.mode ? "Current compare mode" : undefined,
|
|
||||||
disabled: !hasCandidates,
|
|
||||||
icon: <Sparkle size={20} />,
|
|
||||||
run: () => dispatch(commandIds.generationSetCompareMode, { mode: compareItem.mode }),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
items.push(
|
|
||||||
{
|
|
||||||
id: "zoom-in",
|
|
||||||
section: "Zoom",
|
|
||||||
title: "Zoom in",
|
|
||||||
subtitle: `${Math.round(viewport.zoom * 100)}%`,
|
|
||||||
icon: <Plus size={20} />,
|
|
||||||
run: () => dispatch(commandIds.viewportSetZoom, { zoom: viewport.zoom * 1.2 }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "zoom-out",
|
|
||||||
section: "Zoom",
|
|
||||||
title: "Zoom out",
|
|
||||||
subtitle: `${Math.round(viewport.zoom * 100)}%`,
|
|
||||||
icon: <Minus size={20} />,
|
|
||||||
run: () => dispatch(commandIds.viewportSetZoom, { zoom: viewport.zoom / 1.2 }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "zoom-100",
|
|
||||||
section: "Zoom",
|
|
||||||
title: "Zoom to 100%",
|
|
||||||
icon: <CornersOut size={20} />,
|
|
||||||
run: () => dispatch(commandIds.viewportSetZoom, { zoom: 1 }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "fit-artboard",
|
|
||||||
section: "Zoom",
|
|
||||||
title: "Fit artboard",
|
|
||||||
subtitle: activeArtboard?.name,
|
|
||||||
disabled: !activeArtboard,
|
|
||||||
icon: <CornersOut size={20} />,
|
|
||||||
run: () => dispatch(commandIds.viewportFitArtboard, undefined),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
items.push(
|
|
||||||
{
|
|
||||||
id: "debug-reset-viewport",
|
|
||||||
section: "Debug",
|
|
||||||
title: "Reset viewport",
|
|
||||||
icon: <CornersOut size={20} />,
|
|
||||||
run: () => dispatch(commandIds.viewportReset, undefined),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "debug-clear-selection",
|
|
||||||
section: "Debug",
|
|
||||||
title: "Clear selection",
|
|
||||||
subtitle: selection.layerIds.length > 0 || selection.artboardId ? undefined : "Nothing selected",
|
|
||||||
disabled: selection.layerIds.length === 0 && !selection.artboardId,
|
|
||||||
icon: <Cursor size={20} />,
|
|
||||||
run: () => dispatch(commandIds.selectionClear, undefined),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "debug-clear-candidates",
|
|
||||||
section: "Debug",
|
|
||||||
title: "Clear generation state",
|
|
||||||
disabled: !hasCandidates,
|
|
||||||
icon: <Trash size={20} />,
|
|
||||||
run: () => dispatch(commandIds.generationClearCandidates, undefined),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
const generateModeItems: Array<{ mode: GenerateMode; title: string }> = [
|
|
||||||
{ mode: "text-to-image", title: "Text-to-image mode" },
|
|
||||||
{ mode: "image-to-image", title: "Image-to-image mode" },
|
|
||||||
{ mode: "inpaint", title: "Inpaint mode" },
|
|
||||||
{ mode: "outpaint", title: "Outpaint mode" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const generationCompareItems: Array<{ mode: GenerationCompareMode; title: string }> = [
|
|
||||||
{ mode: "result", title: "Show result" },
|
|
||||||
{ mode: "before", title: "Show before" },
|
|
||||||
{ mode: "split", title: "Split compare" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function filterItems(items: PaletteItem[], query: string) {
|
function filterItems(items: PaletteItem[], query: string) {
|
||||||
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||||
if (terms.length === 0) return items;
|
if (terms.length === 0) return items;
|
||||||
@@ -575,25 +213,6 @@ function filterItems(items: PaletteItem[], query: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function toolIcon(tool: ToolId) {
|
|
||||||
switch (tool) {
|
|
||||||
case "select":
|
|
||||||
return <Cursor size={20} />;
|
|
||||||
case "generate":
|
|
||||||
return <Sparkle size={20} />;
|
|
||||||
case "brush":
|
|
||||||
return <PaintBrush size={20} />;
|
|
||||||
case "eraser":
|
|
||||||
return <Eraser size={20} />;
|
|
||||||
case "chromaKey":
|
|
||||||
return <DropHalf size={20} />;
|
|
||||||
case "magicWand":
|
|
||||||
return <MagicWand size={20} />;
|
|
||||||
case "pan":
|
|
||||||
return <Hand size={20} />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function paletteItemClass(active: boolean, disabled: boolean) {
|
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";
|
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`;
|
if (disabled) return `${base} cursor-not-allowed text-white/30 opacity-45`;
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import type { GenerateSettings } from "@editor/tools";
|
import type { GenerateSettings } from "@editor/tools";
|
||||||
import type { AppStore } from "@editor/store";
|
import type { AppStore } from "@editor/store";
|
||||||
|
import type { GenerationResourcesState } from "@editor/state";
|
||||||
import { GenerateControls } from "./bottom-controls/GenerateControls";
|
import { GenerateControls } from "./bottom-controls/GenerateControls";
|
||||||
|
|
||||||
export type GenerateSheetProps = {
|
export type GenerateSheetProps = {
|
||||||
settings: GenerateSettings;
|
settings: GenerateSettings;
|
||||||
|
resources: GenerationResourcesState;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
dispatch: AppStore["dispatch"];
|
dispatch: AppStore["dispatch"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function GenerateSheet({ settings, open, dispatch }: GenerateSheetProps) {
|
export function GenerateSheet({ settings, resources, open, dispatch }: GenerateSheetProps) {
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
aria-hidden={!open}
|
aria-hidden={!open}
|
||||||
@@ -19,7 +21,7 @@ export function GenerateSheet({ settings, open, dispatch }: GenerateSheetProps)
|
|||||||
>
|
>
|
||||||
{open ? (
|
{open ? (
|
||||||
<div className="subtle-scrollbar min-h-0 flex-1 overflow-auto py-4">
|
<div className="subtle-scrollbar min-h-0 flex-1 overflow-auto py-4">
|
||||||
<GenerateControls settings={settings} dispatch={dispatch} />
|
<GenerateControls settings={settings} resources={resources} dispatch={dispatch} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
29
view/GenerationJobStatus.tsx
Normal file
29
view/GenerationJobStatus.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { GenerationJob, GenerationState } from "@editor/state";
|
||||||
|
|
||||||
|
export function currentGenerationJob(generation: GenerationState): GenerationJob | undefined {
|
||||||
|
return generation.jobs.find((job) => job.status === "running") ?? generation.jobs[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GenerationJobStatus({ generation, compact = false }: { generation: GenerationState; compact?: boolean }) {
|
||||||
|
const job = currentGenerationJob(generation);
|
||||||
|
const [, setTick] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (job?.status !== "running") return;
|
||||||
|
const interval = window.setInterval(() => setTick((tick) => tick + 1), 1000);
|
||||||
|
return () => window.clearInterval(interval);
|
||||||
|
}, [job?.id, job?.status]);
|
||||||
|
|
||||||
|
if (!job) return null;
|
||||||
|
const elapsed = Math.max(0, Math.floor(((job.finishedAt ?? Date.now()) - job.startedAt) / 1000));
|
||||||
|
const label = job.status === "running" ? `${job.label} ${formatElapsed(elapsed)}` : job.status === "failed" ? job.error ?? `${job.label} failed` : `${job.label} complete`;
|
||||||
|
const tone = job.status === "failed" ? "bg-red-500/15 text-red-100" : job.status === "running" ? "bg-white/10 text-white/70" : "bg-emerald-500/15 text-emerald-100";
|
||||||
|
|
||||||
|
return <span className={`${compact ? "max-w-56" : "max-w-80"} truncate rounded-full px-3 py-2 text-xs font-medium ${tone}`} title={label} aria-live="polite">{label}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatElapsed(seconds: number) {
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
return `${minutes}:${(seconds % 60).toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, type DragEvent, type MutableRefObject } from "react";
|
import { useMemo, useRef, useState, type DragEvent, type MutableRefObject } from "react";
|
||||||
import { ArrowDown, ArrowUp, DownloadSimple, Eye, EyeSlash, FolderPlus, Lock, LockOpen, Plus, Stack, Trash } from "@phosphor-icons/react";
|
import { ArrowDown, ArrowUp, DownloadSimple, Eye, EyeSlash, FolderPlus, Lock, LockOpen, Plus, Stack, Trash } from "@phosphor-icons/react";
|
||||||
import { commandIds } from "@commands/ids";
|
import { commandIds } from "@commands/ids";
|
||||||
import type { Asset } from "@core/asset";
|
|
||||||
import type { ImageDocument } from "@core/document";
|
import type { ImageDocument } from "@core/document";
|
||||||
import type { Layer } from "@core/layer";
|
import type { Layer } from "@core/layer";
|
||||||
import { getLayerMask } from "@core/layer-mask-utils";
|
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 { MaskEditState, SelectionState } from "@editor/state";
|
||||||
import type { AppStore } from "@editor/store";
|
import type { AppStore } from "@editor/store";
|
||||||
import { resolveLayerDrop } from "@input/index";
|
import { resolveLayerDrop } from "@input/index";
|
||||||
import { downloadArtboardPng } from "./exportArtboardPng";
|
import { downloadArtboardPng } from "@operations/export/downloadArtboard";
|
||||||
import { addArtboard, addEmptyLayer, addGroupLayer, addLayerMask, deleteSelection, groupLayers, moveLayer } from "./layerActions";
|
import { addArtboard, addEmptyLayer, addGroupLayer, addLayerMask, deleteSelection, groupLayers, moveLayer } from "@operations/document/layerActions";
|
||||||
import { analyzeMaskSource, applyMaskRasterOperation, type MaskAnalysis, type MaskRasterOperation } from "./mask/maskRaster";
|
import { MaskOperationButtons, MaskStatus } from "./layers/MaskControls";
|
||||||
|
|
||||||
export type LayersSheetProps = {
|
export type LayersSheetProps = {
|
||||||
document: ImageDocument;
|
document: ImageDocument;
|
||||||
@@ -370,83 +369,6 @@ function LayerRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MaskStatus({ asset }: { asset: Asset }) {
|
|
||||||
const [analysis, setAnalysis] = useState<MaskAnalysis>();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
void analyzeMaskSource(asset.source, asset.intrinsicSize.w, asset.intrinsicSize.h)
|
|
||||||
.then((nextAnalysis) => {
|
|
||||||
if (!cancelled) setAnalysis(nextAnalysis);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!cancelled) setAnalysis(undefined);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [asset.source, asset.intrinsicSize.w, asset.intrinsicSize.h]);
|
|
||||||
|
|
||||||
if (!analysis) return <span className="rounded-full bg-white/5 px-3 py-1 text-xs text-sky-100/45">Reading</span>;
|
|
||||||
return (
|
|
||||||
<span className="inline-flex min-w-0 items-center gap-2 rounded-full bg-white/5 px-2 py-1 text-xs text-sky-100/65">
|
|
||||||
<img src={analysis.thumbnail} alt="" className="h-7 w-10 rounded-md bg-black/30 object-cover ring-1 ring-white/10" />
|
|
||||||
<span className="whitespace-nowrap">Reveal {formatPercent(analysis.coverage)}</span>
|
|
||||||
<span className="whitespace-nowrap text-sky-100/45">Inpaint {formatPercent(analysis.hiddenCoverage)}</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MaskOperationButtons({ maskLayerId, maskAsset, dispatch }: { maskLayerId: string; maskAsset: Asset; dispatch: AppStore["dispatch"] }) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<MaskOperationButton label="Invert" title="Invert mask" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "invert" }} dispatch={dispatch} />
|
|
||||||
<MaskOperationButton label="White" title="Fill mask white" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "fill", fill: "white" }} dispatch={dispatch} />
|
|
||||||
<MaskOperationButton label="Black" title="Fill mask black" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "fill", fill: "black" }} dispatch={dispatch} />
|
|
||||||
<MaskOperationButton label="Clear" title="Clear mask" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "fill", fill: "clear" }} dispatch={dispatch} />
|
|
||||||
<MaskOperationButton label="Feather" title="Feather mask edge" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "feather", radius: 3 }} dispatch={dispatch} />
|
|
||||||
<MaskOperationButton label="Expand" title="Expand mask" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "expand", radius: 3 }} dispatch={dispatch} />
|
|
||||||
<MaskOperationButton label="Contract" title="Contract mask" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "contract", radius: 3 }} dispatch={dispatch} />
|
|
||||||
<MaskOperationButton label="Blur" title="Blur mask edge" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "blur", radius: 2 }} dispatch={dispatch} />
|
|
||||||
<MaskOperationButton label="Clean" title="Despeckle mask" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "despeckle", strength: 8 }} dispatch={dispatch} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MaskOperationButton({
|
|
||||||
label,
|
|
||||||
title,
|
|
||||||
maskLayerId,
|
|
||||||
maskAsset,
|
|
||||||
operation,
|
|
||||||
dispatch,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
title: string;
|
|
||||||
maskLayerId: string;
|
|
||||||
maskAsset: Asset;
|
|
||||||
operation: MaskRasterOperation;
|
|
||||||
dispatch: AppStore["dispatch"];
|
|
||||||
}) {
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={maskActionButtonClass()}
|
|
||||||
disabled={busy}
|
|
||||||
title={title}
|
|
||||||
onClick={() => {
|
|
||||||
setBusy(true);
|
|
||||||
void applyMaskRasterOperation(maskAsset.source, maskAsset.intrinsicSize.w, maskAsset.intrinsicSize.h, operation)
|
|
||||||
.then((source) => dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId, source, mimeType: "image/png", operation }))
|
|
||||||
.finally(() => setBusy(false));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{busy ? "..." : label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type EditingTitle =
|
type EditingTitle =
|
||||||
| { type: "artboard"; id: ArtboardId; draft: string }
|
| { type: "artboard"; id: ArtboardId; draft: string }
|
||||||
| { type: "layer"; id: string; draft: string };
|
| { type: "layer"; id: string; draft: string };
|
||||||
@@ -497,9 +419,5 @@ function labeledToolbarButtonClass() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function maskActionButtonClass() {
|
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";
|
return "rounded-full bg-white/5 px-2.5 py-1 text-[0.7rem] font-semibold text-white/60 transition hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-35";
|
||||||
}
|
|
||||||
|
|
||||||
function formatPercent(value: number) {
|
|
||||||
return `${Math.round(Math.max(0, Math.min(1, value)) * 100)}%`;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,10 @@ import { useEffect } from "react";
|
|||||||
import { DropHalf } from "@phosphor-icons/react";
|
import { DropHalf } from "@phosphor-icons/react";
|
||||||
import { commandIds } from "@commands/ids";
|
import { commandIds } from "@commands/ids";
|
||||||
import type { ImageDocument } from "@core/document";
|
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 { AppStore } from "@editor/store";
|
||||||
import type { ChromaKeySettings } from "@editor/tools";
|
import type { ChromaKeySettings } from "@editor/tools";
|
||||||
import type { SelectionState } from "@editor/state";
|
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 { BottomControlColorPicker } from "./ColorPicker";
|
||||||
import { BottomControlDivider } from "./Divider";
|
import { BottomControlDivider } from "./Divider";
|
||||||
import { BottomControlSlider } from "./Slider";
|
import { BottomControlSlider } from "./Slider";
|
||||||
@@ -38,7 +35,7 @@ export function ChromaKeyControls({ document, selection, settings, dispatch }: C
|
|||||||
return;
|
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;
|
if (cancelled) return;
|
||||||
dispatch(commandIds.toolSetBrushStrokePreview, { layerId: target.layer.id, assetId: target.asset.id, source });
|
dispatch(commandIds.toolSetBrushStrokePreview, { layerId: target.layer.id, assetId: target.asset.id, source });
|
||||||
});
|
});
|
||||||
@@ -153,161 +150,3 @@ export function ChromaKeyControls({ document, selection, settings, dispatch }: C
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveChromaKeyTarget(document: ImageDocument, selection: SelectionState) {
|
|
||||||
const layerId = selection.layerIds[0];
|
|
||||||
if (selection.layerIds.length !== 1 || !layerId) return undefined;
|
|
||||||
const layer = findLayer(document.artboards.find((artboard) => artboard.id === selection.artboardId)?.layers ?? [], layerId);
|
|
||||||
if (!layer || layer.type === "group") return undefined;
|
|
||||||
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
|
||||||
const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id });
|
|
||||||
const layerMask = getLayerMask(layer);
|
|
||||||
const maskLayer = layerMask?.enabled ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerMask.maskLayerId) : undefined;
|
|
||||||
const maskAsset = maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
|
|
||||||
return asset && bounds ? { layer, asset, bounds, maskLayer, maskAsset } : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findLayer(layers: readonly Layer[], layerId: string): Layer | undefined {
|
|
||||||
for (const layer of layers) {
|
|
||||||
if (layer.id === layerId) return layer;
|
|
||||||
if (layer.type === "group") {
|
|
||||||
const found = findLayer(layer.children, layerId);
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applyChromaKeyMask(target: NonNullable<ReturnType<typeof resolveChromaKeyTarget>>, settings: ChromaKeySettings, dispatch: AppStore["dispatch"]) {
|
|
||||||
const source = await chromaKeyMaskSource(target.asset.source, target.asset.intrinsicSize.w, target.asset.intrinsicSize.h, settings);
|
|
||||||
dispatch(commandIds.toolSetBrushStrokePreview, undefined);
|
|
||||||
|
|
||||||
if (target.maskAsset && target.maskLayer && target.maskLayer.type !== "group") {
|
|
||||||
dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId: target.maskLayer.id, source, mimeType: "image/png", operation: { type: "chromaKey" } });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const assetId = crypto.randomUUID();
|
|
||||||
const maskLayerId = crypto.randomUUID();
|
|
||||||
const width = Math.max(1, Math.round(target.asset.intrinsicSize.w));
|
|
||||||
const height = Math.max(1, Math.round(target.asset.intrinsicSize.h));
|
|
||||||
dispatch(commandIds.documentAddLayerMask, {
|
|
||||||
layerId: target.layer.id,
|
|
||||||
asset: {
|
|
||||||
id: assetId,
|
|
||||||
name: `${target.layer.name} Chroma Mask`,
|
|
||||||
mimeType: "image/png",
|
|
||||||
source,
|
|
||||||
intrinsicSize: { w: width, h: height },
|
|
||||||
},
|
|
||||||
maskLayer: {
|
|
||||||
id: maskLayerId,
|
|
||||||
type: "raster",
|
|
||||||
name: `${target.layer.name} Chroma Mask`,
|
|
||||||
visible: true,
|
|
||||||
locked: false,
|
|
||||||
opacity: 1,
|
|
||||||
assetId,
|
|
||||||
transform: {
|
|
||||||
position: { x: target.bounds.x, y: target.bounds.y },
|
|
||||||
scale: { x: target.bounds.w / width, y: target.bounds.h / height },
|
|
||||||
rotation: target.layer.transform.rotation,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
dispatch(commandIds.toolExitMaskEdit, undefined);
|
|
||||||
dispatch(commandIds.toolSetActive, { tool: "chromaKey" });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function chromaKeySource(source: string, width: number, height: number, settings: ChromaKeySettings) {
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
canvas.width = Math.max(1, Math.round(width));
|
|
||||||
canvas.height = Math.max(1, Math.round(height));
|
|
||||||
const context = canvas.getContext("2d");
|
|
||||||
if (!context) return source;
|
|
||||||
const image = await loadImage(source);
|
|
||||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
||||||
const data = context.getImageData(0, 0, canvas.width, canvas.height);
|
|
||||||
const alpha = chromaKeyAlpha(data, canvas.width, canvas.height, settings);
|
|
||||||
for (let pixel = 0; pixel < alpha.length; pixel++) data.data[pixel * 4 + 3] = alpha[pixel] ?? 255;
|
|
||||||
context.putImageData(data, 0, 0);
|
|
||||||
return canvas.toDataURL("image/png");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function chromaKeyMaskSource(source: string, width: number, height: number, settings: ChromaKeySettings) {
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
canvas.width = Math.max(1, Math.round(width));
|
|
||||||
canvas.height = Math.max(1, Math.round(height));
|
|
||||||
const context = canvas.getContext("2d");
|
|
||||||
if (!context) return source;
|
|
||||||
const image = await loadImage(source);
|
|
||||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
||||||
const data = context.getImageData(0, 0, canvas.width, canvas.height);
|
|
||||||
const alpha = chromaKeyAlpha(data, canvas.width, canvas.height, settings);
|
|
||||||
|
|
||||||
for (let pixel = 0; pixel < alpha.length; pixel++) {
|
|
||||||
const index = pixel * 4;
|
|
||||||
data.data[index] = 255;
|
|
||||||
data.data[index + 1] = 255;
|
|
||||||
data.data[index + 2] = 255;
|
|
||||||
data.data[index + 3] = alpha[pixel] ?? 255;
|
|
||||||
}
|
|
||||||
|
|
||||||
context.putImageData(data, 0, 0);
|
|
||||||
return canvas.toDataURL("image/png");
|
|
||||||
}
|
|
||||||
|
|
||||||
function hexToRgb(color: string) {
|
|
||||||
const hex = color.replace("#", "");
|
|
||||||
return { r: Number.parseInt(hex.slice(0, 2), 16), g: Number.parseInt(hex.slice(2, 4), 16), b: Number.parseInt(hex.slice(4, 6), 16) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function chromaKeyAlpha(data: ImageData, width: number, height: number, settings: ChromaKeySettings) {
|
|
||||||
const key = hexToRgb(settings.color);
|
|
||||||
const alpha = new Uint8ClampedArray(width * height);
|
|
||||||
for (let pixel = 0; pixel < alpha.length; pixel++) {
|
|
||||||
const index = pixel * 4;
|
|
||||||
const red = data.data[index] ?? 0;
|
|
||||||
const green = data.data[index + 1] ?? 0;
|
|
||||||
const blue = data.data[index + 2] ?? 0;
|
|
||||||
const sourceAlpha = data.data[index + 3] ?? 255;
|
|
||||||
alpha[pixel] = Math.round(sourceAlpha * chromaKeyKeepFactor(red, green, blue, key, settings));
|
|
||||||
}
|
|
||||||
return postProcessAlpha(alpha, width, height, settings);
|
|
||||||
}
|
|
||||||
|
|
||||||
function chromaKeyKeepFactor(red: number, green: number, blue: number, key: { r: number; g: number; b: number }, settings: ChromaKeySettings) {
|
|
||||||
const tolerance = Math.max(0, Math.min(255, settings.tolerance));
|
|
||||||
const softness = Math.max(0, Math.min(255, settings.softness));
|
|
||||||
const spill = Math.max(0, Math.min(100, settings.spill)) / 100;
|
|
||||||
const distance = Math.hypot(red - key.r, green - key.g, blue - key.b);
|
|
||||||
const edgeKeep = distance <= tolerance ? 0 : softness > 0 && distance < tolerance + softness ? (distance - tolerance) / softness : 1;
|
|
||||||
if (spill <= 0) return edgeKeep;
|
|
||||||
|
|
||||||
const dominant = key.g >= key.r && key.g >= key.b ? green : key.r >= key.b ? red : blue;
|
|
||||||
const neutral = key.g >= key.r && key.g >= key.b ? Math.max(red, blue) : key.r >= key.b ? Math.max(green, blue) : Math.max(red, green);
|
|
||||||
const spillAmount = Math.max(0, dominant - neutral) / 255;
|
|
||||||
return Math.max(0, Math.min(edgeKeep, 1 - spillAmount * spill));
|
|
||||||
}
|
|
||||||
|
|
||||||
function postProcessAlpha(alpha: Uint8ClampedArray, width: number, height: number, settings: ChromaKeySettings) {
|
|
||||||
let next = alpha;
|
|
||||||
const despeckle = Math.round(Math.max(0, Math.min(20, settings.despeckle)));
|
|
||||||
const choke = Math.round(Math.max(-20, Math.min(20, settings.choke)));
|
|
||||||
const feather = Math.round(Math.max(0, Math.min(20, settings.feather)));
|
|
||||||
|
|
||||||
if (despeckle > 0) next = despeckleMaskValues(next, width, height, despeckle);
|
|
||||||
if (choke > 0) next = erodeMaskValues(next, width, height, choke);
|
|
||||||
if (choke < 0) next = dilateMaskValues(next, width, height, -choke);
|
|
||||||
if (feather > 0) next = blurMaskValues(next, width, height, feather);
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadImage(source: string) {
|
|
||||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
|
||||||
const image = new Image();
|
|
||||||
image.onload = () => resolve(image);
|
|
||||||
image.onerror = () => reject(new Error("Failed to load image"));
|
|
||||||
image.src = source;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { commandIds } from "@commands/ids";
|
import { commandIds } from "@commands/ids";
|
||||||
import type { ImageDocument } from "@core/document";
|
import type { ImageDocument } from "@core/document";
|
||||||
import type { GenerationCandidate, GenerationCompareMode, GenerationState, SelectionState, ViewportState } from "@editor/state";
|
import type { GenerationCandidate, GenerationCompareMode, GenerationState, SelectionState, ViewportState } from "@editor/state";
|
||||||
import type { GenerateSettings } from "@editor/tools";
|
import type { GenerateSettings } from "@editor/tools";
|
||||||
import type { AppStore } from "@editor/store";
|
import type { AppStore } from "@editor/store";
|
||||||
import { createMaskedPixelReplacementSource } from "../generate/candidateActions";
|
import { createMaskedPixelReplacementSource } from "@operations/generation/candidateActions";
|
||||||
import { runGenerate, runGenerateFromCandidate } from "../generate/runGenerate";
|
import { runGenerate, runGenerateFromCandidate } from "@operations/generation/runGenerate";
|
||||||
import { createSolidMaskSource } from "../mask/maskRaster";
|
import { runGenerationJob } from "@operations/generation/generationJob";
|
||||||
|
import { currentGenerationJob, GenerationJobStatus } from "../GenerationJobStatus";
|
||||||
|
import { createRefinementMask } from "@operations/masks/rasterActions";
|
||||||
|
|
||||||
export type GenerateActionControlsProps = {
|
export type GenerateActionControlsProps = {
|
||||||
document: ImageDocument;
|
document: ImageDocument;
|
||||||
@@ -18,44 +19,25 @@ export type GenerateActionControlsProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function GenerateActionControls({ document, selection, viewport, settings, generation, dispatch }: GenerateActionControlsProps) {
|
export function GenerateActionControls({ document, selection, viewport, settings, generation, dispatch }: GenerateActionControlsProps) {
|
||||||
const [busy, setBusy] = useState<string>();
|
const job = currentGenerationJob(generation);
|
||||||
const [error, setError] = useState<string>();
|
const busy = job?.status === "running";
|
||||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
|
||||||
const candidate = selectedCandidate(generation);
|
const candidate = selectedCandidate(generation);
|
||||||
const canGenerate = Boolean(settings.prompt.trim()) && !busy;
|
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 (
|
return (
|
||||||
<div className="flex max-w-[calc(100vw-2rem)] flex-wrap items-center justify-center gap-2 px-2">
|
<div className="flex max-w-[calc(100vw-2rem)] flex-wrap items-center justify-center gap-2 px-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!canGenerate}
|
disabled={!canGenerate}
|
||||||
className="h-12 rounded-full bg-white px-7 text-base font-semibold !text-black transition hover:bg-white/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/40 disabled:pointer-events-none disabled:opacity-35"
|
className="h-12 rounded-full bg-white px-7 text-base font-semibold !text-black transition hover:bg-white/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/40 disabled:pointer-events-none disabled:opacity-35"
|
||||||
title={error ?? "Generate with ComfyUI"}
|
title={job?.status === "failed" ? job.error : "Generate with ComfyUI"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setBusy("Generating");
|
void runGenerationJob({ kind: "generate", label: "Generating", dispatch, task: () => runGenerate({ document, selection, viewport, settings, dispatch }) });
|
||||||
setError(undefined);
|
|
||||||
void runGenerate({ document, selection, viewport, settings, dispatch })
|
|
||||||
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : "Generation failed"))
|
|
||||||
.finally(() => setBusy(undefined));
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{busy === "Generating" ? `Generating ${formatElapsed(elapsedSeconds)}` : "Generate"}
|
{busy && job?.kind === "generate" ? "Generating..." : "Generate"}
|
||||||
</button>
|
</button>
|
||||||
{busy ? <span className="rounded-full bg-white/10 px-3 py-2 text-xs font-medium text-white/60">{busy} {formatElapsed(elapsedSeconds)}</span> : null}
|
<GenerationJobStatus generation={generation} />
|
||||||
{candidate ? (
|
{candidate ? (
|
||||||
<>
|
<>
|
||||||
<CandidatePicker generation={generation} dispatch={dispatch} />
|
<CandidatePicker generation={generation} dispatch={dispatch} />
|
||||||
@@ -65,13 +47,10 @@ export function GenerateActionControls({ document, selection, viewport, settings
|
|||||||
compareMode={generation.compareMode ?? "result"}
|
compareMode={generation.compareMode ?? "result"}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
setBusy={setBusy}
|
|
||||||
setError={setError}
|
|
||||||
dispatch={dispatch}
|
dispatch={dispatch}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{error ? <span className="max-w-80 truncate rounded-full bg-red-500/15 px-3 py-2 text-xs font-medium text-red-100" title={error}>{error}</span> : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -104,70 +83,53 @@ function CandidateControls({
|
|||||||
compareMode,
|
compareMode,
|
||||||
settings,
|
settings,
|
||||||
busy,
|
busy,
|
||||||
setBusy,
|
|
||||||
setError,
|
|
||||||
dispatch,
|
dispatch,
|
||||||
}: {
|
}: {
|
||||||
document: ImageDocument;
|
document: ImageDocument;
|
||||||
candidate: GenerationCandidate;
|
candidate: GenerationCandidate;
|
||||||
compareMode: GenerationCompareMode;
|
compareMode: GenerationCompareMode;
|
||||||
settings: GenerateSettings;
|
settings: GenerateSettings;
|
||||||
busy?: string;
|
busy: boolean;
|
||||||
setBusy: (busy: string | undefined) => void;
|
|
||||||
setError: (error: string | undefined) => void;
|
|
||||||
dispatch: AppStore["dispatch"];
|
dispatch: AppStore["dispatch"];
|
||||||
}) {
|
}) {
|
||||||
const rerun = (label: string, nextSettings: GenerateSettings) => {
|
const rerun = (label: string, nextSettings: GenerateSettings) => {
|
||||||
setBusy(label);
|
|
||||||
setError(undefined);
|
|
||||||
dispatch(commandIds.toolSetGenerateSettings, nextSettings);
|
dispatch(commandIds.toolSetGenerateSettings, nextSettings);
|
||||||
void runGenerateFromCandidate({ candidate, settings: nextSettings, dispatch })
|
void runGenerationJob({ kind: "regenerate", label, dispatch, task: () => runGenerateFromCandidate({ candidate, settings: nextSettings, dispatch }) });
|
||||||
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : `${label} failed`))
|
|
||||||
.finally(() => setBusy(undefined));
|
|
||||||
};
|
};
|
||||||
const disabled = Boolean(busy);
|
const disabled = busy;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center justify-center gap-1 rounded-full bg-white/[0.04] px-2 py-1 ring-1 ring-white/[0.05]">
|
<div className="flex flex-wrap items-center justify-center gap-1 rounded-full bg-white/[0.04] px-2 py-1 ring-1 ring-white/[0.05]">
|
||||||
<CandidatePreview candidate={candidate} />
|
<CandidatePreview candidate={candidate} />
|
||||||
<span className="px-2 text-xs font-medium text-white/55">Seed {candidate.seed}</span>
|
<span className="px-2 text-xs font-medium text-white/55">Seed {candidate.seed}</span>
|
||||||
<CandidateCompareControls compareMode={compareMode} disabled={disabled} dispatch={dispatch} />
|
<CandidateCompareControls compareMode={compareMode} disabled={disabled} dispatch={dispatch} />
|
||||||
<CandidateButton disabled={disabled} label="Regenerate" title="Regenerate same mask and crop" busy={busy === "Regenerate"} onClick={() => rerun("Regenerate", candidate.settings)} />
|
<CandidateButton disabled={disabled} label="Regenerate" title="Regenerate same mask and crop" onClick={() => rerun("Regenerate", candidate.settings)} />
|
||||||
<CandidateButton
|
<CandidateButton
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
label="Lower"
|
label="Lower"
|
||||||
title="Lower strength and regenerate same mask"
|
title="Lower strength and regenerate same mask"
|
||||||
busy={busy === "Lower"}
|
|
||||||
onClick={() => rerun("Lower", { ...candidate.settings, strength: Math.max(0, candidate.settings.strength - 10), seed: candidate.seed })}
|
onClick={() => rerun("Lower", { ...candidate.settings, strength: Math.max(0, candidate.settings.strength - 10), seed: candidate.seed })}
|
||||||
/>
|
/>
|
||||||
<CandidateButton disabled={disabled} label="Reuse seed" title="Regenerate with the same seed" busy={busy === "Reuse seed"} onClick={() => rerun("Reuse seed", { ...candidate.settings, seed: candidate.seed })} />
|
<CandidateButton disabled={disabled} label="Reuse seed" title="Regenerate with the same seed" onClick={() => rerun("Reuse seed", { ...candidate.settings, seed: candidate.seed })} />
|
||||||
<CandidateButton disabled={disabled} label="New seed" title="Regenerate with a new seed" busy={busy === "New seed"} onClick={() => rerun("New seed", { ...candidate.settings, seed: -1 })} />
|
<CandidateButton disabled={disabled} label="New seed" title="Regenerate with a new seed" onClick={() => rerun("New seed", { ...candidate.settings, seed: -1 })} />
|
||||||
<CandidateButton disabled={disabled} label="Add as layer" title="Add candidate to the document as a layer" onClick={() => applyCandidateAsLayer(candidate, dispatch)} />
|
<CandidateButton disabled={disabled} label="Add as layer" title="Add candidate to the document as a layer" onClick={() => applyCandidateAsLayer(candidate, dispatch)} />
|
||||||
<CandidateButton
|
<CandidateButton
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
label="Add + mask"
|
label="Add + mask"
|
||||||
title="Add candidate as a layer with a fresh refinement mask"
|
title="Add candidate as a layer with a fresh refinement mask"
|
||||||
busy={busy === "Refine"}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setBusy("Refine");
|
void runGenerationJob({ kind: "refine", label: "Adding refinement mask", dispatch, task: () => applyCandidateAsRefinementLayer(candidate, dispatch) });
|
||||||
setError(undefined);
|
|
||||||
void applyCandidateAsRefinementLayer(candidate, dispatch)
|
|
||||||
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : "Refine setup failed"))
|
|
||||||
.finally(() => setBusy(undefined));
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<CandidateButton
|
<CandidateButton
|
||||||
disabled={disabled || !candidate.inpaint}
|
disabled={disabled || !candidate.inpaint}
|
||||||
label="Replace pixels"
|
label="Replace pixels"
|
||||||
title={candidate.inpaint ? "Replace masked pixels and preserve unmasked pixels" : "Only inpaint candidates can replace masked pixels"}
|
title={candidate.inpaint ? "Replace masked pixels and preserve unmasked pixels" : "Only inpaint candidates can replace masked pixels"}
|
||||||
busy={busy === "Replace"}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setBusy("Replace");
|
void runGenerationJob({ kind: "replace", label: "Replacing pixels", dispatch, task: async () => {
|
||||||
setError(undefined);
|
const source = await createMaskedPixelReplacementSource(document, candidate);
|
||||||
void createMaskedPixelReplacementSource(document, candidate)
|
dispatch(commandIds.generationReplaceCandidatePixels, { candidateId: candidate.id, source, mimeType: "image/png" });
|
||||||
.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));
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<CandidateButton
|
<CandidateButton
|
||||||
@@ -259,7 +221,7 @@ async function applyCandidateAsRefinementLayer(candidate: GenerationCandidate, d
|
|||||||
applyCandidateAsLayerWithIds(candidate, { layerId, assetId: crypto.randomUUID() }, dispatch);
|
applyCandidateAsLayerWithIds(candidate, { layerId, assetId: crypto.randomUUID() }, dispatch);
|
||||||
const width = Math.max(1, Math.round(candidate.intrinsicSize.w));
|
const width = Math.max(1, Math.round(candidate.intrinsicSize.w));
|
||||||
const height = Math.max(1, Math.round(candidate.intrinsicSize.h));
|
const height = Math.max(1, Math.round(candidate.intrinsicSize.h));
|
||||||
const source = await createSolidMaskSource(width, height, "white");
|
const source = await createRefinementMask(width, height);
|
||||||
|
|
||||||
dispatch(commandIds.documentAddLayerMask, {
|
dispatch(commandIds.documentAddLayerMask, {
|
||||||
layerId,
|
layerId,
|
||||||
@@ -295,9 +257,3 @@ function applyCandidateAsLayerWithIds(candidate: GenerationCandidate, ids: { lay
|
|||||||
layerId: ids.layerId,
|
layerId: ids.layerId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatElapsed(seconds: number) {
|
|
||||||
const minutes = Math.floor(seconds / 60);
|
|
||||||
const remainder = seconds % 60;
|
|
||||||
return `${minutes}:${remainder.toString().padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState, type RefObject } from "react";
|
|||||||
import { CaretDown, CaretUp } from "@phosphor-icons/react";
|
import { CaretDown, CaretUp } from "@phosphor-icons/react";
|
||||||
import { commandIds } from "@commands/ids";
|
import { commandIds } from "@commands/ids";
|
||||||
import type { AppStore } from "@editor/store";
|
import type { AppStore } from "@editor/store";
|
||||||
|
import type { GenerationOptions, GenerationResourcesState } from "@editor/state";
|
||||||
import { generateArchitectureDefaults } from "@editor/tools";
|
import { generateArchitectureDefaults } from "@editor/tools";
|
||||||
import type { GenerateArchitecture, GenerateMode, GenerateModel, GenerateSettings } from "@editor/tools";
|
import type { GenerateArchitecture, GenerateMode, GenerateModel, GenerateSettings } from "@editor/tools";
|
||||||
import { BottomControlSelectMenu, type BottomControlSelectOption } from "./SelectMenu";
|
import { BottomControlSelectMenu, type BottomControlSelectOption } from "./SelectMenu";
|
||||||
@@ -43,56 +44,23 @@ const inpaintMaskedContentOptions = [
|
|||||||
|
|
||||||
export type GenerateControlsProps = {
|
export type GenerateControlsProps = {
|
||||||
settings: GenerateSettings;
|
settings: GenerateSettings;
|
||||||
|
resources: GenerationResourcesState;
|
||||||
dispatch: AppStore["dispatch"];
|
dispatch: AppStore["dispatch"];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ComfyArchitectureOption = {
|
export function GenerateControls({ settings, resources, dispatch }: GenerateControlsProps) {
|
||||||
value: GenerateArchitecture;
|
const comfyOptions = resources.options;
|
||||||
label: string;
|
|
||||||
defaultModel: string;
|
|
||||||
models: string[];
|
|
||||||
supportedModes: GenerateMode[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type ComfyOptionsResponse = {
|
|
||||||
architectures?: ComfyArchitectureOption[];
|
|
||||||
models?: string[];
|
|
||||||
textEncoders?: string[];
|
|
||||||
vaes?: string[];
|
|
||||||
samplers?: string[];
|
|
||||||
schedulers?: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export function GenerateControls({ settings, dispatch }: GenerateControlsProps) {
|
|
||||||
const [comfyOptions, setComfyOptions] = useState<ComfyOptionsResponse>();
|
|
||||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||||
const [outpaintOpen, setOutpaintOpen] = useState(false);
|
const [outpaintOpen, setOutpaintOpen] = useState(false);
|
||||||
const [inpaintOpen, setInpaintOpen] = useState(false);
|
const [inpaintOpen, setInpaintOpen] = useState(false);
|
||||||
const [sizeOpen, setSizeOpen] = useState(false);
|
const [sizeOpen, setSizeOpen] = useState(false);
|
||||||
const sizeRef = useRef<HTMLDivElement>(null);
|
const sizeRef = useRef<HTMLDivElement>(null);
|
||||||
const [error, setError] = useState<string>();
|
|
||||||
const modelOptions = resolveModelOptions(settings, comfyOptions);
|
const modelOptions = resolveModelOptions(settings, comfyOptions);
|
||||||
const supportOptions = resolveSupportOptions(settings, comfyOptions);
|
const supportOptions = resolveSupportOptions(settings, comfyOptions);
|
||||||
const samplerOptions = resolveStringOptions(comfyOptions?.samplers, settings.sampler);
|
const samplerOptions = resolveStringOptions(comfyOptions?.samplers, settings.sampler);
|
||||||
const schedulerOptions = resolveStringOptions(comfyOptions?.schedulers, settings.scheduler);
|
const schedulerOptions = resolveStringOptions(comfyOptions?.schedulers, settings.scheduler);
|
||||||
const modeOptions = resolveModeOptions(settings, comfyOptions);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!sizeOpen) return;
|
if (!sizeOpen) return;
|
||||||
const close = (event: PointerEvent) => {
|
const close = (event: PointerEvent) => {
|
||||||
@@ -104,7 +72,7 @@ export function GenerateControls({ settings, dispatch }: GenerateControlsProps)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-5 pb-2 tabular-nums">
|
<div className="grid gap-5 pb-2 tabular-nums">
|
||||||
{error ? <p className="rounded-full bg-red-500/10 px-3 py-2 text-xs text-red-200">{error}</p> : null}
|
{resources.error ? <p className="rounded-full bg-red-500/10 px-3 py-2 text-xs text-red-200">{resources.error}</p> : null}
|
||||||
|
|
||||||
<section className={panelSectionClass()}>
|
<section className={panelSectionClass()}>
|
||||||
<label className="grid gap-2">
|
<label className="grid gap-2">
|
||||||
@@ -229,7 +197,7 @@ export function GenerateControls({ settings, dispatch }: GenerateControlsProps)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveModelOptions(settings: GenerateSettings, comfyOptions: ComfyOptionsResponse | undefined): readonly BottomControlSelectOption<GenerateModel>[] {
|
function resolveModelOptions(settings: GenerateSettings, comfyOptions: GenerationOptions | undefined): readonly BottomControlSelectOption<GenerateModel>[] {
|
||||||
const architecture = comfyOptions?.architectures?.find((option) => option.value === settings.architecture);
|
const architecture = comfyOptions?.architectures?.find((option) => option.value === settings.architecture);
|
||||||
const models = architecture?.models ?? (settings.architecture === "sdxl" ? comfyOptions?.models : undefined) ?? [];
|
const models = architecture?.models ?? (settings.architecture === "sdxl" ? comfyOptions?.models : undefined) ?? [];
|
||||||
const fallbackModel = architecture?.defaultModel ?? generateArchitectureDefaults[settings.architecture].model;
|
const fallbackModel = architecture?.defaultModel ?? generateArchitectureDefaults[settings.architecture].model;
|
||||||
@@ -237,7 +205,7 @@ function resolveModelOptions(settings: GenerateSettings, comfyOptions: ComfyOpti
|
|||||||
return values.map((model) => ({ value: model, label: model === "auto" ? "Auto" : model }));
|
return values.map((model) => ({ value: model, label: model === "auto" ? "Auto" : model }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveSupportOptions(settings: GenerateSettings, comfyOptions: ComfyOptionsResponse | undefined) {
|
function resolveSupportOptions(settings: GenerateSettings, comfyOptions: GenerationOptions | undefined) {
|
||||||
const defaults = generateArchitectureDefaults[settings.architecture];
|
const defaults = generateArchitectureDefaults[settings.architecture];
|
||||||
return {
|
return {
|
||||||
textEncoders: resolveStringOptions([...(comfyOptions?.textEncoders ?? []), defaults.textEncoder].filter((value) => value !== "auto"), settings.textEncoder),
|
textEncoders: resolveStringOptions([...(comfyOptions?.textEncoders ?? []), defaults.textEncoder].filter((value) => value !== "auto"), settings.textEncoder),
|
||||||
@@ -249,7 +217,7 @@ function resolveStringOptions(values: string[] | undefined, current: string): re
|
|||||||
return unique([...(values ?? []), current]).map((value) => ({ value, label: value }));
|
return unique([...(values ?? []), current]).map((value) => ({ value, label: value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveModeOptions(settings: GenerateSettings, comfyOptions: ComfyOptionsResponse | undefined): readonly BottomControlSelectOption<GenerateMode>[] {
|
function resolveModeOptions(settings: GenerateSettings, comfyOptions: GenerationOptions | undefined): readonly BottomControlSelectOption<GenerateMode>[] {
|
||||||
const architecture = comfyOptions?.architectures?.find((option) => option.value === settings.architecture);
|
const architecture = comfyOptions?.architectures?.find((option) => option.value === settings.architecture);
|
||||||
const supportedModes = architecture?.supportedModes?.length ? architecture.supportedModes : generateArchitectureDefaults[settings.architecture].supportedModes;
|
const supportedModes = architecture?.supportedModes?.length ? architecture.supportedModes : generateArchitectureDefaults[settings.architecture].supportedModes;
|
||||||
const availableModes = modes.filter((mode) => supportedModes.includes(mode.value));
|
const availableModes = modes.filter((mode) => supportedModes.includes(mode.value));
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import type { InteractionMode } from "@editor/tools";
|
import type { InteractionMode } from "@editor/tools";
|
||||||
import { isPanInteractionMode } from "@editor/tools";
|
import { isPanInteractionMode } from "@editor/tools";
|
||||||
import type { CanvasInputState } from "./useCanvasInput";
|
export function canvasCursorClass(interactionMode: InteractionMode, isPanning: boolean, hasBrushPreview = false, canBrush = true) {
|
||||||
|
if (isPanning) return "cursor-grabbing";
|
||||||
export function canvasCursorClass(interactionMode: InteractionMode, input: CanvasInputState, hasBrushPreview = false, canBrush = true) {
|
|
||||||
if (input.isPanning) return "cursor-grabbing";
|
|
||||||
if (isPanInteractionMode(interactionMode)) return "cursor-grab";
|
if (isPanInteractionMode(interactionMode)) return "cursor-grab";
|
||||||
if (interactionMode.type === "tool" && (interactionMode.tool === "brush" || interactionMode.tool === "eraser")) {
|
if (interactionMode.type === "tool" && (interactionMode.tool === "brush" || interactionMode.tool === "eraser")) {
|
||||||
if (!canBrush) return "cursor-not-allowed";
|
if (!canBrush) return "cursor-not-allowed";
|
||||||
|
|||||||
@@ -1,158 +0,0 @@
|
|||||||
import { commandIds } from "@commands/ids";
|
|
||||||
import type { ImageDocument } from "@core/document";
|
|
||||||
import type { Vec2D } from "@core/geometry";
|
|
||||||
import type { Layer } from "@core/layer";
|
|
||||||
import { getLayerMask } from "@core/layer-mask-utils";
|
|
||||||
import { resolveTransformTargetBounds } from "@editor/transform-targets";
|
|
||||||
import type { AppStore } from "@editor/store";
|
|
||||||
import type { EditorState } from "@editor/state";
|
|
||||||
import { blurMaskValues, despeckleMaskValues, dilateMaskValues, erodeMaskValues, maskValueFromRgba } from "../mask/maskRaster";
|
|
||||||
|
|
||||||
export async function applyMagicWandAt(store: AppStore, point: Vec2D, modeOverride?: EditorState["tools"]["magicWand"]["mode"]) {
|
|
||||||
const state = store.getState();
|
|
||||||
if (state.editor.tools.activeTool !== "magicWand") return false;
|
|
||||||
const target = resolveTarget(state.document, state.editor);
|
|
||||||
if (!target) return true;
|
|
||||||
const x = Math.floor((point.x - target.layer.transform.position.x) / Math.max(0.0001, target.layer.transform.scale.x));
|
|
||||||
const y = Math.floor((point.y - target.layer.transform.position.y) / Math.max(0.0001, target.layer.transform.scale.y));
|
|
||||||
if (x < 0 || y < 0 || x >= target.asset.intrinsicSize.w || y >= target.asset.intrinsicSize.h) return true;
|
|
||||||
const source = await createWandMask(target.asset.source, target.maskAsset?.source, Math.round(target.asset.intrinsicSize.w), Math.round(target.asset.intrinsicSize.h), x, y, { ...state.editor.tools.magicWand, mode: modeOverride ?? state.editor.tools.magicWand.mode });
|
|
||||||
if (target.maskAsset && target.maskLayer && target.maskLayer.type !== "group") {
|
|
||||||
store.dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId: target.maskLayer.id, source, mimeType: "image/png", operation: { type: "magicWand" } });
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const assetId = crypto.randomUUID();
|
|
||||||
const maskLayerId = crypto.randomUUID();
|
|
||||||
const width = Math.max(1, Math.round(target.asset.intrinsicSize.w));
|
|
||||||
const height = Math.max(1, Math.round(target.asset.intrinsicSize.h));
|
|
||||||
store.dispatch(commandIds.documentAddLayerMask, {
|
|
||||||
layerId: target.layer.id,
|
|
||||||
asset: { id: assetId, name: `${target.layer.name} Wand Mask`, mimeType: "image/png", source, intrinsicSize: { w: width, h: height } },
|
|
||||||
maskLayer: { id: maskLayerId, type: "raster", name: `${target.layer.name} Wand Mask`, visible: true, locked: false, opacity: 1, assetId, transform: { position: { x: target.bounds.x, y: target.bounds.y }, scale: { x: target.bounds.w / width, y: target.bounds.h / height }, rotation: target.layer.transform.rotation } },
|
|
||||||
});
|
|
||||||
store.dispatch(commandIds.toolExitMaskEdit, undefined);
|
|
||||||
store.dispatch(commandIds.toolSetActive, { tool: "magicWand" });
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveTarget(document: ImageDocument, editor: EditorState) {
|
|
||||||
const layerId = editor.selection.layerIds[0];
|
|
||||||
if (!layerId || editor.selection.layerIds.length !== 1) return undefined;
|
|
||||||
const layer = findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId);
|
|
||||||
if (!layer || layer.type === "group") return undefined;
|
|
||||||
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
|
||||||
const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id });
|
|
||||||
const layerMask = getLayerMask(layer);
|
|
||||||
const maskLayer = layerMask?.enabled ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerMask.maskLayerId) : undefined;
|
|
||||||
const maskAsset = maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
|
|
||||||
return asset && bounds ? { layer, asset, bounds, maskLayer, maskAsset } : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createWandMask(source: string, existingMaskSource: string | undefined, width: number, height: number, startX: number, startY: number, settings: EditorState["tools"]["magicWand"]) {
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
canvas.width = Math.max(1, width);
|
|
||||||
canvas.height = Math.max(1, height);
|
|
||||||
const context = canvas.getContext("2d");
|
|
||||||
if (!context) return source;
|
|
||||||
const image = await loadImage(source);
|
|
||||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
||||||
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
|
|
||||||
const start = (startY * canvas.width + startX) * 4;
|
|
||||||
const key = [imageData.data[start] ?? 0, imageData.data[start + 1] ?? 0, imageData.data[start + 2] ?? 0];
|
|
||||||
const selected = postProcessSelection(settings.contiguous ? floodSelect(imageData, canvas.width, canvas.height, startX, startY, key, settings.tolerance) : globalSelect(imageData, key, settings.tolerance), canvas.width, canvas.height, settings);
|
|
||||||
const existingMask = existingMaskSource ? await loadMaskValues(existingMaskSource, canvas.width, canvas.height) : undefined;
|
|
||||||
for (let pixel = 0; pixel < selected.length; pixel++) {
|
|
||||||
const current = existingMask?.[pixel] ?? 255;
|
|
||||||
const selectionValue = selected[pixel] ?? 0;
|
|
||||||
const value = settings.mode === "add"
|
|
||||||
? Math.min(current, 255 - selectionValue)
|
|
||||||
: settings.mode === "subtract"
|
|
||||||
? Math.max(current, selectionValue)
|
|
||||||
: 255 - selectionValue;
|
|
||||||
const index = pixel * 4;
|
|
||||||
imageData.data[index] = 255;
|
|
||||||
imageData.data[index + 1] = 255;
|
|
||||||
imageData.data[index + 2] = 255;
|
|
||||||
imageData.data[index + 3] = value;
|
|
||||||
}
|
|
||||||
context.putImageData(imageData, 0, 0);
|
|
||||||
return canvas.toDataURL("image/png");
|
|
||||||
}
|
|
||||||
|
|
||||||
function floodSelect(data: ImageData, width: number, height: number, startX: number, startY: number, key: number[], tolerance: number) {
|
|
||||||
const selected = new Uint8Array(width * height);
|
|
||||||
const queue: Array<[number, number]> = [[startX, startY]];
|
|
||||||
while (queue.length) {
|
|
||||||
const [x, y] = queue.pop()!;
|
|
||||||
if (x < 0 || y < 0 || x >= width || y >= height) continue;
|
|
||||||
const pixel = y * width + x;
|
|
||||||
if (selected[pixel]) continue;
|
|
||||||
if (!matches(data, pixel, key, tolerance)) continue;
|
|
||||||
selected[pixel] = 1;
|
|
||||||
queue.push([x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]);
|
|
||||||
}
|
|
||||||
return selected;
|
|
||||||
}
|
|
||||||
|
|
||||||
function globalSelect(data: ImageData, key: number[], tolerance: number) {
|
|
||||||
const selected = new Uint8Array(data.width * data.height);
|
|
||||||
for (let pixel = 0; pixel < selected.length; pixel++) if (matches(data, pixel, key, tolerance)) selected[pixel] = 1;
|
|
||||||
return selected;
|
|
||||||
}
|
|
||||||
|
|
||||||
function matches(data: ImageData, pixel: number, key: number[], tolerance: number) {
|
|
||||||
const index = pixel * 4;
|
|
||||||
return Math.hypot((data.data[index] ?? 0) - (key[0] ?? 0), (data.data[index + 1] ?? 0) - (key[1] ?? 0), (data.data[index + 2] ?? 0) - (key[2] ?? 0)) <= tolerance;
|
|
||||||
}
|
|
||||||
|
|
||||||
function postProcessSelection(selected: Uint8Array, width: number, height: number, settings: EditorState["tools"]["magicWand"]) {
|
|
||||||
let next: Uint8ClampedArray = selectionToMaskValues(selected);
|
|
||||||
const despeckle = Math.round(Math.max(0, Math.min(20, settings.despeckle)));
|
|
||||||
const choke = Math.round(Math.max(-20, Math.min(20, settings.choke)));
|
|
||||||
const feather = Math.round(Math.max(0, Math.min(20, settings.feather)));
|
|
||||||
if (despeckle > 0) next = despeckleMaskValues(next, width, height, despeckle);
|
|
||||||
if (choke > 0) next = erodeMaskValues(next, width, height, choke);
|
|
||||||
if (choke < 0) next = dilateMaskValues(next, width, height, -choke);
|
|
||||||
if (feather > 0) next = blurMaskValues(next, width, height, feather);
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectionToMaskValues(selected: Uint8Array) {
|
|
||||||
const values = new Uint8ClampedArray(selected.length);
|
|
||||||
for (let index = 0; index < selected.length; index += 1) values[index] = selected[index] ? 255 : 0;
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMaskValues(source: string, width: number, height: number) {
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
canvas.width = width;
|
|
||||||
canvas.height = height;
|
|
||||||
const context = canvas.getContext("2d");
|
|
||||||
if (!context) return undefined;
|
|
||||||
const image = await loadImage(source);
|
|
||||||
context.drawImage(image, 0, 0, width, height);
|
|
||||||
const data = context.getImageData(0, 0, width, height);
|
|
||||||
const values = new Uint8ClampedArray(width * height);
|
|
||||||
for (let pixel = 0; pixel < values.length; pixel++) values[pixel] = maskValueFromRgba(data.data, pixel * 4);
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findLayer(layers: readonly Layer[], layerId: string): Layer | undefined {
|
|
||||||
for (const layer of layers) {
|
|
||||||
if (layer.id === layerId) return layer;
|
|
||||||
if (layer.type === "group") {
|
|
||||||
const found = findLayer(layer.children, layerId);
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadImage(source: string) {
|
|
||||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
|
||||||
const image = new Image();
|
|
||||||
image.onload = () => resolve(image);
|
|
||||||
image.onerror = () => reject(new Error("Failed to load image"));
|
|
||||||
image.src = source;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -158,6 +158,7 @@ const visualEditorChanges: Array<[string, (state: AppState) => AppState]> = [
|
|||||||
editor: {
|
editor: {
|
||||||
...state.editor,
|
...state.editor,
|
||||||
generation: {
|
generation: {
|
||||||
|
...state.editor.generation,
|
||||||
candidates: [
|
candidates: [
|
||||||
{
|
{
|
||||||
id: "candidate",
|
id: "candidate",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
import { useEffect, useRef, type RefObject } from "react";
|
||||||
import { commandIds } from "@commands/ids";
|
import { commandIds } from "@commands/ids";
|
||||||
import type { AppStore } from "@editor/store";
|
import type { AppStore } from "@editor/store";
|
||||||
import { isPanInteractionMode } from "@editor/tools";
|
import { isPanInteractionMode } from "@editor/tools";
|
||||||
@@ -12,8 +12,8 @@ import {
|
|||||||
pointerInputEventFromPointerEvent,
|
pointerInputEventFromPointerEvent,
|
||||||
wheelInputEventFromWheelEvent,
|
wheelInputEventFromWheelEvent,
|
||||||
} from "@input/index";
|
} from "@input/index";
|
||||||
import { beginBrushSession, canPreviewBrush, cancelBrushSession, commitBrushSession, updateBrushSession, type BrushSession } from "./brush";
|
import { beginBrushSession, canPreviewBrush, cancelBrushSession, commitBrushSession, updateBrushSession, type BrushSession } from "@operations/paint/brush";
|
||||||
import { applyMagicWandAt } from "./magic-wand";
|
import { applyMagicWandAt } from "@operations/masks/magic-wand";
|
||||||
|
|
||||||
export type CanvasInputOptions = {
|
export type CanvasInputOptions = {
|
||||||
globalKeybindConsumer: GlobalKeybindConsumer;
|
globalKeybindConsumer: GlobalKeybindConsumer;
|
||||||
@@ -21,16 +21,11 @@ export type CanvasInputOptions = {
|
|||||||
globalWheelConsumer: GlobalWheelConsumer;
|
globalWheelConsumer: GlobalWheelConsumer;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CanvasInputState = {
|
|
||||||
isPanning: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useCanvasInput(
|
export function useCanvasInput(
|
||||||
canvasRef: RefObject<HTMLCanvasElement | null>,
|
canvasRef: RefObject<HTMLCanvasElement | null>,
|
||||||
store: AppStore,
|
store: AppStore,
|
||||||
options: CanvasInputOptions,
|
options: CanvasInputOptions,
|
||||||
): CanvasInputState {
|
) {
|
||||||
const [isPanning, setIsPanning] = useState(false);
|
|
||||||
const brushSession = useRef<BrushSession | undefined>(undefined);
|
const brushSession = useRef<BrushSession | undefined>(undefined);
|
||||||
const brushSessionId = useRef(0);
|
const brushSessionId = useRef(0);
|
||||||
|
|
||||||
@@ -115,7 +110,7 @@ export function useCanvasInput(
|
|||||||
if (consumed) {
|
if (consumed) {
|
||||||
clearBrushPreview();
|
clearBrushPreview();
|
||||||
canvas.setPointerCapture(event.pointerId);
|
canvas.setPointerCapture(event.pointerId);
|
||||||
setIsPanning(true);
|
store.dispatch(commandIds.editorSetPointerSession, { type: "pan" });
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -203,7 +198,7 @@ export function useCanvasInput(
|
|||||||
const consumed = panHandler.pointerUp(inputEvent);
|
const consumed = panHandler.pointerUp(inputEvent);
|
||||||
if (!consumed) return;
|
if (!consumed) return;
|
||||||
|
|
||||||
setIsPanning(false);
|
store.dispatch(commandIds.editorSetPointerSession, undefined);
|
||||||
clearBrushPreview();
|
clearBrushPreview();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
};
|
};
|
||||||
@@ -250,7 +245,6 @@ export function useCanvasInput(
|
|||||||
};
|
};
|
||||||
}, [canvasRef, options, store]);
|
}, [canvasRef, options, store]);
|
||||||
|
|
||||||
return { isPanning };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isEditableKeyboardTarget(target: EventTarget | null) {
|
function isEditableKeyboardTarget(target: EventTarget | null) {
|
||||||
|
|||||||
84
view/layers/MaskControls.tsx
Normal file
84
view/layers/MaskControls.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { Asset } from "@core/asset";
|
||||||
|
import type { AppStore } from "@editor/store";
|
||||||
|
import { analyzeMask, runMaskOperation, type MaskAnalysis, type MaskRasterOperation } from "@operations/masks/rasterActions";
|
||||||
|
|
||||||
|
export function MaskStatus({ asset }: { asset: Asset }) {
|
||||||
|
const [analysis, setAnalysis] = useState<MaskAnalysis>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
void analyzeMask(asset)
|
||||||
|
.then((nextAnalysis) => {
|
||||||
|
if (!cancelled) setAnalysis(nextAnalysis);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setAnalysis(undefined);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [asset.source, asset.intrinsicSize.w, asset.intrinsicSize.h]);
|
||||||
|
|
||||||
|
if (!analysis) return <span className="rounded-full bg-white/5 px-3 py-1 text-xs text-sky-100/45">Reading</span>;
|
||||||
|
return (
|
||||||
|
<span className="inline-flex min-w-0 items-center gap-2 rounded-full bg-white/5 px-2 py-1 text-xs text-sky-100/65">
|
||||||
|
<img src={analysis.thumbnail} alt="" className="h-7 w-10 rounded-md bg-black/30 object-cover ring-1 ring-white/10" />
|
||||||
|
<span className="whitespace-nowrap">Reveal {formatPercent(analysis.coverage)}</span>
|
||||||
|
<span className="whitespace-nowrap text-sky-100/45">Inpaint {formatPercent(analysis.hiddenCoverage)}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MaskOperationButtons({ maskLayerId, maskAsset, dispatch }: { maskLayerId: string; maskAsset: Asset; dispatch: AppStore["dispatch"] }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MaskOperationButton label="Invert" title="Invert mask" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "invert" }} dispatch={dispatch} />
|
||||||
|
<MaskOperationButton label="White" title="Fill mask white" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "fill", fill: "white" }} dispatch={dispatch} />
|
||||||
|
<MaskOperationButton label="Black" title="Fill mask black" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "fill", fill: "black" }} dispatch={dispatch} />
|
||||||
|
<MaskOperationButton label="Clear" title="Clear mask" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "fill", fill: "clear" }} dispatch={dispatch} />
|
||||||
|
<MaskOperationButton label="Feather" title="Feather mask edge" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "feather", radius: 3 }} dispatch={dispatch} />
|
||||||
|
<MaskOperationButton label="Expand" title="Expand mask" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "expand", radius: 3 }} dispatch={dispatch} />
|
||||||
|
<MaskOperationButton label="Contract" title="Contract mask" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "contract", radius: 3 }} dispatch={dispatch} />
|
||||||
|
<MaskOperationButton label="Blur" title="Blur mask edge" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "blur", radius: 2 }} dispatch={dispatch} />
|
||||||
|
<MaskOperationButton label="Clean" title="Despeckle mask" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "despeckle", strength: 8 }} dispatch={dispatch} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MaskOperationButton({
|
||||||
|
label,
|
||||||
|
title,
|
||||||
|
maskLayerId,
|
||||||
|
maskAsset,
|
||||||
|
operation,
|
||||||
|
dispatch,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
title: string;
|
||||||
|
maskLayerId: string;
|
||||||
|
maskAsset: Asset;
|
||||||
|
operation: MaskRasterOperation;
|
||||||
|
dispatch: AppStore["dispatch"];
|
||||||
|
}) {
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={maskActionButtonClass()}
|
||||||
|
disabled={busy}
|
||||||
|
title={title}
|
||||||
|
onClick={() => {
|
||||||
|
setBusy(true);
|
||||||
|
void runMaskOperation(maskLayerId, maskAsset, operation, dispatch)
|
||||||
|
.finally(() => setBusy(false));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{busy ? "..." : label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function maskActionButtonClass() { return "rounded-full bg-white/5 px-2.5 py-1 text-[0.7rem] font-semibold text-white/60 transition hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-35"; }
|
||||||
|
function formatPercent(value: number) { return `${Math.round(value * 100)}%`; }
|
||||||
358
view/paletteItems.tsx
Normal file
358
view/paletteItems.tsx
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
import { ArrowDown, ArrowUp, CornersOut, Cursor, DownloadSimple, DropHalf, Eraser, Eye, EyeSlash, FolderOpen, FolderPlus, Hand, Lock, LockOpen, MagicWand, Minus, PaintBrush, Plus, Sparkle, Stack, Trash } from "@phosphor-icons/react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { commandIds } from "@commands/ids";
|
||||||
|
import type { Artboard } from "@core/artboard";
|
||||||
|
import type { ImageDocument } from "@core/document";
|
||||||
|
import type { GenerationCompareMode, GenerationState, SelectionState, ViewportState } from "@editor/state";
|
||||||
|
import type { AppStore } from "@editor/store";
|
||||||
|
import { availableToolIds, type GenerateMode, type ToolId, type ToolState } from "@editor/tools";
|
||||||
|
import type { DocumentReadIndex, IndexedLayerInfo } from "@editor/document-indexes";
|
||||||
|
import { downloadArtboardPng } from "@operations/export/downloadArtboard";
|
||||||
|
import { addArtboard, addEmptyLayer, addGroupLayer, deleteSelection, groupLayers, moveLayer } from "@operations/document/layerActions";
|
||||||
|
import { labelForTool } from "./toolLabels";
|
||||||
|
|
||||||
|
export type PaletteItem = { id: string; title: string; section: string; subtitle?: string; keywords?: string[]; disabled?: boolean; icon: ReactNode; run: () => void };
|
||||||
|
|
||||||
|
export function createPaletteItems(options: {
|
||||||
|
document: ImageDocument;
|
||||||
|
documentIndex: DocumentReadIndex;
|
||||||
|
activeArtboard?: Artboard;
|
||||||
|
activeArtboardId?: string;
|
||||||
|
selectedLayer?: IndexedLayerInfo;
|
||||||
|
canGroup: boolean;
|
||||||
|
canUngroup: boolean;
|
||||||
|
selection: SelectionState;
|
||||||
|
viewport: ViewportState;
|
||||||
|
tools: ToolState;
|
||||||
|
generation: GenerationState;
|
||||||
|
layersOpen: boolean;
|
||||||
|
dispatch: AppStore["dispatch"];
|
||||||
|
openFilePicker: () => void;
|
||||||
|
openGenerate: () => void;
|
||||||
|
openLayers: () => void;
|
||||||
|
closeLayers: () => void;
|
||||||
|
}): PaletteItem[] {
|
||||||
|
const {
|
||||||
|
document,
|
||||||
|
documentIndex,
|
||||||
|
activeArtboard,
|
||||||
|
activeArtboardId,
|
||||||
|
selectedLayer,
|
||||||
|
canGroup,
|
||||||
|
canUngroup,
|
||||||
|
selection,
|
||||||
|
viewport,
|
||||||
|
tools,
|
||||||
|
generation,
|
||||||
|
layersOpen,
|
||||||
|
dispatch,
|
||||||
|
openFilePicker,
|
||||||
|
openGenerate,
|
||||||
|
openLayers,
|
||||||
|
closeLayers,
|
||||||
|
} = options;
|
||||||
|
const hasCandidates = generation.candidates.length > 0;
|
||||||
|
const items: PaletteItem[] = [];
|
||||||
|
|
||||||
|
items.push(
|
||||||
|
...availableToolIds.map((tool) => ({
|
||||||
|
id: `tool-${tool}`,
|
||||||
|
section: "Tools",
|
||||||
|
title: `Switch to ${labelForTool(tool)}`,
|
||||||
|
subtitle: tool === tools.activeTool ? "Current tool" : undefined,
|
||||||
|
keywords: [tool],
|
||||||
|
icon: toolIcon(tool),
|
||||||
|
run: () => {
|
||||||
|
if (tool === "generate") openGenerate();
|
||||||
|
else dispatch(commandIds.toolSetActive, { tool });
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
items.push(
|
||||||
|
{
|
||||||
|
id: "import-image",
|
||||||
|
section: "File",
|
||||||
|
title: "Import image",
|
||||||
|
subtitle: "Add an image as a layer",
|
||||||
|
keywords: ["open", "file", "layer"],
|
||||||
|
icon: <FolderOpen size={20} />,
|
||||||
|
run: openFilePicker,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "export-artboard",
|
||||||
|
section: "File",
|
||||||
|
title: "Export artboard as PNG",
|
||||||
|
subtitle: activeArtboard ? activeArtboard.name : "No artboard selected",
|
||||||
|
keywords: ["download", "png"],
|
||||||
|
disabled: !activeArtboard,
|
||||||
|
icon: <DownloadSimple size={20} />,
|
||||||
|
run: () => {
|
||||||
|
if (activeArtboard) void downloadArtboardPng(activeArtboard, document.assets);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
items.push(
|
||||||
|
{
|
||||||
|
id: layersOpen ? "close-layers" : "open-layers",
|
||||||
|
section: "Layers",
|
||||||
|
title: layersOpen ? "Close layers panel" : "Open layers panel",
|
||||||
|
keywords: ["panel", "stack"],
|
||||||
|
icon: <Stack size={20} weight={layersOpen ? "fill" : "regular"} />,
|
||||||
|
run: layersOpen ? closeLayers : openLayers,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "add-artboard",
|
||||||
|
section: "Layers",
|
||||||
|
title: "Add artboard",
|
||||||
|
icon: <Plus size={20} />,
|
||||||
|
run: () => addArtboard(document, dispatch),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "add-empty-layer",
|
||||||
|
section: "Layers",
|
||||||
|
title: "Add empty layer",
|
||||||
|
subtitle: activeArtboardId ? undefined : "No artboard available",
|
||||||
|
keywords: ["new", "raster"],
|
||||||
|
disabled: !activeArtboardId,
|
||||||
|
icon: <Plus size={20} />,
|
||||||
|
run: () => {
|
||||||
|
if (activeArtboardId) addEmptyLayer(document, activeArtboardId, selectedLayer, dispatch);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "add-group",
|
||||||
|
section: "Layers",
|
||||||
|
title: "Add group",
|
||||||
|
subtitle: activeArtboardId ? undefined : "No artboard available",
|
||||||
|
disabled: !activeArtboardId,
|
||||||
|
icon: <FolderPlus size={20} />,
|
||||||
|
run: () => {
|
||||||
|
if (activeArtboardId) addGroupLayer(activeArtboardId, dispatch);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "group-selection",
|
||||||
|
section: "Layers",
|
||||||
|
title: "Group selected layers",
|
||||||
|
subtitle: canGroup ? `${selection.layerIds.length} selected` : "Select one or more layers",
|
||||||
|
disabled: !canGroup,
|
||||||
|
icon: <Stack size={20} />,
|
||||||
|
run: () => {
|
||||||
|
if (selection.artboardId) groupLayers(selection.artboardId, selection.layerIds, dispatch);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ungroup-selection",
|
||||||
|
section: "Layers",
|
||||||
|
title: "Ungroup selected group",
|
||||||
|
subtitle: selectedLayer?.layer.name,
|
||||||
|
disabled: !canUngroup || !selectedLayer,
|
||||||
|
icon: <Stack size={20} weight="fill" />,
|
||||||
|
run: () => {
|
||||||
|
if (selectedLayer?.layer.type === "group") dispatch(commandIds.documentUngroupLayer, { groupId: selectedLayer.layer.id });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "move-layer-up",
|
||||||
|
section: "Layers",
|
||||||
|
title: "Move selected layer up",
|
||||||
|
subtitle: selectedLayer?.layer.name,
|
||||||
|
disabled: !selectedLayer,
|
||||||
|
icon: <ArrowUp size={20} />,
|
||||||
|
run: () => {
|
||||||
|
if (selectedLayer) moveLayer(documentIndex, selectedLayer, -1, dispatch);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "move-layer-down",
|
||||||
|
section: "Layers",
|
||||||
|
title: "Move selected layer down",
|
||||||
|
subtitle: selectedLayer?.layer.name,
|
||||||
|
disabled: !selectedLayer,
|
||||||
|
icon: <ArrowDown size={20} />,
|
||||||
|
run: () => {
|
||||||
|
if (selectedLayer) moveLayer(documentIndex, selectedLayer, 1, dispatch);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "toggle-layer-visible",
|
||||||
|
section: "Layers",
|
||||||
|
title: selectedLayer?.layer.visible === false ? "Show selected layer" : "Hide selected layer",
|
||||||
|
subtitle: selectedLayer?.layer.name,
|
||||||
|
disabled: !selectedLayer,
|
||||||
|
icon: selectedLayer?.layer.visible === false ? <Eye size={20} /> : <EyeSlash size={20} />,
|
||||||
|
run: () => {
|
||||||
|
if (selectedLayer) dispatch(commandIds.documentSetLayerVisible, { layerId: selectedLayer.layer.id, visible: !selectedLayer.layer.visible });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "toggle-layer-lock",
|
||||||
|
section: "Layers",
|
||||||
|
title: selectedLayer?.layer.locked ? "Unlock selected layer" : "Lock selected layer",
|
||||||
|
subtitle: selectedLayer?.layer.name,
|
||||||
|
disabled: !selectedLayer,
|
||||||
|
icon: selectedLayer?.layer.locked ? <LockOpen size={20} /> : <Lock size={20} />,
|
||||||
|
run: () => {
|
||||||
|
if (selectedLayer) dispatch(commandIds.documentSetLayerLocked, { layerId: selectedLayer.layer.id, locked: !selectedLayer.layer.locked });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "delete-selection",
|
||||||
|
section: "Layers",
|
||||||
|
title: selectedLayer ? "Delete selected layer" : "Delete selected artboard",
|
||||||
|
subtitle: selectedLayer?.layer.name ?? activeArtboard?.name,
|
||||||
|
disabled: !selectedLayer && !selection.artboardId,
|
||||||
|
icon: <Trash size={20} />,
|
||||||
|
run: () => deleteSelection(selection, selectedLayer, dispatch),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
items.push(
|
||||||
|
{
|
||||||
|
id: "open-generate",
|
||||||
|
section: "Generate",
|
||||||
|
title: "Open generate panel",
|
||||||
|
subtitle: tools.activeTool === "generate" ? "Current tool" : undefined,
|
||||||
|
keywords: ["ai"],
|
||||||
|
icon: <Sparkle size={20} />,
|
||||||
|
run: openGenerate,
|
||||||
|
},
|
||||||
|
...generateModeItems.map((modeItem) => ({
|
||||||
|
id: `generate-mode-${modeItem.mode}`,
|
||||||
|
section: "Generate",
|
||||||
|
title: modeItem.title,
|
||||||
|
subtitle: tools.generate.mode === modeItem.mode ? "Current mode" : undefined,
|
||||||
|
keywords: ["mode", modeItem.mode],
|
||||||
|
icon: <Sparkle size={20} />,
|
||||||
|
run: () => {
|
||||||
|
openGenerate();
|
||||||
|
dispatch(commandIds.toolSetGenerateSettings, { mode: modeItem.mode });
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
id: "generate-random-seed",
|
||||||
|
section: "Generate",
|
||||||
|
title: "Use random seed",
|
||||||
|
subtitle: tools.generate.seed === -1 ? "Current seed" : `Seed ${tools.generate.seed}`,
|
||||||
|
keywords: ["seed"],
|
||||||
|
icon: <Sparkle size={20} />,
|
||||||
|
run: () => dispatch(commandIds.toolSetGenerateSettings, { seed: -1 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "clear-generation-candidates",
|
||||||
|
section: "Generate",
|
||||||
|
title: "Clear candidates",
|
||||||
|
subtitle: hasCandidates ? `${generation.candidates.length} candidate${generation.candidates.length === 1 ? "" : "s"}` : "No candidates",
|
||||||
|
disabled: !hasCandidates,
|
||||||
|
icon: <Trash size={20} />,
|
||||||
|
run: () => dispatch(commandIds.generationClearCandidates, undefined),
|
||||||
|
},
|
||||||
|
...generationCompareItems.map((compareItem) => ({
|
||||||
|
id: `generation-compare-${compareItem.mode}`,
|
||||||
|
section: "Generate",
|
||||||
|
title: compareItem.title,
|
||||||
|
subtitle: generation.compareMode === compareItem.mode ? "Current compare mode" : undefined,
|
||||||
|
disabled: !hasCandidates,
|
||||||
|
icon: <Sparkle size={20} />,
|
||||||
|
run: () => dispatch(commandIds.generationSetCompareMode, { mode: compareItem.mode }),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
items.push(
|
||||||
|
{
|
||||||
|
id: "zoom-in",
|
||||||
|
section: "Zoom",
|
||||||
|
title: "Zoom in",
|
||||||
|
subtitle: `${Math.round(viewport.zoom * 100)}%`,
|
||||||
|
icon: <Plus size={20} />,
|
||||||
|
run: () => dispatch(commandIds.viewportSetZoom, { zoom: viewport.zoom * 1.2 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zoom-out",
|
||||||
|
section: "Zoom",
|
||||||
|
title: "Zoom out",
|
||||||
|
subtitle: `${Math.round(viewport.zoom * 100)}%`,
|
||||||
|
icon: <Minus size={20} />,
|
||||||
|
run: () => dispatch(commandIds.viewportSetZoom, { zoom: viewport.zoom / 1.2 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zoom-100",
|
||||||
|
section: "Zoom",
|
||||||
|
title: "Zoom to 100%",
|
||||||
|
icon: <CornersOut size={20} />,
|
||||||
|
run: () => dispatch(commandIds.viewportSetZoom, { zoom: 1 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "fit-artboard",
|
||||||
|
section: "Zoom",
|
||||||
|
title: "Fit artboard",
|
||||||
|
subtitle: activeArtboard?.name,
|
||||||
|
disabled: !activeArtboard,
|
||||||
|
icon: <CornersOut size={20} />,
|
||||||
|
run: () => dispatch(commandIds.viewportFitArtboard, undefined),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
items.push(
|
||||||
|
{
|
||||||
|
id: "debug-reset-viewport",
|
||||||
|
section: "Debug",
|
||||||
|
title: "Reset viewport",
|
||||||
|
icon: <CornersOut size={20} />,
|
||||||
|
run: () => dispatch(commandIds.viewportReset, undefined),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "debug-clear-selection",
|
||||||
|
section: "Debug",
|
||||||
|
title: "Clear selection",
|
||||||
|
subtitle: selection.layerIds.length > 0 || selection.artboardId ? undefined : "Nothing selected",
|
||||||
|
disabled: selection.layerIds.length === 0 && !selection.artboardId,
|
||||||
|
icon: <Cursor size={20} />,
|
||||||
|
run: () => dispatch(commandIds.selectionClear, undefined),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "debug-clear-candidates",
|
||||||
|
section: "Debug",
|
||||||
|
title: "Clear generation state",
|
||||||
|
disabled: !hasCandidates,
|
||||||
|
icon: <Trash size={20} />,
|
||||||
|
run: () => dispatch(commandIds.generationClearCandidates, undefined),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateModeItems: Array<{ mode: GenerateMode; title: string }> = [
|
||||||
|
{ mode: "text-to-image", title: "Text-to-image mode" },
|
||||||
|
{ mode: "image-to-image", title: "Image-to-image mode" },
|
||||||
|
{ mode: "inpaint", title: "Inpaint mode" },
|
||||||
|
{ mode: "outpaint", title: "Outpaint mode" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const generationCompareItems: Array<{ mode: GenerationCompareMode; title: string }> = [
|
||||||
|
{ mode: "result", title: "Show result" },
|
||||||
|
{ mode: "before", title: "Show before" },
|
||||||
|
{ mode: "split", title: "Split compare" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function toolIcon(tool: ToolId) {
|
||||||
|
switch (tool) {
|
||||||
|
case "select":
|
||||||
|
return <Cursor size={20} />;
|
||||||
|
case "generate":
|
||||||
|
return <Sparkle size={20} />;
|
||||||
|
case "brush":
|
||||||
|
return <PaintBrush size={20} />;
|
||||||
|
case "eraser":
|
||||||
|
return <Eraser size={20} />;
|
||||||
|
case "chromaKey":
|
||||||
|
return <DropHalf size={20} />;
|
||||||
|
case "magicWand":
|
||||||
|
return <MagicWand size={20} />;
|
||||||
|
case "pan":
|
||||||
|
return <Hand size={20} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,57 +1,15 @@
|
|||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import { commandIds } from "@commands/ids";
|
|
||||||
import type { AppStore } from "@editor/store";
|
import type { AppStore } from "@editor/store";
|
||||||
|
import { importImageAsLayer } from "@operations/import/importImage";
|
||||||
|
import { decodeBrowserImageFile } from "@platform/browser/imageFiles";
|
||||||
|
|
||||||
export function useImageImport(store: AppStore) {
|
export function useImageImport(store: AppStore) {
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const importFile = useCallback(
|
const importFile = useCallback(
|
||||||
async (file: File) => {
|
async (file: File) => {
|
||||||
if (!file.type.startsWith("image/")) return;
|
const image = await decodeBrowserImageFile(file);
|
||||||
|
if (image) importImageAsLayer(store, image);
|
||||||
const source = URL.createObjectURL(file);
|
|
||||||
const intrinsicSize = await loadImageSize(source);
|
|
||||||
const state = store.getState();
|
|
||||||
const artboard = state.editor.selection.artboardId
|
|
||||||
? state.document.artboards.find((candidate) => candidate.id === state.editor.selection.artboardId)
|
|
||||||
: state.document.artboards[0];
|
|
||||||
|
|
||||||
if (!artboard) {
|
|
||||||
URL.revokeObjectURL(source);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const assetId = crypto.randomUUID();
|
|
||||||
const layerId = crypto.randomUUID();
|
|
||||||
const center = state.editor.viewport.center;
|
|
||||||
|
|
||||||
store.dispatch(commandIds.documentAddAsset, {
|
|
||||||
asset: {
|
|
||||||
id: assetId,
|
|
||||||
name: file.name,
|
|
||||||
mimeType: file.type,
|
|
||||||
source,
|
|
||||||
intrinsicSize,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
store.dispatch(commandIds.documentAddImageLayer, {
|
|
||||||
artboardId: artboard.id,
|
|
||||||
layer: {
|
|
||||||
id: layerId,
|
|
||||||
type: "image",
|
|
||||||
name: file.name,
|
|
||||||
visible: true,
|
|
||||||
locked: false,
|
|
||||||
opacity: 1,
|
|
||||||
assetId,
|
|
||||||
transform: {
|
|
||||||
position: { x: center.x - intrinsicSize.w / 2, y: center.y - intrinsicSize.h / 2 },
|
|
||||||
scale: { x: 1, y: 1 },
|
|
||||||
rotation: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
store.dispatch(commandIds.selectionSet, { artboardId: artboard.id, layerIds: [layerId] });
|
|
||||||
},
|
},
|
||||||
[store],
|
[store],
|
||||||
);
|
);
|
||||||
@@ -97,12 +55,3 @@ export function useImageImport(store: AppStore) {
|
|||||||
|
|
||||||
return { input, openFilePicker, importFile };
|
return { input, openFilePicker, importFile };
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadImageSize(source: string): Promise<{ w: number; h: number }> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const image = new Image();
|
|
||||||
image.onload = () => resolve({ w: image.naturalWidth, h: image.naturalHeight });
|
|
||||||
image.onerror = () => reject(new Error("Failed to load image"));
|
|
||||||
image.src = source;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user