Compare commits

..

5 Commits

Author SHA1 Message Date
syntaxbullet
49b029d60a feat: update project name to 'image-studio' and enhance document title in App component 2026-07-11 11:38:59 +02:00
syntaxbullet
03493a1c32 feat: add generation cancel job functionality and improve job handling
- Introduced `generationCancelJob` command ID in `commands/ids.ts`.
- Added `GenerationCancelJobPayload` type in `commands/payloads.ts`.
- Enhanced job status to include "cancelled" in `editor/state.ts`.
- Updated `runGenerationJob` to accept an `AbortSignal` and handle cancellation.
- Implemented cancellation logic in `runGenerate` and related functions.
- Added tests for job cancellation in `operations/generation/workflow.test.ts`.
- Improved layer rendering logic to prevent stack overflow in `editor/document-indexes.ts`.
- Added raster size assertions in `platform/browser/rasterLimits.ts` for image processing limits.
- Enhanced image file handling to check for size limits in `platform/browser/imageFiles.ts`.
- Updated UI components to reflect job cancellation state in `view/GenerationJobStatus.tsx` and `view/bottom-controls/GenerateActionControls.tsx`.
2026-07-11 11:37:21 +02:00
syntaxbullet
d8fdd43416 feat: enhance accessibility by adding ARIA attributes and focus styles; improve canvas and button elements 2026-07-11 11:28:57 +02:00
syntaxbullet
53cf25c132 feat: implement document actions for managing artboards, zoom, and history; update UI components to utilize new actions 2026-07-11 11:26:27 +02:00
syntaxbullet
1236b6dd2f feat: add commands for clearing candidates and reusing candidate settings, update UI controls 2026-07-11 11:23:25 +02:00
40 changed files with 662 additions and 192 deletions

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -5,11 +5,14 @@ import {
generationAddCandidateCommand, generationAddCandidateCommand,
generationApplyCandidateAsLayerCommand, generationApplyCandidateAsLayerCommand,
generationRemoveCandidateCommand, generationRemoveCandidateCommand,
generationClearCandidatesCommand,
generationReuseCandidateSettingsCommand,
generationReplaceCandidatePixelsCommand, generationReplaceCandidatePixelsCommand,
generationSetCompareModeCommand, generationSetCompareModeCommand,
generationFailJobCommand, generationFailJobCommand,
generationStartJobCommand, generationStartJobCommand,
generationSucceedJobCommand, generationSucceedJobCommand,
generationCancelJobCommand,
} from "./generation"; } from "./generation";
describe("generation commands", () => { describe("generation commands", () => {
@@ -38,6 +41,48 @@ describe("generation commands", () => {
expect(next.editor.generation.compareMode).toBe("split"); expect(next.editor.generation.compareMode).toBe("split");
}); });
test("bounds retained candidate source memory as well as candidate count", () => {
const largeSource = "x".repeat(34 * 1024 * 1024);
const first = { ...generationCandidate("candidate-1"), source: largeSource };
const second = { ...generationCandidate("candidate-2"), source: largeSource };
const withFirst = generationAddCandidateCommand.execute({ state: createInitialAppState("Test") }, { candidate: first });
const withSecond = generationAddCandidateCommand.execute({ state: withFirst }, { candidate: second });
expect(withSecond.editor.generation.candidates.map((candidate) => candidate.id)).toEqual(["candidate-2"]);
});
test("clears the candidate session and resets comparison", () => {
const withCandidate = generationAddCandidateCommand.execute(
{ state: createInitialAppState("Test") },
{ candidate: generationCandidate("candidate-1") },
);
const comparing = generationSetCompareModeCommand.execute({ state: withCandidate }, { mode: "split" });
const next = generationClearCandidatesCommand.execute({ state: comparing }, undefined);
expect(next.editor.generation.candidates).toEqual([]);
expect(next.editor.generation.selectedCandidateId).toBeUndefined();
expect(next.editor.generation.compareMode).toBe("result");
});
test("reuses a candidate's full settings with its resolved seed", () => {
const candidate = generationCandidate("candidate-1");
candidate.settings = {
...candidate.settings,
prompt: "Stored prompt",
model: "stored-model.safetensors",
seed: -1,
inpaint: { ...candidate.settings.inpaint, maskBlur: 7 },
};
candidate.seed = 987654;
const state = generationAddCandidateCommand.execute({ state: createInitialAppState("Test") }, { candidate });
const next = generationReuseCandidateSettingsCommand.execute({ state }, { candidateId: candidate.id });
expect(next.editor.tools.generate).toEqual({ ...candidate.settings, seed: 987654 });
expect(next.editor.generation).toBe(state.editor.generation);
});
test("applies candidates as top-level layers", () => { test("applies candidates as top-level layers", () => {
const withRemainingCandidate = generationAddCandidateCommand.execute( const withRemainingCandidate = generationAddCandidateCommand.execute(
{ state: documentWithSourceLayer() }, { state: documentWithSourceLayer() },
@@ -125,6 +170,13 @@ describe("generation commands", () => {
expect(failed.editor.generation.jobs[0]).toMatchObject({ id: "job-1", status: "failed", error: "Backend unavailable", finishedAt: 125 }); expect(failed.editor.generation.jobs[0]).toMatchObject({ id: "job-1", status: "failed", error: "Backend unavailable", finishedAt: 125 });
}); });
test("records explicit cancellation separately from failure", () => {
const running = generationStartJobCommand.execute({ state: createInitialAppState("Test") }, { jobId: "job-1", kind: "generate", label: "Generating", startedAt: 100 });
const cancelled = generationCancelJobCommand.execute({ state: running }, { jobId: "job-1", finishedAt: 110 });
expect(cancelled.editor.generation.jobs[0]).toMatchObject({ id: "job-1", status: "cancelled", finishedAt: 110 });
});
}); });
function documentWithSourceLayer() { function documentWithSourceLayer() {

View File

@@ -24,6 +24,10 @@ export type GenerationRemoveCandidatePayload = {
candidateId: GenerationCandidateId; candidateId: GenerationCandidateId;
}; };
export type GenerationReuseCandidateSettingsPayload = {
candidateId: GenerationCandidateId;
};
export type GenerationApplyCandidateAsLayerPayload = { export type GenerationApplyCandidateAsLayerPayload = {
candidateId: GenerationCandidateId; candidateId: GenerationCandidateId;
assetId: AssetId; assetId: AssetId;
@@ -39,10 +43,12 @@ export type GenerationReplaceCandidatePixelsPayload = {
export type GenerationStartJobPayload = { jobId: GenerationJobId; kind: GenerationJobKind; label: string; startedAt: number }; export type GenerationStartJobPayload = { jobId: GenerationJobId; kind: GenerationJobKind; label: string; startedAt: number };
export type GenerationSucceedJobPayload = { jobId: GenerationJobId; finishedAt: number }; export type GenerationSucceedJobPayload = { jobId: GenerationJobId; finishedAt: number };
export type GenerationFailJobPayload = { jobId: GenerationJobId; finishedAt: number; error: string }; export type GenerationFailJobPayload = { jobId: GenerationJobId; finishedAt: number; error: string };
export type GenerationCancelJobPayload = { jobId: GenerationJobId; finishedAt: number };
export type GenerationSetResourcesPayload = { options: GenerationOptions }; export type GenerationSetResourcesPayload = { options: GenerationOptions };
export type GenerationFailResourcesPayload = { error: string }; export type GenerationFailResourcesPayload = { error: string };
const maxCandidates = 12; const maxCandidates = 12;
const maxCandidateSourceBytes = 96 * 1024 * 1024;
const maxJobs = 20; const maxJobs = 20;
const generationCompareModes = new Set<GenerationCompareMode>(["result", "before", "split"]); const generationCompareModes = new Set<GenerationCompareMode>(["result", "before", "split"]);
@@ -51,7 +57,7 @@ export const generationAddCandidateCommand: Command<GenerationAddCandidatePayloa
name: "Add generation candidate", name: "Add generation candidate",
history: { mode: "ignore" }, history: { mode: "ignore" },
execute({ state }, payload) { execute({ state }, payload) {
const candidates = [payload.candidate, ...state.editor.generation.candidates.filter((candidate) => candidate.id !== payload.candidate.id)].slice(0, maxCandidates); const candidates = retainCandidateBudget([payload.candidate, ...state.editor.generation.candidates.filter((candidate) => candidate.id !== payload.candidate.id)]);
return { return {
...state, ...state,
editor: { editor: {
@@ -140,6 +146,30 @@ export const generationClearCandidatesCommand: Command = {
}, },
}; };
export const generationReuseCandidateSettingsCommand: Command<GenerationReuseCandidateSettingsPayload> = {
id: commandIds.generationReuseCandidateSettings,
name: "Reuse generation candidate settings",
execute({ state }, payload) {
const candidate = state.editor.generation.candidates.find((item) => item.id === payload.candidateId);
if (!candidate) return state;
return {
...state,
editor: {
...state.editor,
tools: {
...state.editor.tools,
generate: {
...candidate.settings,
seed: candidate.seed,
outpaint: { ...candidate.settings.outpaint },
inpaint: { ...candidate.settings.inpaint },
},
},
},
};
},
};
export const generationApplyCandidateAsLayerCommand: Command<GenerationApplyCandidateAsLayerPayload> = { export const generationApplyCandidateAsLayerCommand: Command<GenerationApplyCandidateAsLayerPayload> = {
id: commandIds.generationApplyCandidateAsLayer, id: commandIds.generationApplyCandidateAsLayer,
name: "Apply generation candidate as layer", name: "Apply generation candidate as layer",
@@ -249,6 +279,15 @@ export const generationFailJobCommand: Command<GenerationFailJobPayload> = {
}, },
}; };
export const generationCancelJobCommand: Command<GenerationCancelJobPayload> = {
id: commandIds.generationCancelJob,
name: "Cancel generation job",
history: { mode: "ignore" },
execute({ state }, payload) {
return settleJob(state, payload.jobId, payload.finishedAt, "cancelled");
},
};
export const generationLoadResourcesCommand: Command = { export const generationLoadResourcesCommand: Command = {
id: commandIds.generationLoadResources, id: commandIds.generationLoadResources,
name: "Load generation resources", name: "Load generation resources",
@@ -284,11 +323,13 @@ export const generationCommands = [
generationSetCompareModeCommand, generationSetCompareModeCommand,
generationRemoveCandidateCommand, generationRemoveCandidateCommand,
generationClearCandidatesCommand, generationClearCandidatesCommand,
generationReuseCandidateSettingsCommand,
generationApplyCandidateAsLayerCommand, generationApplyCandidateAsLayerCommand,
generationReplaceCandidatePixelsCommand, generationReplaceCandidatePixelsCommand,
generationStartJobCommand, generationStartJobCommand,
generationSucceedJobCommand, generationSucceedJobCommand,
generationFailJobCommand, generationFailJobCommand,
generationCancelJobCommand,
generationLoadResourcesCommand, generationLoadResourcesCommand,
generationSetResourcesCommand, generationSetResourcesCommand,
generationFailResourcesCommand, generationFailResourcesCommand,
@@ -302,13 +343,31 @@ function updateResources(state: AppState, resources: GenerationState["resources"
return { ...state, editor: { ...state.editor, generation: { ...state.editor.generation, resources } } }; return { ...state, editor: { ...state.editor, generation: { ...state.editor.generation, resources } } };
} }
function settleJob(state: AppState, jobId: GenerationJobId, finishedAt: number, status: "succeeded" | "failed", error?: string): AppState { function settleJob(state: AppState, jobId: GenerationJobId, finishedAt: number, status: "succeeded" | "failed" | "cancelled", error?: string): AppState {
if (!Number.isFinite(finishedAt)) return state; if (!Number.isFinite(finishedAt)) return state;
const job = state.editor.generation.jobs.find((candidate) => candidate.id === jobId); const job = state.editor.generation.jobs.find((candidate) => candidate.id === jobId);
if (!job || job.status !== "running" || finishedAt < job.startedAt) return state; 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)); return updateJobs(state, state.editor.generation.jobs.map((candidate) => candidate.id === jobId ? { ...candidate, status, finishedAt, error } : candidate));
} }
function retainCandidateBudget(candidates: GenerationCandidate[]): GenerationCandidate[] {
const retained: GenerationCandidate[] = [];
let bytes = 0;
for (const candidate of candidates) {
const candidateBytes = candidateRetainedBytes(candidate);
if (retained.length > 0 && bytes + candidateBytes > maxCandidateSourceBytes) continue;
retained.push(candidate);
bytes += candidateBytes;
if (retained.length >= maxCandidates) break;
}
return retained;
}
function candidateRetainedBytes(candidate: GenerationCandidate): number {
return [candidate.source, candidate.inputImage, candidate.maskImage, candidate.inpaint?.inputImage, candidate.inpaint?.maskImage]
.reduce((total, source) => total + (source?.length ?? 0) * 2, 0);
}
type LayerLocation = { type LayerLocation = {
artboardId: ArtboardId; artboardId: ArtboardId;
layer: Layer; layer: Layer;

View File

@@ -42,11 +42,13 @@ export const commandIds = {
generationSetCompareMode: "generation.setCompareMode", generationSetCompareMode: "generation.setCompareMode",
generationRemoveCandidate: "generation.removeCandidate", generationRemoveCandidate: "generation.removeCandidate",
generationClearCandidates: "generation.clearCandidates", generationClearCandidates: "generation.clearCandidates",
generationReuseCandidateSettings: "generation.reuseCandidateSettings",
generationApplyCandidateAsLayer: "generation.applyCandidateAsLayer", generationApplyCandidateAsLayer: "generation.applyCandidateAsLayer",
generationReplaceCandidatePixels: "generation.replaceCandidatePixels", generationReplaceCandidatePixels: "generation.replaceCandidatePixels",
generationStartJob: "generation.startJob", generationStartJob: "generation.startJob",
generationSucceedJob: "generation.succeedJob", generationSucceedJob: "generation.succeedJob",
generationFailJob: "generation.failJob", generationFailJob: "generation.failJob",
generationCancelJob: "generation.cancelJob",
generationLoadResources: "generation.loadResources", generationLoadResources: "generation.loadResources",
generationSetResources: "generation.setResources", generationSetResources: "generation.setResources",
generationFailResources: "generation.failResources", generationFailResources: "generation.failResources",

View File

@@ -27,12 +27,14 @@ import type {
GenerationAddCandidatePayload, GenerationAddCandidatePayload,
GenerationApplyCandidateAsLayerPayload, GenerationApplyCandidateAsLayerPayload,
GenerationRemoveCandidatePayload, GenerationRemoveCandidatePayload,
GenerationReuseCandidateSettingsPayload,
GenerationReplaceCandidatePixelsPayload, GenerationReplaceCandidatePixelsPayload,
GenerationSelectCandidatePayload, GenerationSelectCandidatePayload,
GenerationSetCompareModePayload, GenerationSetCompareModePayload,
GenerationStartJobPayload, GenerationStartJobPayload,
GenerationSucceedJobPayload, GenerationSucceedJobPayload,
GenerationFailJobPayload, GenerationFailJobPayload,
GenerationCancelJobPayload,
GenerationSetResourcesPayload, GenerationSetResourcesPayload,
GenerationFailResourcesPayload, GenerationFailResourcesPayload,
} from "./generation"; } from "./generation";
@@ -99,11 +101,13 @@ export type CommandPayloads = {
[commandIds.generationSetCompareMode]: GenerationSetCompareModePayload; [commandIds.generationSetCompareMode]: GenerationSetCompareModePayload;
[commandIds.generationRemoveCandidate]: GenerationRemoveCandidatePayload; [commandIds.generationRemoveCandidate]: GenerationRemoveCandidatePayload;
[commandIds.generationClearCandidates]: void; [commandIds.generationClearCandidates]: void;
[commandIds.generationReuseCandidateSettings]: GenerationReuseCandidateSettingsPayload;
[commandIds.generationApplyCandidateAsLayer]: GenerationApplyCandidateAsLayerPayload; [commandIds.generationApplyCandidateAsLayer]: GenerationApplyCandidateAsLayerPayload;
[commandIds.generationReplaceCandidatePixels]: GenerationReplaceCandidatePixelsPayload; [commandIds.generationReplaceCandidatePixels]: GenerationReplaceCandidatePixelsPayload;
[commandIds.generationStartJob]: GenerationStartJobPayload; [commandIds.generationStartJob]: GenerationStartJobPayload;
[commandIds.generationSucceedJob]: GenerationSucceedJobPayload; [commandIds.generationSucceedJob]: GenerationSucceedJobPayload;
[commandIds.generationFailJob]: GenerationFailJobPayload; [commandIds.generationFailJob]: GenerationFailJobPayload;
[commandIds.generationCancelJob]: GenerationCancelJobPayload;
[commandIds.generationLoadResources]: void; [commandIds.generationLoadResources]: void;
[commandIds.generationSetResources]: GenerationSetResourcesPayload; [commandIds.generationSetResources]: GenerationSetResourcesPayload;
[commandIds.generationFailResources]: GenerationFailResourcesPayload; [commandIds.generationFailResources]: GenerationFailResourcesPayload;

View File

@@ -67,6 +67,17 @@ describe("document read indexes", () => {
expect(visited).toEqual(["group", "target", "mask"]); expect(visited).toEqual(["group", "target", "mask"]);
expect(layers.map((layer) => layer.id)).toEqual(["mask", "target", "group"]); expect(layers.map((layer) => layer.id)).toEqual(["mask", "target", "group"]);
}); });
test("indexes pathological nesting without recursive stack overflow", () => {
let layers: Layer[] = [raster("leaf", "Leaf", "asset-nested", { x: 80, y: 10 }, { x: 2, y: 3 })];
for (let depth = 0; depth < 10_000; depth += 1) layers = [group(`group-${depth}`, `Group ${depth}`, layers)];
const deepDocument = { ...document, artboards: [{ ...document.artboards[0]!, layers }] };
const index = createDocumentReadIndex(deepDocument);
expect(index.layerById.size).toBe(10_001);
expect(resolveIndexedLayerBounds(index, layers[0]!)).toEqual({ x: 80, y: 10, w: 40, h: 30 });
});
}); });
function raster( function raster(

View File

@@ -99,55 +99,58 @@ function indexLayerTree(options: {
documentMaskLayerIds: Set<LayerId>; documentMaskLayerIds: Set<LayerId>;
maskLayerIdsByLayerList: Map<readonly Layer[], ReadonlySet<LayerId>>; maskLayerIdsByLayerList: Map<readonly Layer[], ReadonlySet<LayerId>>;
}): Set<LayerId> { }): Set<LayerId> {
const layerListMaskLayerIds = new Set<LayerId>(); type Frame = { layers: readonly Layer[]; parentGroupId?: LayerId; visited: boolean };
const stack: Frame[] = [{ layers: options.layers, parentGroupId: options.parentGroupId, visited: false }];
for (let index = 0; index < options.layers.length; index += 1) { while (stack.length > 0) {
const layer = options.layers[index]; const frame = stack.pop();
if (!layer) continue; if (!frame) continue;
if (frame.visited) {
options.layerById.set(layer.id, layer); const ids = new Set<LayerId>();
options.layerInfoById.set(layer.id, { for (const layer of frame.layers) {
artboardId: options.artboardId, const mask = getLayerMask(layer);
parentGroupId: options.parentGroupId, if (mask) ids.add(mask.maskLayerId);
layer, if (layer.type === "group") for (const id of options.maskLayerIdsByLayerList.get(layer.children) ?? []) ids.add(id);
siblings: options.layers, }
index, options.maskLayerIdsByLayerList.set(frame.layers, ids);
}); continue;
const layerMask = getLayerMask(layer);
if (layerMask) {
options.documentMaskLayerIds.add(layerMask.maskLayerId);
layerListMaskLayerIds.add(layerMask.maskLayerId);
} }
stack.push({ ...frame, visited: true });
if (layer.type === "group") { for (let index = frame.layers.length - 1; index >= 0; index -= 1) {
const childMaskLayerIds = indexLayerTree({ const layer = frame.layers[index];
...options, if (!layer) continue;
layers: layer.children, options.layerById.set(layer.id, layer);
parentGroupId: layer.id, options.layerInfoById.set(layer.id, { artboardId: options.artboardId, parentGroupId: frame.parentGroupId, layer, siblings: frame.layers, index });
}); const mask = getLayerMask(layer);
for (const maskLayerId of childMaskLayerIds) layerListMaskLayerIds.add(maskLayerId); if (mask) options.documentMaskLayerIds.add(mask.maskLayerId);
if (layer.type === "group") stack.push({ layers: layer.children, parentGroupId: layer.id, visited: false });
} }
} }
return new Set(options.maskLayerIdsByLayerList.get(options.layers) ?? []);
options.maskLayerIdsByLayerList.set(options.layers, layerListMaskLayerIds);
return layerListMaskLayerIds;
} }
function countDisplayLayers(layers: readonly Layer[], maskLayerIds: ReadonlySet<LayerId>): number { function countDisplayLayers(layers: readonly Layer[], maskLayerIds: ReadonlySet<LayerId>): number {
let count = 0; let count = 0;
for (const layer of layers) { const stack = [...layers];
while (stack.length > 0) {
const layer = stack.pop();
if (!layer) continue;
if (maskLayerIds.has(layer.id)) continue; if (maskLayerIds.has(layer.id)) continue;
count += 1; count += 1;
if (layer.type === "group") count += countDisplayLayers(layer.children, maskLayerIds); if (layer.type === "group") stack.push(...layer.children);
} }
return count; return count;
} }
function unionLayerBounds(index: DocumentReadIndex, layers: readonly Layer[]): Rect | undefined { function unionLayerBounds(index: DocumentReadIndex, layers: readonly Layer[]): Rect | undefined {
let bounds: Rect | undefined; let bounds: Rect | undefined;
const stack = [...layers];
for (const layer of layers) { while (stack.length > 0) {
const layer = stack.pop();
if (!layer) continue;
if (layer.type === "group") {
stack.push(...layer.children);
continue;
}
const layerBounds = resolveIndexedLayerBounds(index, layer); const layerBounds = resolveIndexedLayerBounds(index, layer);
if (!layerBounds) continue; if (!layerBounds) continue;
bounds = bounds ? unionRects(bounds, layerBounds) : layerBounds; bounds = bounds ? unionRects(bounds, layerBounds) : layerBounds;

View File

@@ -82,7 +82,7 @@ export type GenerationCandidate = {
export type GenerationCompareMode = "result" | "before" | "split"; export type GenerationCompareMode = "result" | "before" | "split";
export type GenerationJobKind = "generate" | "regenerate" | "refine" | "replace"; export type GenerationJobKind = "generate" | "regenerate" | "refine" | "replace";
export type GenerationJobStatus = "running" | "succeeded" | "failed"; export type GenerationJobStatus = "running" | "succeeded" | "failed" | "cancelled";
export type GenerationJob = { export type GenerationJob = {
id: GenerationJobId; id: GenerationJobId;

View File

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

View File

@@ -6,7 +6,8 @@ export async function runGenerationJob(options: {
kind: GenerationJobKind; kind: GenerationJobKind;
label: string; label: string;
dispatch: AppStore["dispatch"]; dispatch: AppStore["dispatch"];
task: () => Promise<void>; signal: AbortSignal;
task: (signal: AbortSignal) => Promise<void>;
}): Promise<void> { }): Promise<void> {
const jobId = crypto.randomUUID(); const jobId = crypto.randomUUID();
const startedAt = Date.now(); const startedAt = Date.now();
@@ -14,9 +15,13 @@ export async function runGenerationJob(options: {
if (!nextState.editor.generation.jobs.some((job) => job.id === jobId && job.status === "running")) return; if (!nextState.editor.generation.jobs.some((job) => job.id === jobId && job.status === "running")) return;
try { try {
await options.task(); await options.task(options.signal);
options.dispatch(commandIds.generationSucceedJob, { jobId, finishedAt: Date.now() }); options.dispatch(commandIds.generationSucceedJob, { jobId, finishedAt: Date.now() });
} catch (reason: unknown) { } catch (reason: unknown) {
if (options.signal.aborted) {
options.dispatch(commandIds.generationCancelJob, { jobId, finishedAt: Date.now() });
return;
}
options.dispatch(commandIds.generationFailJob, { options.dispatch(commandIds.generationFailJob, {
jobId, jobId,
finishedAt: Date.now(), finishedAt: Date.now(),

View File

@@ -17,6 +17,7 @@ export async function runGenerate(options: {
viewport: ViewportState; viewport: ViewportState;
settings: GenerateSettings; settings: GenerateSettings;
dispatch: AppStore["dispatch"]; dispatch: AppStore["dispatch"];
signal?: AbortSignal;
}) { }) {
const { document, selection, settings, dispatch } = options; const { document, selection, settings, dispatch } = options;
const precondition = checkGenerationPreconditions(document, selection, settings); const precondition = checkGenerationPreconditions(document, selection, settings);
@@ -39,6 +40,7 @@ export async function runGenerate(options: {
inputImage, inputImage,
maskImage, maskImage,
inpaintBundle, inpaintBundle,
signal: options.signal,
}); });
const intrinsicSize = await loadImageSize(generated.source); const intrinsicSize = await loadImageSize(generated.source);
const placement = resolveGeneratedOutputPlacement({ document, selection, settings, intrinsicSize, inpaintBundle }); const placement = resolveGeneratedOutputPlacement({ document, selection, settings, intrinsicSize, inpaintBundle });
@@ -64,6 +66,7 @@ export async function runGenerateFromCandidate(options: {
candidate: GenerationCandidate; candidate: GenerationCandidate;
settings?: GenerateSettings; settings?: GenerateSettings;
dispatch: AppStore["dispatch"]; dispatch: AppStore["dispatch"];
signal?: AbortSignal;
}) { }) {
const settings = options.settings ?? options.candidate.settings; const settings = options.settings ?? options.candidate.settings;
const seed = resolveSeed(settings.seed); const seed = resolveSeed(settings.seed);
@@ -75,6 +78,7 @@ export async function runGenerateFromCandidate(options: {
inputImage: options.candidate.inputImage, inputImage: options.candidate.inputImage,
maskImage: options.candidate.maskImage, maskImage: options.candidate.maskImage,
inpaintCandidate: options.candidate, inpaintCandidate: options.candidate,
signal: options.signal,
}); });
const intrinsicSize = await loadImageSize(generated.source); const intrinsicSize = await loadImageSize(generated.source);
@@ -141,6 +145,7 @@ async function requestGenerate(options: {
maskImage?: string; maskImage?: string;
inpaintBundle?: InpaintBundle; inpaintBundle?: InpaintBundle;
inpaintCandidate?: GenerationCandidate; inpaintCandidate?: GenerationCandidate;
signal?: AbortSignal;
}) { }) {
return requestGeneration({ return requestGeneration({
architecture: options.settings.architecture, architecture: options.settings.architecture,
@@ -162,7 +167,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,
}); }, options.signal);
} }
function resolveInpaintRequest(inpaintBundle: InpaintBundle | undefined, inpaintCandidate: GenerationCandidate | undefined, settings: GenerateSettings) { function resolveInpaintRequest(inpaintBundle: InpaintBundle | undefined, inpaintCandidate: GenerationCandidate | undefined, settings: GenerateSettings) {

View File

@@ -39,6 +39,25 @@ describe("generation workflow", () => {
expect(app.store.getState().document.artboards[0]?.layers.some((layer) => layer.id === "layer-new")).toBe(true); expect(app.store.getState().document.artboards[0]?.layers.some((layer) => layer.id === "layer-new")).toBe(true);
expect(app.store.getState().editor.generation.candidates).toHaveLength(0); expect(app.store.getState().editor.generation.candidates).toHaveLength(0);
}); });
test("cancels the active operation and records a cancelled job", async () => {
const app = createTestApp();
let receivedSignal: AbortSignal | undefined;
const workflow = createGenerationWorkflow(app.store, dependencies({
runGenerate: async (options) => {
receivedSignal = options.signal;
await new Promise<void>((_resolve, reject) => options.signal?.addEventListener("abort", () => reject(new DOMException("Cancelled", "AbortError")), { once: true }));
},
}));
const running = workflow.generate();
await Promise.resolve();
workflow.cancel();
await running;
expect(receivedSignal?.aborted).toBe(true);
expect(app.store.getState().editor.generation.jobs[0]?.status).toBe("cancelled");
});
}); });
function dependencies(overrides: Partial<GenerationWorkflowDependencies>): GenerationWorkflowDependencies { function dependencies(overrides: Partial<GenerationWorkflowDependencies>): GenerationWorkflowDependencies {

View File

@@ -30,8 +30,17 @@ const defaultDependencies: GenerationWorkflowDependencies = {
}; };
export function createGenerationWorkflow(store: AppStore, dependencies: GenerationWorkflowDependencies = defaultDependencies) { export function createGenerationWorkflow(store: AppStore, dependencies: GenerationWorkflowDependencies = defaultDependencies) {
const job = (kind: GenerationJobKind, label: string, task: () => Promise<void>) => let activeController: AbortController | undefined;
runGenerationJob({ kind, label, dispatch: store.dispatch, task }); const job = async (kind: GenerationJobKind, label: string, task: (signal: AbortSignal) => Promise<void>) => {
if (store.getState().editor.generation.jobs.some((candidate) => candidate.status === "running")) return;
const controller = new AbortController();
activeController = controller;
try {
await runGenerationJob({ kind, label, dispatch: store.dispatch, signal: controller.signal, task });
} finally {
if (activeController === controller) activeController = undefined;
}
};
return { return {
precondition: () => { precondition: () => {
@@ -41,7 +50,7 @@ export function createGenerationWorkflow(store: AppStore, dependencies: Generati
loadResources: () => dependencies.loadGenerationResources(store), loadResources: () => dependencies.loadGenerationResources(store),
generate: () => job("generate", "Generating", async () => { generate: () => job("generate", "Generating", async (signal) => {
const state = store.getState(); const state = store.getState();
await dependencies.runGenerate({ await dependencies.runGenerate({
document: state.document, document: state.document,
@@ -49,15 +58,16 @@ export function createGenerationWorkflow(store: AppStore, dependencies: Generati
viewport: state.editor.viewport, viewport: state.editor.viewport,
settings: state.editor.tools.generate, settings: state.editor.tools.generate,
dispatch: store.dispatch, dispatch: store.dispatch,
signal,
}); });
}), }),
regenerate: (candidateId: string, settings?: GenerateSettings, label = "Regenerate") => regenerate: (candidateId: string, settings?: GenerateSettings, label = "Regenerate") =>
job("regenerate", label, async () => { job("regenerate", label, async (signal) => {
const candidate = findCandidate(store, candidateId); const candidate = findCandidate(store, candidateId);
const nextSettings = settings ?? candidate.settings; const nextSettings = settings ?? candidate.settings;
store.dispatch(commandIds.toolSetGenerateSettings, nextSettings); store.dispatch(commandIds.toolSetGenerateSettings, nextSettings);
await dependencies.runGenerateFromCandidate({ candidate, settings: nextSettings, dispatch: store.dispatch }); await dependencies.runGenerateFromCandidate({ candidate, settings: nextSettings, dispatch: store.dispatch, signal });
}), }),
applyCandidateAsLayer: (candidateId: string) => { applyCandidateAsLayer: (candidateId: string) => {
@@ -115,6 +125,8 @@ export function createGenerationWorkflow(store: AppStore, dependencies: Generati
const source = await dependencies.createMaskedPixelReplacementSource(store.getState().document, candidate); const source = await dependencies.createMaskedPixelReplacementSource(store.getState().document, candidate);
store.dispatch(commandIds.generationReplaceCandidatePixels, { candidateId, source, mimeType: "image/png" }); store.dispatch(commandIds.generationReplaceCandidatePixels, { candidateId, source, mimeType: "image/png" });
}), }),
cancel: () => activeController?.abort(),
}; };
} }

View File

@@ -1,5 +1,5 @@
{ {
"name": "bun-react-template", "name": "image-studio",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",

View File

@@ -8,8 +8,16 @@ export type BrowserImageFile = {
export async function decodeBrowserImageFile(file: File): Promise<BrowserImageFile | undefined> { export async function decodeBrowserImageFile(file: File): Promise<BrowserImageFile | undefined> {
if (!file.type.startsWith("image/")) return undefined; if (!file.type.startsWith("image/")) return undefined;
if (file.size > 128 * 1024 * 1024) throw new Error("Image file is too large. The import limit is 128 MB.");
const objectUrl = URL.createObjectURL(file);
let intrinsicSize: { w: number; h: number };
try {
intrinsicSize = await loadImageSize(objectUrl);
assertCanvasRasterSize(intrinsicSize.w, intrinsicSize.h, "Imported image");
} finally {
URL.revokeObjectURL(objectUrl);
}
const source = await fileToDataUrl(file); const source = await fileToDataUrl(file);
const intrinsicSize = await loadImageSize(source);
return { name: file.name, mimeType: file.type, source, intrinsicSize, release: () => undefined }; return { name: file.name, mimeType: file.type, source, intrinsicSize, release: () => undefined };
} }
@@ -30,3 +38,4 @@ function loadImageSize(source: string): Promise<{ w: number; h: number }> {
image.src = source; image.src = source;
}); });
} }
import { assertCanvasRasterSize } from "./rasterLimits";

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { cropMaskValuesToRgba, expandRectWithinBounds, invertMaskValues } from "./maskRaster"; import { blurMaskValues, cropMaskValuesToRgba, dilateMaskValues, erodeMaskValues, expandRectWithinBounds, invertMaskValues } from "./maskRaster";
describe("mask raster utilities", () => { describe("mask raster utilities", () => {
test("exports the normalized drawn mask without filling the whole crop", () => { test("exports the normalized drawn mask without filling the whole crop", () => {
@@ -28,6 +28,13 @@ describe("mask raster utilities", () => {
h: 64, h: 64,
}); });
}); });
test("uses separable mask kernels without changing square-kernel semantics", () => {
const values = new Uint8ClampedArray([0, 0, 0, 0, 255, 0, 0, 0, 0]);
expect([...dilateMaskValues(values, 3, 3, 1)]).toEqual(new Array(9).fill(255));
expect([...erodeMaskValues(new Uint8ClampedArray(new Array(9).fill(255)), 3, 3, 1)]).toEqual(new Array(9).fill(255));
expect([...blurMaskValues(values, 3, 3, 1)]).toEqual(new Array(9).fill(28));
});
}); });
function activeRedPixels(rgba: Uint8ClampedArray) { function activeRedPixels(rgba: Uint8ClampedArray) {

View File

@@ -1,4 +1,5 @@
import type { Rect } from "@core/geometry"; import type { Rect } from "@core/geometry";
import { assertCanvasRasterSize, assertProcessingRasterSize } from "./rasterLimits";
export type MaskFill = "white" | "black" | "clear"; export type MaskFill = "white" | "black" | "clear";
@@ -32,6 +33,7 @@ export type NormalizedMaskOptions = {
}; };
export async function createSolidMaskSource(width: number, height: number, fill: MaskFill): Promise<string> { export async function createSolidMaskSource(width: number, height: number, fill: MaskFill): Promise<string> {
assertProcessingRasterSize(width, height, "Mask");
const canvas = createCanvas(width, height); const canvas = createCanvas(width, height);
const context = require2dContext(canvas); const context = require2dContext(canvas);
context.clearRect(0, 0, canvas.width, canvas.height); context.clearRect(0, 0, canvas.width, canvas.height);
@@ -48,6 +50,7 @@ export async function createSolidMaskSource(width: number, height: number, fill:
} }
export async function applyMaskRasterOperation(source: string, width: number, height: number, operation: MaskRasterOperation): Promise<string> { export async function applyMaskRasterOperation(source: string, width: number, height: number, operation: MaskRasterOperation): Promise<string> {
assertProcessingRasterSize(width, height, "Mask");
if (operation.type === "fill") return createSolidMaskSource(width, height, operation.fill); if (operation.type === "fill") return createSolidMaskSource(width, height, operation.fill);
const mask = await loadMaskValues(source, width, height); const mask = await loadMaskValues(source, width, height);
@@ -56,6 +59,7 @@ export async function applyMaskRasterOperation(source: string, width: number, he
} }
export async function analyzeMaskSource(source: string, width: number, height: number): Promise<MaskAnalysis> { export async function analyzeMaskSource(source: string, width: number, height: number): Promise<MaskAnalysis> {
assertProcessingRasterSize(width, height, "Mask");
const mask = await loadMaskValues(source, width, height); const mask = await loadMaskValues(source, width, height);
const revealed = analyzeValues(mask.values, mask.width, mask.height, false); const revealed = analyzeValues(mask.values, mask.width, mask.height, false);
const hiddenValues = invertMaskValues(mask.values); const hiddenValues = invertMaskValues(mask.values);
@@ -74,6 +78,7 @@ export async function analyzeMaskSource(source: string, width: number, height: n
} }
export async function createNormalizedMaskSource(source: string, width: number, height: number, options: NormalizedMaskOptions): Promise<{ source: string; values: Uint8ClampedArray; bounds?: Rect }> { export async function createNormalizedMaskSource(source: string, width: number, height: number, options: NormalizedMaskOptions): Promise<{ source: string; values: Uint8ClampedArray; bounds?: Rect }> {
assertProcessingRasterSize(width, height, "Mask");
const mask = await loadMaskValues(source, width, height); const mask = await loadMaskValues(source, width, height);
let values = options.polarity === "hidden" ? invertMaskValues(mask.values) : new Uint8ClampedArray(mask.values); let values = options.polarity === "hidden" ? invertMaskValues(mask.values) : new Uint8ClampedArray(mask.values);
@@ -94,6 +99,7 @@ export async function createNormalizedMaskSource(source: string, width: number,
export async function loadImageCanvas(source: string, width?: number, height?: number): Promise<HTMLCanvasElement> { export async function loadImageCanvas(source: string, width?: number, height?: number): Promise<HTMLCanvasElement> {
const image = await loadImage(source); const image = await loadImage(source);
assertCanvasRasterSize(width ?? image.naturalWidth, height ?? image.naturalHeight);
const canvas = createCanvas(width ?? image.naturalWidth, height ?? image.naturalHeight); const canvas = createCanvas(width ?? image.naturalWidth, height ?? image.naturalHeight);
const context = require2dContext(canvas); const context = require2dContext(canvas);
context.clearRect(0, 0, canvas.width, canvas.height); context.clearRect(0, 0, canvas.width, canvas.height);
@@ -108,6 +114,7 @@ export async function imageSourceToPngDataUrl(source: string): Promise<string> {
} }
export function cropCanvas(sourceCanvas: HTMLCanvasElement, crop: Rect, outputWidth = crop.w, outputHeight = crop.h): string { export function cropCanvas(sourceCanvas: HTMLCanvasElement, crop: Rect, outputWidth = crop.w, outputHeight = crop.h): string {
assertProcessingRasterSize(outputWidth, outputHeight, "Crop");
const canvas = createCanvas(outputWidth, outputHeight); const canvas = createCanvas(outputWidth, outputHeight);
const context = require2dContext(canvas); const context = require2dContext(canvas);
context.clearRect(0, 0, outputWidth, outputHeight); context.clearRect(0, 0, outputWidth, outputHeight);
@@ -116,6 +123,7 @@ export function cropCanvas(sourceCanvas: HTMLCanvasElement, crop: Rect, outputWi
} }
export function cropMaskValuesToDataUrl(values: Uint8ClampedArray, width: number, height: number, crop: Rect, outputWidth = crop.w, outputHeight = crop.h): string { export function cropMaskValuesToDataUrl(values: Uint8ClampedArray, width: number, height: number, crop: Rect, outputWidth = crop.w, outputHeight = crop.h): string {
assertProcessingRasterSize(outputWidth, outputHeight, "Mask crop");
const canvas = createCanvas(outputWidth, outputHeight); const canvas = createCanvas(outputWidth, outputHeight);
const context = require2dContext(canvas); const context = require2dContext(canvas);
const imageData = context.createImageData(outputWidth, outputHeight); const imageData = context.createImageData(outputWidth, outputHeight);
@@ -203,52 +211,53 @@ export function invertMaskValues(values: Uint8ClampedArray): Uint8ClampedArray {
export function erodeMaskValues(values: Uint8ClampedArray, width: number, height: number, radius: number): Uint8ClampedArray { export function erodeMaskValues(values: Uint8ClampedArray, width: number, height: number, radius: number): Uint8ClampedArray {
const safeRadius = Math.round(clampNumber(radius, 0, 256)); const safeRadius = Math.round(clampNumber(radius, 0, 256));
if (safeRadius <= 0) return new Uint8ClampedArray(values); if (safeRadius <= 0) return new Uint8ClampedArray(values);
const next = new Uint8ClampedArray(values.length); return separableExtrema(values, width, height, safeRadius, Math.min, 255);
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
let value = 255;
for (let oy = -safeRadius; oy <= safeRadius; oy += 1) {
for (let ox = -safeRadius; ox <= safeRadius; ox += 1) value = Math.min(value, values[clampInt(y + oy, 0, height - 1) * width + clampInt(x + ox, 0, width - 1)] ?? 0);
}
next[y * width + x] = value;
}
}
return next;
} }
export function dilateMaskValues(values: Uint8ClampedArray, width: number, height: number, radius: number): Uint8ClampedArray { export function dilateMaskValues(values: Uint8ClampedArray, width: number, height: number, radius: number): Uint8ClampedArray {
const safeRadius = Math.round(clampNumber(radius, 0, 256)); const safeRadius = Math.round(clampNumber(radius, 0, 256));
if (safeRadius <= 0) return new Uint8ClampedArray(values); if (safeRadius <= 0) return new Uint8ClampedArray(values);
const next = new Uint8ClampedArray(values.length); return separableExtrema(values, width, height, safeRadius, Math.max, 0);
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
let value = 0;
for (let oy = -safeRadius; oy <= safeRadius; oy += 1) {
for (let ox = -safeRadius; ox <= safeRadius; ox += 1) value = Math.max(value, values[clampInt(y + oy, 0, height - 1) * width + clampInt(x + ox, 0, width - 1)] ?? 0);
}
next[y * width + x] = value;
}
}
return next;
} }
export function blurMaskValues(values: Uint8ClampedArray, width: number, height: number, radius: number): Uint8ClampedArray { export function blurMaskValues(values: Uint8ClampedArray, width: number, height: number, radius: number): Uint8ClampedArray {
const safeRadius = Math.round(clampNumber(radius, 0, 256)); const safeRadius = Math.round(clampNumber(radius, 0, 256));
if (safeRadius <= 0) return new Uint8ClampedArray(values); if (safeRadius <= 0) return new Uint8ClampedArray(values);
const horizontal = new Float64Array(values.length);
const next = new Uint8ClampedArray(values.length); const next = new Uint8ClampedArray(values.length);
const span = safeRadius * 2 + 1;
for (let y = 0; y < height; y += 1) { for (let y = 0; y < height; y += 1) {
let total = 0;
for (let ox = -safeRadius; ox <= safeRadius; ox += 1) total += values[y * width + clampInt(ox, 0, width - 1)] ?? 0;
for (let x = 0; x < width; x += 1) { for (let x = 0; x < width; x += 1) {
let total = 0; horizontal[y * width + x] = total / span;
let count = 0; total += (values[y * width + clampInt(x + safeRadius + 1, 0, width - 1)] ?? 0) - (values[y * width + clampInt(x - safeRadius, 0, width - 1)] ?? 0);
for (let oy = -safeRadius; oy <= safeRadius; oy += 1) {
for (let ox = -safeRadius; ox <= safeRadius; ox += 1) {
total += values[clampInt(y + oy, 0, height - 1) * width + clampInt(x + ox, 0, width - 1)] ?? 0;
count += 1;
}
}
next[y * width + x] = Math.round(total / count);
} }
} }
for (let x = 0; x < width; x += 1) {
let total = 0;
for (let oy = -safeRadius; oy <= safeRadius; oy += 1) total += horizontal[clampInt(oy, 0, height - 1) * width + x] ?? 0;
for (let y = 0; y < height; y += 1) {
next[y * width + x] = Math.round(total / span);
total += (horizontal[clampInt(y + safeRadius + 1, 0, height - 1) * width + x] ?? 0) - (horizontal[clampInt(y - safeRadius, 0, height - 1) * width + x] ?? 0);
}
}
return next;
}
function separableExtrema(values: Uint8ClampedArray, width: number, height: number, radius: number, combine: (a: number, b: number) => number, initial: number) {
const horizontal = new Uint8ClampedArray(values.length);
const next = new Uint8ClampedArray(values.length);
for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) {
let value = initial;
for (let offset = -radius; offset <= radius; offset += 1) value = combine(value, values[y * width + clampInt(x + offset, 0, width - 1)] ?? 0);
horizontal[y * width + x] = value;
}
for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) {
let value = initial;
for (let offset = -radius; offset <= radius; offset += 1) value = combine(value, horizontal[clampInt(y + offset, 0, height - 1) * width + x] ?? 0);
next[y * width + x] = value;
}
return next; return next;
} }

View File

@@ -0,0 +1,20 @@
const maxCanvasDimension = 16_384;
const maxCanvasPixels = 64 * 1024 * 1024;
const maxProcessingPixels = 24 * 1024 * 1024;
export function assertCanvasRasterSize(width: number, height: number, label = "Image") {
assertRasterSize(width, height, maxCanvasPixels, label);
}
export function assertProcessingRasterSize(width: number, height: number, label = "Image") {
assertRasterSize(width, height, maxProcessingPixels, label);
}
function assertRasterSize(width: number, height: number, maxPixels: number, label: string) {
const w = Math.round(width);
const h = Math.round(height);
if (!Number.isSafeInteger(w) || !Number.isSafeInteger(h) || w <= 0 || h <= 0) throw new Error(`${label} dimensions are invalid.`);
if (w > maxCanvasDimension || h > maxCanvasDimension || w * h > maxPixels) {
throw new Error(`${label} is too large (${w} x ${h}). Maximum processing size is ${maxCanvasDimension} px per side and ${Math.floor(maxPixels / 1_000_000)} megapixels.`);
}
}

View File

@@ -4,8 +4,8 @@ export async function fetchGenerationOptions(): Promise<unknown> {
return response.json() as Promise<unknown>; return response.json() as Promise<unknown>;
} }
export async function requestGeneration(body: unknown): Promise<{ source: string; mimeType: string }> { export async function requestGeneration(body: unknown, signal?: AbortSignal): Promise<{ source: string; mimeType: string }> {
const response = await fetch("/api/comfy/generate", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); const response = await fetch("/api/comfy/generate", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal });
if (!response.ok) throw new Error(await response.text()); if (!response.ok) throw new Error(await response.text());
return response.json() as Promise<{ source: string; mimeType: string }>; return response.json() as Promise<{ source: string; mimeType: string }>;
} }

View File

@@ -3,7 +3,7 @@ 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 { getLayerMask } from "@core/layer-mask-utils";
import { createDocumentReadIndex, forEachLayerBackToFront, resolveIndexedLayerBounds, type DocumentReadIndex } from "@editor/document-indexes"; import { createDocumentReadIndex, resolveIndexedLayerBounds, type DocumentReadIndex } from "@editor/document-indexes";
import type { EditorState, GenerationCandidate, MaskViewMode, ViewportState } from "@editor/state"; import type { EditorState, GenerationCandidate, MaskViewMode, ViewportState } from "@editor/state";
import { clearScreenRect } from "./clear-rect"; import { clearScreenRect } from "./clear-rect";
import type { ImageTextureRenderer } from "./image-textures"; import type { ImageTextureRenderer } from "./image-textures";
@@ -24,7 +24,7 @@ export function renderLayers(context: WebGlRendererContext, document: ImageDocum
if (!artboard.visible) continue; if (!artboard.visible) continue;
const clipRect = documentRectToScreenRect(context.canvas, artboard.bounds, editor.viewport); const clipRect = documentRectToScreenRect(context.canvas, artboard.bounds, editor.viewport);
const maskLayerIds = documentIndex.maskLayerIdsByArtboardId.get(artboard.id) ?? emptyLayerIds; const maskLayerIds = documentIndex.maskLayerIdsByArtboardId.get(artboard.id) ?? emptyLayerIds;
forEachLayerBackToFront(artboard.layers, (layer) => renderLayer(context, documentIndex, editor, layer, imageTextureRenderer, clipRect, maskLayerIds, 1)); renderLayerTree(context, documentIndex, editor, artboard.layers, imageTextureRenderer, clipRect, maskLayerIds);
if (generationCandidate?.placement.artboardId === artboard.id) renderGenerationCandidatePreview(context, editor, generationCandidate, imageTextureRenderer, clipRect); if (generationCandidate?.placement.artboardId === artboard.id) renderGenerationCandidatePreview(context, editor, generationCandidate, imageTextureRenderer, clipRect);
} }
} }
@@ -33,31 +33,46 @@ export function generationCandidatePreviewAssets(editor: EditorState): Asset[] {
return editor.generation.candidates.map((candidate) => generationCandidateAsset(candidate)); return editor.generation.candidates.map((candidate) => generationCandidateAsset(candidate));
} }
function renderLayer( function renderLayerTree(
context: WebGlRendererContext, context: WebGlRendererContext,
documentIndex: DocumentReadIndex, documentIndex: DocumentReadIndex,
editor: EditorState, editor: EditorState,
layer: Layer, layers: readonly Layer[],
imageTextureRenderer: ImageTextureRenderer, imageTextureRenderer: ImageTextureRenderer,
clipRect: ScreenRect, clipRect: ScreenRect,
maskLayerIds: ReadonlySet<string>, maskLayerIds: ReadonlySet<string>,
inheritedOpacity: number, ) {
const stack: Array<{ layer: Layer; clipRect: ScreenRect; inheritedOpacity: number }> = [];
for (const layer of layers) stack.push({ layer, clipRect, inheritedOpacity: 1 });
while (stack.length > 0) {
const frame = stack.pop();
if (!frame) continue;
const { layer } = frame;
if (!layer.visible || maskLayerIds.has(layer.id)) continue;
const effectiveOpacity = frame.inheritedOpacity * layer.opacity;
if (effectiveOpacity <= 0) continue;
const effectiveClipRect = resolveLayerClipRect(context, documentIndex, editor.viewport, layer, frame.clipRect);
if (!effectiveClipRect) continue;
if (layer.type === "group") {
for (const child of layer.children) stack.push({ layer: child, clipRect: effectiveClipRect, inheritedOpacity: effectiveOpacity });
continue;
}
renderLeafLayer(context, documentIndex, editor, layer, imageTextureRenderer, effectiveClipRect, effectiveOpacity);
}
}
function renderLeafLayer(
context: WebGlRendererContext,
documentIndex: DocumentReadIndex,
editor: EditorState,
layer: Exclude<Layer, { type: "group" }>,
imageTextureRenderer: ImageTextureRenderer,
effectiveClipRect: ScreenRect,
effectiveOpacity: number,
) { ) {
const editingMaskLayer = editor.maskEdit?.maskLayerId === layer.id; const editingMaskLayer = editor.maskEdit?.maskLayerId === layer.id;
const maskViewMode = editor.maskEdit?.viewMode ?? "composite"; const maskViewMode = editor.maskEdit?.viewMode ?? "composite";
const isolatedMaskView = isIsolatedMaskView(maskViewMode); const isolatedMaskView = isIsolatedMaskView(maskViewMode);
if (!layer.visible || maskLayerIds.has(layer.id)) return;
const effectiveOpacity = inheritedOpacity * layer.opacity;
if (effectiveOpacity <= 0) return;
const effectiveClipRect = resolveLayerClipRect(context, documentIndex, editor.viewport, layer, clipRect);
if (!effectiveClipRect) return;
if (layer.type === "group") {
forEachLayerBackToFront(layer.children, (child) => renderLayer(context, documentIndex, editor, child, imageTextureRenderer, effectiveClipRect, maskLayerIds, effectiveOpacity));
return;
}
if (isolatedMaskView && layer.id !== editor.maskEdit?.targetLayerId) return; if (isolatedMaskView && layer.id !== editor.maskEdit?.targetLayerId) return;
const bounds = resolveIndexedLayerBounds(documentIndex, layer); const bounds = resolveIndexedLayerBounds(documentIndex, layer);

View File

@@ -4,7 +4,7 @@ export async function handleComfyApi(request: Request) {
try { try {
const url = new URL(request.url); const url = new URL(request.url);
if (url.pathname === "/api/comfy/models" && request.method === "GET") return json(await listGenerationOptions()); 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)); if (url.pathname === "/api/comfy/generate" && request.method === "POST") return json(await generate(await request.json() as ComfyGenerateRequest, request.signal));
return new Response("Not found", { status: 404 }); return new Response("Not found", { status: 404 });
} catch (error) { } catch (error) {
return new Response(error instanceof Error ? error.message : "ComfyUI request failed", { status: 500 }); return new Response(error instanceof Error ? error.message : "ComfyUI request failed", { status: 500 });

View File

@@ -111,15 +111,15 @@ async function listCheckpointModels() {
return (await listGenerationOptions()).models; return (await listGenerationOptions()).models;
} }
export async function generate(request: ComfyGenerateRequest) { export async function generate(request: ComfyGenerateRequest, signal?: AbortSignal) {
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`);
if (!request.model || request.model === "auto") request.model = await defaultModelForArchitecture(architecture); if (!request.model || request.model === "auto") request.model = await defaultModelForArchitecture(architecture);
const clientId = crypto.randomUUID(); const clientId = crypto.randomUUID();
const uploaded = request.inputImage ? await uploadDataUrl(request.inputImage, `image-studio-${crypto.randomUUID()}.png`) : undefined; const uploaded = request.inputImage ? await uploadDataUrl(request.inputImage, `image-studio-${crypto.randomUUID()}.png`, signal) : undefined;
const mask = request.maskImage ? await uploadDataUrl(request.maskImage, `image-studio-mask-${crypto.randomUUID()}.png`) : undefined; const mask = request.maskImage ? await uploadDataUrl(request.maskImage, `image-studio-mask-${crypto.randomUUID()}.png`, signal) : undefined;
if (request.mode === "inpaint" && (!uploaded || !mask)) throw new Error("Inpaint requires normalized input and mask images"); if (request.mode === "inpaint" && (!uploaded || !mask)) throw new Error("Inpaint requires normalized input and mask images");
const prompt = buildComfyWorkflow({ ...request, architecture, inputImage: uploaded, maskImage: mask }); const prompt = buildComfyWorkflow({ ...request, architecture, inputImage: uploaded, maskImage: mask });
@@ -127,6 +127,7 @@ export async function generate(request: ComfyGenerateRequest) {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ client_id: clientId, prompt }), body: JSON.stringify({ client_id: clientId, prompt }),
signal,
}); });
if (!queued.ok) throw new Error(`ComfyUI prompt failed: ${queued.status} ${await queued.text()}`); if (!queued.ok) throw new Error(`ComfyUI prompt failed: ${queued.status} ${await queued.text()}`);
const queuedBody = await queued.json() as { prompt_id?: string; node_errors?: unknown }; const queuedBody = await queued.json() as { prompt_id?: string; node_errors?: unknown };
@@ -134,19 +135,25 @@ export async function generate(request: ComfyGenerateRequest) {
if (nodeError) throw new Error(`ComfyUI rejected the workflow: ${nodeError}`); if (nodeError) throw new Error(`ComfyUI rejected the workflow: ${nodeError}`);
if (!queuedBody.prompt_id) throw new Error("ComfyUI did not return a prompt id"); if (!queuedBody.prompt_id) throw new Error("ComfyUI did not return a prompt id");
const prompt_id = queuedBody.prompt_id; const prompt_id = queuedBody.prompt_id;
const history = await waitForHistory(prompt_id); let history: unknown;
try {
history = await waitForHistory(prompt_id, signal);
} catch (error) {
if (signal?.aborted) await cancelComfyPrompt(prompt_id);
throw error;
}
const historyError = historyErrorMessage(history); const historyError = historyErrorMessage(history);
if (historyError) throw new Error(`ComfyUI generation failed: ${historyError}`); if (historyError) throw new Error(`ComfyUI generation failed: ${historyError}`);
const image = selectGeneratedOutputImage(history); const image = selectGeneratedOutputImage(history);
if (!image) throw new Error("ComfyUI did not return an image"); if (!image) throw new Error("ComfyUI did not return an image");
const imageResponse = await fetch(`${comfyBaseUrl}/view?${new URLSearchParams({ filename: image.filename, subfolder: image.subfolder ?? "", type: image.type ?? "output" })}`); const imageResponse = await fetch(`${comfyBaseUrl}/view?${new URLSearchParams({ filename: image.filename, subfolder: image.subfolder ?? "", type: image.type ?? "output" })}`, { signal });
if (!imageResponse.ok) throw new Error(`ComfyUI image fetch failed: ${imageResponse.status}`); if (!imageResponse.ok) throw new Error(`ComfyUI image fetch failed: ${imageResponse.status}`);
const bytes = Buffer.from(await imageResponse.arrayBuffer()); const bytes = Buffer.from(await imageResponse.arrayBuffer());
return { source: `data:image/png;base64,${bytes.toString("base64")}`, mimeType: "image/png" }; return { source: `data:image/png;base64,${bytes.toString("base64")}`, mimeType: "image/png" };
} }
async function uploadDataUrl(dataUrl: string, filename: string) { async function uploadDataUrl(dataUrl: string, filename: string, signal?: AbortSignal) {
const match = /^data:([^;]+);base64,(.+)$/.exec(dataUrl); const match = /^data:([^;]+);base64,(.+)$/.exec(dataUrl);
if (!match) throw new Error("Expected a base64 data URL image"); if (!match) throw new Error("Expected a base64 data URL image");
const mimeType = match[1] ?? "image/png"; const mimeType = match[1] ?? "image/png";
@@ -154,27 +161,53 @@ async function uploadDataUrl(dataUrl: string, filename: string) {
const form = new FormData(); const form = new FormData();
form.append("image", new File([new Uint8Array(Buffer.from(base64, "base64"))], filename, { type: mimeType })); form.append("image", new File([new Uint8Array(Buffer.from(base64, "base64"))], filename, { type: mimeType }));
form.append("overwrite", "true"); form.append("overwrite", "true");
const response = await fetch(`${comfyBaseUrl}/upload/image`, { method: "POST", body: form }); const response = await fetch(`${comfyBaseUrl}/upload/image`, { method: "POST", body: form, signal });
if (!response.ok) throw new Error(`ComfyUI upload failed: ${response.status}`); if (!response.ok) throw new Error(`ComfyUI upload failed: ${response.status}`);
const uploaded = await response.json() as { name: string }; const uploaded = await response.json() as { name: string };
return uploaded.name; return uploaded.name;
} }
async function waitForHistory(promptId: string) { async function waitForHistory(promptId: string, signal?: AbortSignal) {
const startedAt = Date.now(); const startedAt = Date.now();
let attempts = 0; let attempts = 0;
while (Date.now() - startedAt < comfyHistoryTimeoutMs) { while (Date.now() - startedAt < comfyHistoryTimeoutMs) {
const response = await fetch(`${comfyBaseUrl}/history/${promptId}`); if (signal?.aborted) {
await cancelComfyPrompt(promptId);
throw signal.reason ?? new DOMException("Generation cancelled", "AbortError");
}
const response = await fetch(`${comfyBaseUrl}/history/${promptId}`, { signal });
attempts += 1; attempts += 1;
if (response.ok) { if (response.ok) {
const history = await response.json() as Record<string, unknown>; const history = await response.json() as Record<string, unknown>;
if (history[promptId]) return history[promptId]; if (history[promptId]) return history[promptId];
} }
await Bun.sleep(comfyHistoryPollIntervalMs); await abortableSleep(comfyHistoryPollIntervalMs, signal);
} }
throw new Error(`Timed out waiting for ComfyUI prompt ${promptId} after ${Math.round(comfyHistoryTimeoutMs / 1000)} seconds and ${attempts} checks`); throw new Error(`Timed out waiting for ComfyUI prompt ${promptId} after ${Math.round(comfyHistoryTimeoutMs / 1000)} seconds and ${attempts} checks`);
} }
async function cancelComfyPrompt(promptId: string) {
const queueResponse = await fetch(`${comfyBaseUrl}/queue`).catch(() => undefined);
const queue = queueResponse?.ok ? await queueResponse.json() as { queue_running?: unknown[][]; queue_pending?: unknown[][] } : undefined;
const isRunning = queue?.queue_running?.some((entry) => entry.includes(promptId)) ?? false;
const isPending = queue?.queue_pending?.some((entry) => entry.includes(promptId)) ?? true;
const requests: Promise<unknown>[] = [];
if (isPending) requests.push(fetch(`${comfyBaseUrl}/queue`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ delete: [promptId] }) }));
if (isRunning) requests.push(fetch(`${comfyBaseUrl}/interrupt`, { method: "POST" }));
await Promise.allSettled(requests);
}
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
if (!signal) return Bun.sleep(ms);
return new Promise((resolve, reject) => {
const timeout = setTimeout(resolve, ms);
signal.addEventListener("abort", () => {
clearTimeout(timeout);
reject(signal.reason ?? new DOMException("Generation cancelled", "AbortError"));
}, { once: true });
});
}
export function selectGeneratedOutputImage(history: unknown): { filename: string; subfolder?: string; type?: string } | undefined { export function selectGeneratedOutputImage(history: unknown): { filename: string; subfolder?: string; type?: string } | undefined {
const outputs = (history as { outputs?: Record<string, { images?: { filename: string; subfolder?: string; type?: string }[] }> }).outputs ?? {}; const outputs = (history as { outputs?: Record<string, { images?: { filename: string; subfolder?: string; type?: string }[] }> }).outputs ?? {};
const saveImageOutput = outputs["8"]?.images?.find(isGeneratedImage); const saveImageOutput = outputs["8"]?.images?.find(isGeneratedImage);

View File

@@ -15,7 +15,6 @@ import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/t
import type { AppState } from "@editor/state"; import type { AppState } from "@editor/state";
import { handleCommandPaletteKey, handleDeleteSelectionKey, handleHistoryKey, handleOperationKey, handleToolKey, keybindEventFromKeyboardEvent } from "@input/index"; import { handleCommandPaletteKey, handleDeleteSelectionKey, handleHistoryKey, handleOperationKey, handleToolKey, keybindEventFromKeyboardEvent } from "@input/index";
import { shallowEqual, useAppState } from "./useAppState"; import { shallowEqual, useAppState } from "./useAppState";
import { downloadArtboardPng } from "@operations/export/downloadArtboard";
import { useImageImport } from "./useImageImport"; import { useImageImport } from "./useImageImport";
import { useViewportActivityIsland } from "./useViewportActivityIsland"; import { useViewportActivityIsland } from "./useViewportActivityIsland";
import { useProjectLifecycle } from "./useProjectLifecycle"; import { useProjectLifecycle } from "./useProjectLifecycle";
@@ -37,13 +36,17 @@ export function App({ app }: AppProps) {
const chromaKeyOpen = workspace.panel === "chromaKey"; const chromaKeyOpen = workspace.panel === "chromaKey";
const layersOpen = workspace.panel === "layers"; const layersOpen = workspace.panel === "layers";
useEffect(() => {
window.document.title = `${document.name} — Image Studio`;
}, [document.name]);
useEffect(() => { useEffect(() => {
if (generateOpen) void app.workflows.generation.loadResources(); if (generateOpen) void app.workflows.generation.loadResources();
}, [app.workflows.generation, generateOpen]); }, [app.workflows.generation, generateOpen]);
const openGenerate = useCallback(() => { const openGenerate = useCallback(() => {
app.store.dispatch(commandIds.workspaceSetPanel, { panel: "generate" }); app.actions.document.openGenerate();
}, [app.store]); }, [app.actions.document]);
const closeGenerate = useCallback(() => { const closeGenerate = useCallback(() => {
app.store.dispatch(commandIds.workspaceSetPanel, { panel: "none" }); app.store.dispatch(commandIds.workspaceSetPanel, { panel: "none" });
@@ -90,7 +93,7 @@ export function App({ app }: AppProps) {
if (editableTarget) return; if (editableTarget) return;
const historyConsumed = handleHistoryKey({ event: keybindEvent, dispatch: app.store.dispatch }); const historyConsumed = handleHistoryKey({ event: keybindEvent, actions: app.actions.document });
if (historyConsumed) { if (historyConsumed) {
event.preventDefault(); event.preventDefault();
return; return;
@@ -134,7 +137,7 @@ export function App({ app }: AppProps) {
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [app.store, toggleLayers]); }, [app.actions.document, app.store, toggleLayers]);
const transformBounds = transformTarget ? resolveTransformTargetBounds(document, transformTarget) : undefined; const transformBounds = transformTarget ? resolveTransformTargetBounds(document, transformTarget) : undefined;
const brushHint = brushUnavailableHint(document, { selection, tools, maskEdit }); const brushHint = brushUnavailableHint(document, { selection, tools, maskEdit });
@@ -156,28 +159,33 @@ export function App({ app }: AppProps) {
openGenerate={openGenerate} openGenerate={openGenerate}
openLayers={openLayers} openLayers={openLayers}
closeLayers={closeLayers} closeLayers={closeLayers}
documentActions={app.actions.document}
/> />
<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-between gap-4 rounded-full px-4 text-white backdrop-blur-xl">
<div className="min-w-0 pl-2">
<p className="text-[0.65rem] font-semibold uppercase tracking-[0.22em] text-white/45">Image Studio</p>
<h1 className="truncate text-sm font-semibold text-white/90" title={document.name}>{document.name}</h1>
</div>
<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"> <button type="button" className="rounded-full border-0 bg-transparent p-0" onClick={openGenerate} aria-label="Open generation activity" title="Open generation activity">
<GenerationJobStatus generation={generation} compact /> <GenerationJobStatus generation={generation} compact />
</button> </button>
<button type="button" className={topBarButtonClass()} onClick={imageImport.openFilePicker}> <button type="button" className={topBarButtonClass()} onClick={imageImport.openFilePicker} aria-label="Open image" title="Open image (Cmd/Ctrl+O)">
<FolderOpen size={24} /> <FolderOpen size={24} />
</button> </button>
<button type="button" className={topBarButtonClass()} onClick={project.openFilePicker} title="Open project (Cmd/Ctrl+Shift+O)"> <button type="button" className={topBarButtonClass()} onClick={project.openFilePicker} aria-label="Open project" title="Open project (Cmd/Ctrl+Shift+O)">
<FolderSimple size={24} /> <FolderSimple size={24} />
</button> </button>
<button type="button" className={topBarButtonClass()} onClick={project.save} title="Save project (Cmd/Ctrl+S)"> <button type="button" className={topBarButtonClass()} onClick={project.save} aria-label="Save project" title="Save project (Cmd/Ctrl+S)">
<FloppyDisk size={24} /> <FloppyDisk size={24} />
</button> </button>
<button type="button" className={topBarButtonClass(generateOpen)} aria-pressed={generateOpen} onClick={toggleGenerate}> <button type="button" className={topBarButtonClass(generateOpen)} aria-label="Generate" aria-pressed={generateOpen} aria-controls="generate-sheet" onClick={toggleGenerate} title="Generate (G)">
<Sparkle size={24} weight={generateOpen ? "fill" : "regular"} /> <Sparkle size={24} weight={generateOpen ? "fill" : "regular"} />
</button> </button>
<button type="button" className={topBarButtonClass()} disabled={!activeArtboard} onClick={() => activeArtboard && void downloadArtboardPng(activeArtboard, document.assets)}> <button type="button" className={topBarButtonClass()} aria-label="Export active artboard as PNG" title="Export active artboard as PNG" disabled={!activeArtboard} onClick={() => void app.actions.document.exportArtboard(activeArtboard?.id)}>
<DownloadSimple size={24} /> <DownloadSimple size={24} />
</button> </button>
<button type="button" className={topBarButtonClass(layersOpen)} aria-pressed={layersOpen} onClick={toggleLayers}> <button type="button" className={topBarButtonClass(layersOpen)} aria-label="Layers" aria-pressed={layersOpen} aria-controls="layers-sheet" onClick={toggleLayers} title="Layers (L)">
<Stack size={24} weight={layersOpen ? "fill" : "regular"} /> <Stack size={24} weight={layersOpen ? "fill" : "regular"} />
</button> </button>
</div> </div>
@@ -202,6 +210,7 @@ export function App({ app }: AppProps) {
maskEdit={maskEdit} maskEdit={maskEdit}
open={layersOpen} open={layersOpen}
dispatch={app.store.dispatch} dispatch={app.store.dispatch}
documentActions={app.actions.document}
/> />
<div className="absolute inset-x-0 bottom-4 z-10 flex justify-center"> <div className="absolute inset-x-0 bottom-4 z-10 flex justify-center">
<BottomControlsIsland <BottomControlsIsland
@@ -224,6 +233,7 @@ export function App({ app }: AppProps) {
transformTarget={viewportActivityIsland.visible ? undefined : transformTarget} transformTarget={viewportActivityIsland.visible ? undefined : transformTarget}
dispatch={app.store.dispatch} dispatch={app.store.dispatch}
generationWorkflow={app.workflows.generation} generationWorkflow={app.workflows.generation}
documentActions={app.actions.document}
/> />
</div> </div>
<div className="absolute bottom-4 left-4 z-10"> <div className="absolute bottom-4 left-4 z-10">

View File

@@ -12,6 +12,7 @@ import { ZoomControls } from "./bottom-controls/ZoomControls";
import type { Rect } from "@core/geometry"; import type { Rect } from "@core/geometry";
import type { TransformTarget } from "@editor/transform"; import type { TransformTarget } from "@editor/transform";
import type { GenerationWorkflow } from "@operations/generation/workflow"; import type { GenerationWorkflow } from "@operations/generation/workflow";
import type { DocumentActions } from "@app/document-actions";
export type BottomControlsAction = "pan" | "zoom"; export type BottomControlsAction = "pan" | "zoom";
export type BottomControlsIslandProps = { export type BottomControlsIslandProps = {
@@ -34,9 +35,10 @@ export type BottomControlsIslandProps = {
brushHint?: string; brushHint?: string;
dispatch: AppStore["dispatch"]; dispatch: AppStore["dispatch"];
generationWorkflow: GenerationWorkflow; generationWorkflow: GenerationWorkflow;
documentActions: DocumentActions;
}; };
export function BottomControlsIsland({ document, selection, viewport, visible, action, activeTool, operation, brushSettings, generateSettings, generation, chromaKeySettings, magicWandSettings, editingMask = false, maskViewMode = "composite", transformBounds, transformTarget, brushHint, dispatch, generationWorkflow }: BottomControlsIslandProps) { export function BottomControlsIsland({ document, selection, viewport, visible, action, activeTool, operation, brushSettings, generateSettings, generation, chromaKeySettings, magicWandSettings, editingMask = false, maskViewMode = "composite", transformBounds, transformTarget, brushHint, dispatch, generationWorkflow, documentActions }: BottomControlsIslandProps) {
const zoomPercent = Math.round(viewport.zoom * 100); const zoomPercent = Math.round(viewport.zoom * 100);
const x = Math.round(viewport.center.x); const x = Math.round(viewport.center.x);
const y = Math.round(viewport.center.y); const y = Math.round(viewport.center.y);
@@ -49,7 +51,7 @@ export function BottomControlsIsland({ document, selection, viewport, visible, a
}`} }`}
> >
{operation === "generate" ? ( {operation === "generate" ? (
<GenerateActionControls settings={generateSettings} generation={generation} dispatch={dispatch} workflow={generationWorkflow} /> <GenerateActionControls settings={generateSettings} generation={generation} dispatch={dispatch} workflow={generationWorkflow} generate={documentActions.generate} />
) : (activeTool === "brush" || activeTool === "eraser") && brushHint ? ( ) : (activeTool === "brush" || activeTool === "eraser") && brushHint ? (
<BrushHint tool={activeTool} hint={brushHint} /> <BrushHint tool={activeTool} hint={brushHint} />
) : activeTool === "brush" || activeTool === "eraser" ? ( ) : activeTool === "brush" || activeTool === "eraser" ? (
@@ -63,7 +65,7 @@ export function BottomControlsIsland({ document, selection, viewport, visible, a
) : action === "pan" ? ( ) : action === "pan" ? (
<PanControls x={x} y={y} /> <PanControls x={x} y={y} />
) : ( ) : (
<ZoomControls zoom={viewport.zoom} zoomPercent={zoomPercent} dispatch={dispatch} /> <ZoomControls zoomPercent={zoomPercent} actions={documentActions} />
)} )}
</div> </div>
); );

View File

@@ -42,7 +42,14 @@ export function CanvasViewport({
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, cursorState.isPanning, 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 touch-none ${cursorClass}`}
role="img"
aria-label="Image editing canvas. Use the tools and layers controls to edit the document."
/>
);
} }
type CanvasCursorState = { type CanvasCursorState = {

View File

@@ -8,6 +8,7 @@ import type { AppStore } from "@editor/store";
import type { ToolState } from "@editor/tools"; import type { ToolState } from "@editor/tools";
import { createDocumentReadIndex } from "@editor/document-indexes"; import { createDocumentReadIndex } from "@editor/document-indexes";
import { createPaletteItems, type PaletteItem } from "./paletteItems"; import { createPaletteItems, type PaletteItem } from "./paletteItems";
import type { DocumentActions } from "@app/document-actions";
export type CommandPaletteProps = { export type CommandPaletteProps = {
state: CommandPaletteState; state: CommandPaletteState;
@@ -23,6 +24,7 @@ export type CommandPaletteProps = {
openGenerate: () => void; openGenerate: () => void;
openLayers: () => void; openLayers: () => void;
closeLayers: () => void; closeLayers: () => void;
documentActions: DocumentActions;
}; };
export function CommandPalette({ export function CommandPalette({
@@ -39,6 +41,7 @@ export function CommandPalette({
openGenerate, openGenerate,
openLayers, openLayers,
closeLayers, closeLayers,
documentActions,
}: CommandPaletteProps) { }: CommandPaletteProps) {
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const documentIndex = useMemo(() => createDocumentReadIndex(document), [document]); const documentIndex = useMemo(() => createDocumentReadIndex(document), [document]);
@@ -67,6 +70,7 @@ export function CommandPalette({
openGenerate, openGenerate,
openLayers, openLayers,
closeLayers, closeLayers,
documentActions,
}), }),
[ [
activeArtboard, activeArtboard,
@@ -77,6 +81,7 @@ export function CommandPalette({
dispatch, dispatch,
document, document,
documentIndex, documentIndex,
documentActions,
generation, generation,
layersOpen, layersOpen,
openFilePicker, openFilePicker,

View File

@@ -13,6 +13,7 @@ export type GenerateSheetProps = {
export function GenerateSheet({ settings, resources, open, dispatch }: GenerateSheetProps) { export function GenerateSheet({ settings, resources, open, dispatch }: GenerateSheetProps) {
return ( return (
<aside <aside
id="generate-sheet"
aria-hidden={!open} aria-hidden={!open}
aria-label="Generate settings" aria-label="Generate settings"
className={`pointer-events-auto absolute bottom-6 right-4 top-24 z-20 flex w-[28rem] max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-[2.5rem] px-4 text-sm text-white backdrop-blur-xl transition-all duration-200 ${ className={`pointer-events-auto absolute bottom-6 right-4 top-24 z-20 flex w-[28rem] max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-[2.5rem] px-4 text-sm text-white backdrop-blur-xl transition-all duration-200 ${

View File

@@ -17,8 +17,8 @@ export function GenerationJobStatus({ generation, compact = false }: { generatio
if (!job) return null; if (!job) return null;
const elapsed = Math.max(0, Math.floor(((job.finishedAt ?? Date.now()) - job.startedAt) / 1000)); 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 label = job.status === "running" ? `${job.label} ${formatElapsed(elapsed)}` : job.status === "failed" ? job.error ?? `${job.label} failed` : job.status === "cancelled" ? `${job.label} cancelled` : `${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"; const tone = job.status === "failed" ? "bg-red-500/15 text-red-100" : job.status === "running" ? "bg-white/10 text-white/70" : job.status === "cancelled" ? "bg-amber-500/15 text-amber-100" : "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>; 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>;
} }

View File

@@ -9,7 +9,7 @@ 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 "@operations/export/downloadArtboard"; import type { DocumentActions } from "@app/document-actions";
import { addArtboard, addEmptyLayer, addGroupLayer, addLayerMask, deleteSelection, groupLayers, moveLayer } from "@operations/document/layerActions"; import { addArtboard, addEmptyLayer, addGroupLayer, addLayerMask, deleteSelection, groupLayers, moveLayer } from "@operations/document/layerActions";
import { MaskOperationButtons, MaskStatus } from "./layers/MaskControls"; import { MaskOperationButtons, MaskStatus } from "./layers/MaskControls";
@@ -19,15 +19,18 @@ export type LayersSheetProps = {
maskEdit?: MaskEditState; maskEdit?: MaskEditState;
open: boolean; open: boolean;
dispatch: AppStore["dispatch"]; dispatch: AppStore["dispatch"];
documentActions: DocumentActions;
}; };
export function LayersSheet({ document, selection, maskEdit, open, dispatch }: LayersSheetProps) { export function LayersSheet({ document, selection, maskEdit, open, dispatch, documentActions }: LayersSheetProps) {
const draggedLayerId = useRef<string | undefined>(undefined); const draggedLayerId = useRef<string | undefined>(undefined);
const [editingTitle, setEditingTitle] = useState<EditingTitle>(); const [editingTitle, setEditingTitle] = useState<EditingTitle>();
return ( return (
<aside <aside
id="layers-sheet"
aria-hidden={!open} aria-hidden={!open}
aria-label="Layers"
className={`pointer-events-auto absolute bottom-6 right-4 top-24 z-20 flex w-[28rem] flex-col overflow-hidden rounded-[2.5rem] px-4 text-sm text-white backdrop-blur-xl transition-all duration-200 ${ className={`pointer-events-auto absolute bottom-6 right-4 top-24 z-20 flex w-[28rem] flex-col overflow-hidden rounded-[2.5rem] px-4 text-sm text-white backdrop-blur-xl transition-all duration-200 ${
open ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-8 opacity-0" open ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-8 opacity-0"
}`} }`}
@@ -41,6 +44,7 @@ export function LayersSheet({ document, selection, maskEdit, open, dispatch }: L
editingTitle={editingTitle} editingTitle={editingTitle}
setEditingTitle={setEditingTitle} setEditingTitle={setEditingTitle}
dispatch={dispatch} dispatch={dispatch}
documentActions={documentActions}
/> />
) : null} ) : null}
</aside> </aside>
@@ -55,6 +59,7 @@ function LayersSheetBody({
editingTitle, editingTitle,
setEditingTitle, setEditingTitle,
dispatch, dispatch,
documentActions,
}: Omit<LayersSheetProps, "open"> & { }: Omit<LayersSheetProps, "open"> & {
draggedLayerId: MutableRefObject<string | undefined>; draggedLayerId: MutableRefObject<string | undefined>;
editingTitle: EditingTitle | undefined; editingTitle: EditingTitle | undefined;
@@ -152,7 +157,7 @@ function LayersSheetBody({
title="Export PNG" title="Export PNG"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
void downloadArtboardPng(artboard, document.assets); void documentActions.exportArtboard(artboard.id);
}} }}
> >
<DownloadSimple size={24} weight="regular" /> <DownloadSimple size={24} weight="regular" />
@@ -377,6 +382,7 @@ function RenameInput({ value, onChange, onCommit, onCancel }: { value: string; o
return ( return (
<input <input
autoFocus autoFocus
aria-label="Rename item"
className="min-w-0 flex-1 rounded-full bg-white px-3 py-1.5 font-medium text-black outline-none ring-1 ring-black/10 focus:ring-sky-300/50" className="min-w-0 flex-1 rounded-full bg-white px-3 py-1.5 font-medium text-black outline-none ring-1 ring-black/10 focus:ring-sky-300/50"
value={value} value={value}
onChange={(event) => onChange(event.target.value)} onChange={(event) => onChange(event.target.value)}

View File

@@ -33,8 +33,8 @@ export function BrushControls({ tool, settings, editingMask = false, maskViewMod
<BottomControlDivider /> <BottomControlDivider />
{editingMask ? ( {editingMask ? (
<> <>
<button type="button" className={maskModeButtonClass(tool === "brush")} onClick={() => dispatch(commandIds.toolSetActive, { tool: "brush" })}>Reveal</button> <button type="button" className={maskModeButtonClass(tool === "brush")} aria-pressed={tool === "brush"} onClick={() => dispatch(commandIds.toolSetActive, { tool: "brush" })}>Reveal</button>
<button type="button" className={maskModeButtonClass(tool === "eraser")} onClick={() => dispatch(commandIds.toolSetActive, { tool: "eraser" })}>Hide</button> <button type="button" className={maskModeButtonClass(tool === "eraser")} aria-pressed={tool === "eraser"} onClick={() => dispatch(commandIds.toolSetActive, { tool: "eraser" })}>Hide</button>
<BottomControlDivider /> <BottomControlDivider />
</> </>
) : null} ) : null}

View File

@@ -10,9 +10,10 @@ export type GenerateActionControlsProps = {
generation: GenerationState; generation: GenerationState;
dispatch: AppStore["dispatch"]; dispatch: AppStore["dispatch"];
workflow: GenerationWorkflow; workflow: GenerationWorkflow;
generate: () => Promise<void>;
}; };
export function GenerateActionControls({ settings, generation, dispatch, workflow }: GenerateActionControlsProps) { export function GenerateActionControls({ settings, generation, dispatch, workflow, generate }: GenerateActionControlsProps) {
const job = currentGenerationJob(generation); const job = currentGenerationJob(generation);
const busy = job?.status === "running"; const busy = job?.status === "running";
const candidate = selectedCandidate(generation); const candidate = selectedCandidate(generation);
@@ -28,13 +29,14 @@ export function GenerateActionControls({ settings, generation, dispatch, workflo
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={job?.status === "failed" ? job.error : preconditionMessage ?? "Generate with ComfyUI"} title={job?.status === "failed" ? job.error : preconditionMessage ?? "Generate with ComfyUI"}
onClick={() => { onClick={() => {
void workflow.generate(); void generate();
}} }}
> >
{busy && job?.kind === "generate" ? "Generating..." : "Generate"} {busy && job?.kind === "generate" ? "Generating..." : "Generate"}
</button> </button>
{preconditionMessage ? <span className="max-w-72 text-xs text-white/55" role="status">{preconditionMessage}</span> : null} {preconditionMessage ? <span className="max-w-72 text-xs text-white/55" role="status">{preconditionMessage}</span> : null}
<GenerationJobStatus generation={generation} /> <GenerationJobStatus generation={generation} />
{busy ? <button type="button" className="h-9 rounded-full bg-white/5 px-3 text-xs font-semibold text-white/70 hover:bg-white/10 hover:text-white" onClick={workflow.cancel}>Cancel</button> : null}
{candidate ? ( {candidate ? (
<> <>
<CandidatePicker generation={generation} dispatch={dispatch} /> <CandidatePicker generation={generation} dispatch={dispatch} />
@@ -53,7 +55,6 @@ export function GenerateActionControls({ settings, generation, dispatch, workflo
} }
function CandidatePicker({ generation, dispatch }: { generation: GenerationState; dispatch: AppStore["dispatch"] }) { function CandidatePicker({ generation, dispatch }: { generation: GenerationState; dispatch: AppStore["dispatch"] }) {
if (generation.candidates.length < 2) return null;
return ( return (
<div className="subtle-scrollbar flex max-w-[min(28rem,calc(100vw-2rem))] items-center gap-1 overflow-x-auto rounded-full bg-white/[0.04] px-1.5 py-1 ring-1 ring-white/[0.05]" role="group" aria-label="Generation candidates"> <div className="subtle-scrollbar flex max-w-[min(28rem,calc(100vw-2rem))] items-center gap-1 overflow-x-auto rounded-full bg-white/[0.04] px-1.5 py-1 ring-1 ring-white/[0.05]" role="group" aria-label="Generation candidates">
{generation.candidates.map((candidate) => { {generation.candidates.map((candidate) => {
@@ -70,6 +71,15 @@ function CandidatePicker({ generation, dispatch }: { generation: GenerationState
</button> </button>
); );
})} })}
<button
type="button"
disabled={generation.candidates.length === 0}
className="h-9 shrink-0 rounded-full px-3 text-xs font-semibold text-white/55 transition hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-35"
title="Dismiss every candidate and reset comparison"
onClick={() => dispatch(commandIds.generationClearCandidates, undefined)}
>
Clear all
</button>
</div> </div>
); );
} }
@@ -97,7 +107,9 @@ function CandidateControls({
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" title={`${candidate.settings.model} · ${candidate.mode}`}>
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" onClick={() => rerun("Regenerate", candidate.settings)} /> <CandidateButton disabled={disabled} label="Regenerate" title="Regenerate same mask and crop" onClick={() => rerun("Regenerate", candidate.settings)} />
<CandidateButton <CandidateButton
@@ -108,6 +120,12 @@ function CandidateControls({
/> />
<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="Reuse seed" title="Regenerate with the same seed" onClick={() => rerun("Reuse seed", { ...candidate.settings, seed: candidate.seed })} />
<CandidateButton disabled={disabled} label="New seed" title="Regenerate with a new seed" onClick={() => rerun("New seed", { ...candidate.settings, seed: -1 })} /> <CandidateButton disabled={disabled} label="New seed" title="Regenerate with a new seed" onClick={() => rerun("New seed", { ...candidate.settings, seed: -1 })} />
<CandidateButton
disabled={disabled}
label="Use settings"
title="Restore this candidate's prompt, model, generation settings, and resolved seed"
onClick={() => dispatch(commandIds.generationReuseCandidateSettings, { candidateId: candidate.id })}
/>
<CandidateButton disabled={disabled} label="Add as layer" title="Add candidate to the document as a layer" onClick={() => workflow.applyCandidateAsLayer(candidate.id)} /> <CandidateButton disabled={disabled} label="Add as layer" title="Add candidate to the document as a layer" onClick={() => workflow.applyCandidateAsLayer(candidate.id)} />
<CandidateButton <CandidateButton
disabled={disabled} disabled={disabled}
@@ -136,7 +154,7 @@ function CandidateControls({
}} }}
/> />
<CandidateButton disabled={disabled} label="Dismiss" title="Remove this candidate" onClick={() => dispatch(commandIds.generationRemoveCandidate, { candidateId: candidate.id })} /> <CandidateButton disabled={disabled} label="Dismiss" title="Remove this candidate" onClick={() => dispatch(commandIds.generationRemoveCandidate, { candidateId: candidate.id })} />
{settings.seed !== candidate.seed ? null : <span className="sr-only">Current settings reuse this seed</span>} {settings.seed !== candidate.seed ? null : <span className="sr-only">Current settings use this candidate's resolved seed</span>}
</div> </div>
); );
} }

View File

@@ -19,10 +19,10 @@ export function MagicWandControls({ settings, dispatch }: { settings: MagicWandS
<BottomControlDivider /> <BottomControlDivider />
<Slider label="Clean" value={settings.despeckle} min={0} max={20} onChange={(despeckle) => dispatch(commandIds.toolSetMagicWandSettings, { despeckle })} /> <Slider label="Clean" value={settings.despeckle} min={0} max={20} onChange={(despeckle) => dispatch(commandIds.toolSetMagicWandSettings, { despeckle })} />
<BottomControlDivider /> <BottomControlDivider />
<button type="button" className={`rounded-full px-4 py-2 text-base font-medium transition ${settings.contiguous ? "bg-white text-black" : "bg-white/10 text-white"}`} onClick={() => dispatch(commandIds.toolSetMagicWandSettings, { contiguous: !settings.contiguous })}>Contig</button> <button type="button" className={`rounded-full px-4 py-2 text-base font-medium transition ${settings.contiguous ? "bg-white text-black" : "bg-white/10 text-white"}`} aria-label="Contiguous selection" aria-pressed={settings.contiguous} title="Select only connected pixels" onClick={() => dispatch(commandIds.toolSetMagicWandSettings, { contiguous: !settings.contiguous })}>Contiguous</button>
<BottomControlDivider /> <BottomControlDivider />
{(["replace", "add", "subtract"] as const).map((mode) => ( {(["replace", "add", "subtract"] as const).map((mode) => (
<button key={mode} type="button" className={`rounded-full px-4 py-2 text-base font-medium capitalize transition ${settings.mode === mode ? "bg-white text-black" : "bg-white/10 text-white"}`} onClick={() => dispatch(commandIds.toolSetMagicWandSettings, { mode })}>{mode === "subtract" ? "Sub" : mode}</button> <button key={mode} type="button" className={`rounded-full px-4 py-2 text-base font-medium capitalize transition ${settings.mode === mode ? "bg-white text-black" : "bg-white/10 text-white"}`} aria-pressed={settings.mode === mode} onClick={() => dispatch(commandIds.toolSetMagicWandSettings, { mode })}>{mode}</button>
))} ))}
<span className="px-2 text-sm text-white/60">Shift-click adds, Alt-click subtracts</span> <span className="px-2 text-sm text-white/60">Shift-click adds, Alt-click subtracts</span>
</div> </div>

View File

@@ -1,5 +1,5 @@
import { Check, CaretDown } from "@phosphor-icons/react"; import { Check, CaretDown } from "@phosphor-icons/react";
import { useEffect, useRef, useState, type CSSProperties, type RefObject } from "react"; import { useEffect, useId, useRef, useState, type CSSProperties, type KeyboardEvent, type RefObject } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
export type BottomControlSelectOption<TValue extends string> = { export type BottomControlSelectOption<TValue extends string> = {
@@ -20,6 +20,7 @@ export function BottomControlSelectMenu<TValue extends string>({ value, options,
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null); const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
const listboxId = useId();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [menuStyle, setMenuStyle] = useState<CSSProperties>(); const [menuStyle, setMenuStyle] = useState<CSSProperties>();
const selectedOption = options.find((option) => option.value === value) ?? options[0]; const selectedOption = options.find((option) => option.value === value) ?? options[0];
@@ -50,8 +51,11 @@ export function BottomControlSelectMenu<TValue extends string>({ value, options,
if (!rootRef.current?.contains(target) && !menuRef.current?.contains(target)) setOpen(false); if (!rootRef.current?.contains(target) && !menuRef.current?.contains(target)) setOpen(false);
}; };
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key === "Escape") setOpen(false); if (event.key === "Escape") {
setOpen(false);
buttonRef.current?.focus();
}
}; };
window.addEventListener("pointerdown", handlePointerDown); window.addEventListener("pointerdown", handlePointerDown);
@@ -66,6 +70,21 @@ export function BottomControlSelectMenu<TValue extends string>({ value, options,
}; };
}, [open, placement]); }, [open, placement]);
const openAndFocusOption = (index: number) => {
setOpen(true);
window.requestAnimationFrame(() => {
const optionButtons = menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="option"]');
optionButtons?.[Math.max(0, Math.min(index, options.length - 1))]?.focus();
});
};
const handleTriggerKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key !== "ArrowDown" && event.key !== "ArrowUp" && event.key !== "Home" && event.key !== "End") return;
event.preventDefault();
const selectedIndex = Math.max(0, options.findIndex((option) => option.value === value));
openAndFocusOption(event.key === "End" ? options.length - 1 : event.key === "Home" ? 0 : selectedIndex);
};
return ( return (
<div ref={rootRef} className={`relative min-w-0 ${placement === "inline" ? "w-full" : ""}`}> <div ref={rootRef} className={`relative min-w-0 ${placement === "inline" ? "w-full" : ""}`}>
<button <button
@@ -75,6 +94,8 @@ export function BottomControlSelectMenu<TValue extends string>({ value, options,
aria-label={props["aria-label"]} aria-label={props["aria-label"]}
aria-haspopup="listbox" aria-haspopup="listbox"
aria-expanded={open} aria-expanded={open}
aria-controls={listboxId}
onKeyDown={handleTriggerKeyDown}
onClick={() => setOpen((current) => !current)} onClick={() => setOpen((current) => !current)}
> >
{placement === "inline" && label ? <span className="shrink-0 pr-3 text-sm font-medium text-white/55">{label}</span> : null} {placement === "inline" && label ? <span className="shrink-0 pr-3 text-sm font-medium text-white/55">{label}</span> : null}
@@ -86,6 +107,7 @@ export function BottomControlSelectMenu<TValue extends string>({ value, options,
{open && placement === "inline" ? ( {open && placement === "inline" ? (
<SelectOptions <SelectOptions
menuRef={menuRef} menuRef={menuRef}
id={listboxId}
options={options} options={options}
value={value} value={value}
onValueChange={onValueChange} onValueChange={onValueChange}
@@ -97,6 +119,7 @@ export function BottomControlSelectMenu<TValue extends string>({ value, options,
createPortal( createPortal(
<SelectOptions <SelectOptions
menuRef={menuRef} menuRef={menuRef}
id={listboxId}
options={options} options={options}
value={value} value={value}
onValueChange={onValueChange} onValueChange={onValueChange}
@@ -114,6 +137,7 @@ export function BottomControlSelectMenu<TValue extends string>({ value, options,
type SelectOptionsProps<TValue extends string> = { type SelectOptionsProps<TValue extends string> = {
menuRef: RefObject<HTMLDivElement | null>; menuRef: RefObject<HTMLDivElement | null>;
id: string;
options: readonly BottomControlSelectOption<TValue>[]; options: readonly BottomControlSelectOption<TValue>[];
value: TValue; value: TValue;
className: string; className: string;
@@ -123,10 +147,28 @@ type SelectOptionsProps<TValue extends string> = {
setOpen: (open: boolean) => void; setOpen: (open: boolean) => void;
}; };
function SelectOptions<TValue extends string>({ menuRef, options, value, className, style, onValueChange, setOpen, ...props }: SelectOptionsProps<TValue>) { function SelectOptions<TValue extends string>({ menuRef, id, options, value, className, style, onValueChange, setOpen, ...props }: SelectOptionsProps<TValue>) {
const handleOptionKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
const optionButtons = menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="option"]');
if (!optionButtons) return;
if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
return;
}
const nextIndex = event.key === "ArrowDown" ? (index + 1) % options.length
: event.key === "ArrowUp" ? (index - 1 + options.length) % options.length
: event.key === "Home" ? 0
: event.key === "End" ? options.length - 1
: undefined;
if (nextIndex === undefined) return;
event.preventDefault();
optionButtons[nextIndex]?.focus();
};
return ( return (
<div ref={menuRef} className={className} style={style} role="listbox" aria-label={props["aria-label"]}> <div ref={menuRef} id={id} className={className} style={style} role="listbox" aria-label={props["aria-label"]}>
{options.map((option) => { {options.map((option, index) => {
const selected = option.value === value; const selected = option.value === value;
return ( return (
<button <button
@@ -135,6 +177,8 @@ function SelectOptions<TValue extends string>({ menuRef, options, value, classNa
className={`flex h-10 w-full items-center gap-3 rounded-full px-3 text-left text-sm transition ${selected ? "bg-white !text-black" : "text-white/80 hover:bg-white/10 hover:text-white"}`} className={`flex h-10 w-full items-center gap-3 rounded-full px-3 text-left text-sm transition ${selected ? "bg-white !text-black" : "text-white/80 hover:bg-white/10 hover:text-white"}`}
role="option" role="option"
aria-selected={selected} aria-selected={selected}
tabIndex={selected ? 0 : -1}
onKeyDown={(event) => handleOptionKeyDown(event, index)}
onClick={() => { onClick={() => {
onValueChange(option.value); onValueChange(option.value);
setOpen(false); setOpen(false);

View File

@@ -1,27 +1,25 @@
import { Minus, CornersOut, Plus } from "@phosphor-icons/react"; import { Minus, CornersOut, Plus } from "@phosphor-icons/react";
import { commandIds } from "@commands/ids"; import type { DocumentActions } from "@app/document-actions";
import type { AppStore } from "@editor/store";
import { BottomControlDivider } from "./Divider"; import { BottomControlDivider } from "./Divider";
import { bottomControlButtonClass, bottomControlMenuClass, bottomControlValueClass } from "./styles"; import { bottomControlButtonClass, bottomControlMenuClass, bottomControlValueClass } from "./styles";
export type ZoomControlsProps = { export type ZoomControlsProps = {
zoom: number;
zoomPercent: number; zoomPercent: number;
dispatch: AppStore["dispatch"]; actions: DocumentActions;
}; };
export function ZoomControls({ zoom, zoomPercent, dispatch }: ZoomControlsProps) { export function ZoomControls({ zoomPercent, actions }: ZoomControlsProps) {
return ( return (
<div className={bottomControlMenuClass()}> <div className={bottomControlMenuClass()}>
<button type="button" className={bottomControlButtonClass()} aria-label="Zoom out" onClick={() => dispatch(commandIds.viewportSetZoom, { zoom: zoom / 1.2 })}> <button type="button" className={bottomControlButtonClass()} aria-label="Zoom out" onClick={() => actions.zoomBy(1 / 1.2)}>
<Minus size={24} weight="regular" /> <Minus size={24} weight="regular" />
</button> </button>
<span className={bottomControlValueClass()}>{zoomPercent}%</span> <span className={bottomControlValueClass()}>{zoomPercent}%</span>
<button type="button" className={bottomControlButtonClass()} aria-label="Zoom in" onClick={() => dispatch(commandIds.viewportSetZoom, { zoom: zoom * 1.2 })}> <button type="button" className={bottomControlButtonClass()} aria-label="Zoom in" onClick={() => actions.zoomBy(1.2)}>
<Plus size={24} weight="regular" /> <Plus size={24} weight="regular" />
</button> </button>
<BottomControlDivider /> <BottomControlDivider />
<button type="button" className={bottomControlButtonClass()} aria-label="Fit artboard" title="Fit artboard" onClick={() => dispatch(commandIds.viewportFitArtboard, undefined)}> <button type="button" className={bottomControlButtonClass()} aria-label="Fit artboard" title="Fit artboard" onClick={() => actions.fitArtboard()}>
<CornersOut size={24} weight="regular" /> <CornersOut size={24} weight="regular" />
</button> </button>
</div> </div>

View File

@@ -14,6 +14,22 @@
body { body {
@apply m-0 min-w-[320px] bg-background text-foreground; @apply m-0 min-w-[320px] bg-background text-foreground;
} }
:where(button, a, input, textarea, select, [tabindex]):focus-visible {
outline: 2px solid rgb(125 211 252 / 0.95);
outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
} }
@layer components { @layer components {

View File

@@ -1,4 +1,4 @@
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 { ArrowCounterClockwise, 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 type { ReactNode } from "react";
import { commandIds } from "@commands/ids"; import { commandIds } from "@commands/ids";
import type { Artboard } from "@core/artboard"; import type { Artboard } from "@core/artboard";
@@ -7,7 +7,7 @@ import type { GenerationCompareMode, GenerationState, SelectionState, ViewportSt
import type { AppStore } from "@editor/store"; import type { AppStore } from "@editor/store";
import { availableToolIds, type GenerateMode, type ToolId, type ToolState } from "@editor/tools"; import { availableToolIds, type GenerateMode, type ToolId, type ToolState } from "@editor/tools";
import type { DocumentReadIndex, IndexedLayerInfo } from "@editor/document-indexes"; import type { DocumentReadIndex, IndexedLayerInfo } from "@editor/document-indexes";
import { downloadArtboardPng } from "@operations/export/downloadArtboard"; import type { DocumentActions } from "@app/document-actions";
import { addArtboard, addEmptyLayer, addGroupLayer, deleteSelection, groupLayers, moveLayer } from "@operations/document/layerActions"; import { addArtboard, addEmptyLayer, addGroupLayer, deleteSelection, groupLayers, moveLayer } from "@operations/document/layerActions";
import { labelForTool } from "./toolLabels"; import { labelForTool } from "./toolLabels";
@@ -31,6 +31,7 @@ export function createPaletteItems(options: {
openGenerate: () => void; openGenerate: () => void;
openLayers: () => void; openLayers: () => void;
closeLayers: () => void; closeLayers: () => void;
documentActions: DocumentActions;
}): PaletteItem[] { }): PaletteItem[] {
const { const {
document, document,
@@ -50,6 +51,7 @@ export function createPaletteItems(options: {
openGenerate, openGenerate,
openLayers, openLayers,
closeLayers, closeLayers,
documentActions,
} = options; } = options;
const hasCandidates = generation.candidates.length > 0; const hasCandidates = generation.candidates.length > 0;
const items: PaletteItem[] = []; const items: PaletteItem[] = [];
@@ -85,12 +87,30 @@ export function createPaletteItems(options: {
disabled: !activeArtboard, disabled: !activeArtboard,
icon: <DownloadSimple size={20} />, icon: <DownloadSimple size={20} />,
run: () => { run: () => {
if (activeArtboard) void downloadArtboardPng(activeArtboard, document.assets); if (activeArtboard) void documentActions.exportArtboard(activeArtboard.id);
}, },
}, },
); );
items.push( items.push(
{
id: "undo",
section: "Edit",
title: "Undo",
subtitle: "Cmd/Ctrl+Z",
disabled: !documentActions.canUndo(),
icon: <ArrowCounterClockwise size={20} />,
run: documentActions.undo,
},
{
id: "redo",
section: "Edit",
title: "Redo",
subtitle: "Cmd/Ctrl+Shift+Z",
disabled: !documentActions.canRedo(),
icon: <ArrowCounterClockwise size={20} className="scale-x-[-1]" />,
run: documentActions.redo,
},
{ {
id: layersOpen ? "close-layers" : "open-layers", id: layersOpen ? "close-layers" : "open-layers",
section: "Layers", section: "Layers",
@@ -271,7 +291,7 @@ export function createPaletteItems(options: {
title: "Zoom in", title: "Zoom in",
subtitle: `${Math.round(viewport.zoom * 100)}%`, subtitle: `${Math.round(viewport.zoom * 100)}%`,
icon: <Plus size={20} />, icon: <Plus size={20} />,
run: () => dispatch(commandIds.viewportSetZoom, { zoom: viewport.zoom * 1.2 }), run: () => documentActions.zoomBy(1.2),
}, },
{ {
id: "zoom-out", id: "zoom-out",
@@ -279,14 +299,14 @@ export function createPaletteItems(options: {
title: "Zoom out", title: "Zoom out",
subtitle: `${Math.round(viewport.zoom * 100)}%`, subtitle: `${Math.round(viewport.zoom * 100)}%`,
icon: <Minus size={20} />, icon: <Minus size={20} />,
run: () => dispatch(commandIds.viewportSetZoom, { zoom: viewport.zoom / 1.2 }), run: () => documentActions.zoomBy(1 / 1.2),
}, },
{ {
id: "zoom-100", id: "zoom-100",
section: "Zoom", section: "Zoom",
title: "Zoom to 100%", title: "Zoom to 100%",
icon: <CornersOut size={20} />, icon: <CornersOut size={20} />,
run: () => dispatch(commandIds.viewportSetZoom, { zoom: 1 }), run: () => documentActions.zoomTo(1),
}, },
{ {
id: "fit-artboard", id: "fit-artboard",
@@ -295,34 +315,7 @@ export function createPaletteItems(options: {
subtitle: activeArtboard?.name, subtitle: activeArtboard?.name,
disabled: !activeArtboard, disabled: !activeArtboard,
icon: <CornersOut size={20} />, icon: <CornersOut size={20} />,
run: () => dispatch(commandIds.viewportFitArtboard, undefined), run: () => documentActions.fitArtboard(activeArtboard?.id),
},
);
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),
}, },
); );