Compare commits

..

5 Commits

Author SHA1 Message Date
syntaxbullet
38565382c3 feat: add feather brush tool with adjustable settings and blending functionality
- Implemented feather brush tool in the brushRaster module, allowing for feathered edges in brush strokes.
- Added new FeatherControls component for UI adjustments of feather settings including size, radius, strength, and smoothing.
- Updated brush preview logic to accommodate feather tool alongside existing brush and eraser tools.
- Enhanced layer rendering to support feather mask previews and interactions.
- Introduced blending logic for feathered strokes to mix blurred mask values with original pixels.
- Added unit tests for feather blending functionality and tool keybindings.
- Updated cursor handling to reflect feather tool usage.
2026-07-11 22:08:25 +02:00
syntaxbullet
95043dfbdd feat: enhance ControlNet integration with Xinsir-compatible pose rendering and update model handling 2026-07-11 19:46:02 +02:00
syntaxbullet
ff762b8f17 feat: add inpaint region functionality and related tools
- Enhanced cursor behavior for new tools: semantic select, mask lasso, and mask rectangle.
- Updated mask edit state to include mask asset ID and kind.
- Implemented inpaint region commands for adding, applying, and removing inpaint regions.
- Introduced new operations for lasso and semantic selection tools.
- Created UI components for candidate review and inpaint region management.
- Added tests for inpaint region commands to ensure functionality.
- Updated various components to support new inpaint features and improve user experience.
2026-07-11 16:41:22 +02:00
syntaxbullet
f4e13b80e7 feat: add script to launch Image Studio with local ComfyUI server 2026-07-11 15:11:22 +02:00
syntaxbullet
5915c62a9a refactor: Update bottom controls for improved styling and functionality
- Adjusted button styles across various components for consistency and better UX.
- Enhanced layout of action controls to utilize whitespace more effectively.
- Updated slider styles for a more modern appearance and improved usability.
- Refined input fields and labels for better accessibility and readability.
- Introduced new app surface styles for a cohesive design across the application.
- Added tests for canvas cursor behavior to ensure correct cursor display during operations.
2026-07-11 14:55:32 +02:00
100 changed files with 2567 additions and 634 deletions

View File

@@ -36,6 +36,19 @@ Start the development server:
bun dev
```
To start Image Studio together with the local headless ComfyUI server:
```bash
bun run dev:full
```
The combined launcher reuses a ComfyUI server already listening at
`http://127.0.0.1:8188`. Otherwise, it starts the existing local ComfyUI
installation and stops that process when Image Studio exits. Its paths and
address can be overridden with the `COMFYUI_BASE_DIR`, `COMFYUI_SOURCE_DIR`,
`COMFYUI_PYTHON`, `COMFYUI_MODEL_PATHS_CONFIG`, `COMFYUI_HOST`, and
`COMFYUI_PORT` environment variables.
Build for production:
```bash
@@ -53,6 +66,7 @@ bun start
| Command | Description |
| --- | --- |
| `bun dev` | Start the app with Bun hot reload |
| `bun run dev:full` | Start the app and local headless ComfyUI server |
| `bun run build` | Build the production bundle |
| `bun start` | Run the production server |
| `bun test` | Run tests |

View File

@@ -11,6 +11,7 @@ import { viewportCommands } from "@commands/viewport";
import { workspaceCommands } from "@commands/workspace";
import { editorCommands } from "@commands/editor";
import { projectCommands } from "@commands/project";
import { inpaintRegionCommands } from "@commands/inpaint-region";
import { createInitialAppState } from "@editor/initial-state";
import { createAppStore } from "@editor/store";
import { createGenerationWorkflow } from "@operations/generation/workflow";
@@ -19,7 +20,7 @@ import { createDocumentActions } from "./document-actions";
export type ImageStudioApp = ReturnType<typeof createImageStudioApp>;
export function createImageStudioApp(options?: { documentName?: string; createDefaultArtboard?: boolean }) {
const registry = createCommandRegistry([...projectCommands, ...viewportCommands, ...selectionCommands, ...documentCommands, ...toolCommands, ...generationCommands, ...transformCommands, ...historyCommands, ...commandPaletteCommands, ...workspaceCommands, ...editorCommands]);
const registry = createCommandRegistry([...projectCommands, ...viewportCommands, ...selectionCommands, ...documentCommands, ...inpaintRegionCommands, ...toolCommands, ...generationCommands, ...transformCommands, ...historyCommands, ...commandPaletteCommands, ...workspaceCommands, ...editorCommands]);
const store = createAppStore(createInitialAppState(options?.documentName), registry);
const generation = createGenerationWorkflow(store);
const documentActions = createDocumentActions(store, generation);

View File

@@ -3,6 +3,7 @@ import type { ArtboardId, LayerId } from "@core/id";
import type { Layer } from "@core/layer";
import type { LayerGroup } from "@core/layer-group";
import { getLayerMask } from "@core/layer-mask-utils";
import type { MaskEditState } from "@editor/state";
export type LayerLocation = {
artboardId: ArtboardId;
@@ -242,13 +243,18 @@ export function removeMissingMaskReferences(document: ImageDocument): ImageDocum
});
}
export function isMaskEditFor(maskEdit: { targetLayerId: LayerId; maskLayerId: LayerId } | undefined, targetLayerId: LayerId, maskLayerId: LayerId) {
export function isMaskEditFor(maskEdit: MaskEditState | undefined, targetLayerId: LayerId, maskLayerId: LayerId) {
return maskEdit?.targetLayerId === targetLayerId && maskEdit.maskLayerId === maskLayerId;
}
export function isMaskEditValid(maskEdit: { targetLayerId: LayerId; maskLayerId: LayerId } | undefined, document: ImageDocument) {
export function isMaskEditValid(maskEdit: MaskEditState | undefined, document: ImageDocument) {
if (!maskEdit) return false;
const target = findLayerLocation(document, maskEdit.targetLayerId)?.layer;
if (maskEdit.kind === "inpaintRegion") {
const region = document.inpaintRegions.find((candidate) => candidate.id === maskEdit.inpaintRegionId && candidate.targetLayerId === maskEdit.targetLayerId && candidate.maskAssetId === maskEdit.maskAssetId);
return Boolean(target && region && document.assets.some((asset) => asset.id === region.maskAssetId));
}
if (!maskEdit.maskLayerId) return false;
const mask = findLayerLocation(document, maskEdit.maskLayerId)?.layer;
return Boolean(target && getLayerMask(target)?.maskLayerId === maskEdit.maskLayerId && mask && mask.type !== "group");
}
@@ -300,3 +306,26 @@ export function mapAllLayersInTree(layers: Layer[], mapLayer: (layer: Layer) =>
return mapLayer(mapped);
});
}
export function removeInpaintRegionsForTargets(document: ImageDocument, targetLayerIds: ReadonlySet<LayerId>): ImageDocument {
const removed = document.inpaintRegions.filter((region) => targetLayerIds.has(region.targetLayerId));
if (removed.length === 0) return document;
const inpaintRegions = document.inpaintRegions.filter((region) => !targetLayerIds.has(region.targetLayerId));
const removedMaskAssetIds = new Set(removed.map((region) => region.maskAssetId));
const retainedRegionAssetIds = new Set(inpaintRegions.map((region) => region.maskAssetId));
const layerAssetIds = new Set<string>();
for (const artboard of document.artboards) {
const stack = [...artboard.layers];
while (stack.length > 0) {
const layer = stack.pop();
if (!layer) continue;
if (layer.type === "group") stack.push(...layer.children);
else if (layer.type === "image" || layer.type === "raster") layerAssetIds.add(layer.assetId);
}
}
return {
...document,
inpaintRegions,
assets: document.assets.filter((asset) => !removedMaskAssetIds.has(asset.id) || retainedRegionAssetIds.has(asset.id) || layerAssetIds.has(asset.id)),
};
}

View File

@@ -285,7 +285,7 @@ describe("document commands", () => {
inverted: false,
});
expect(masked.document.artboards[0]?.layers[1]?.clippingMask).toEqual({ maskLayerId: "mask" });
expect(masked.editor.maskEdit).toEqual({ targetLayerId: "target", maskLayerId: "mask" });
expect(masked.editor.maskEdit).toEqual({ kind: "layerMask", targetLayerId: "target", maskLayerId: "mask", maskAssetId: "mask-asset" });
expect(masked.editor.tools.activeTool).toBe("brush");
});

View File

@@ -1,4 +1,4 @@
import { findLayerLocation, isReferencedMaskLayer, mapLayerInDocument, insertLayer, replaceLayerListInDocument, replaceSelectedLayersWithGroup, removeLayerFromDocument, ungroupLayerInDocument, findGroup, removeLayerMaskReference, withLayerMask, removeUnreferencedMaskLayer, removeMissingMaskReferences, isMaskEditFor, isMaskEditValid, collectLayerIds, collectClippingMaskIds, collectAttachedMaskIds } from "./document-tree";
import { findLayerLocation, isReferencedMaskLayer, mapLayerInDocument, insertLayer, replaceLayerListInDocument, replaceSelectedLayersWithGroup, removeLayerFromDocument, ungroupLayerInDocument, findGroup, removeLayerMaskReference, withLayerMask, removeUnreferencedMaskLayer, removeMissingMaskReferences, isMaskEditFor, isMaskEditValid, collectLayerIds, collectClippingMaskIds, collectAttachedMaskIds, removeInpaintRegionsForTargets } from "./document-tree";
import type { Asset } from "@core/asset";
import type { ImageDocument } from "@core/document";
import type { Rect } from "@core/geometry";
@@ -141,6 +141,7 @@ export type DocumentAddLayerMaskPayload = {
layerId: LayerId;
asset: Asset;
maskLayer: RasterLayer;
activeTool?: "brush" | "feather";
};
export type LayerMaskOperation =
@@ -235,10 +236,14 @@ export const documentRemoveArtboardCommand: Command<DocumentRemoveArtboardPayloa
name: "Remove artboard",
execute({ state }, payload) {
const removedSelectedArtboard = state.editor.selection.artboardId === payload.id;
const document = {
const removedArtboard = state.document.artboards.find((artboard) => artboard.id === payload.id);
const withoutArtboard = {
...state.document,
artboards: state.document.artboards.filter((artboard) => artboard.id !== payload.id),
};
const removedLayerIds = new Set<LayerId>();
for (const layer of removedArtboard?.layers ?? []) collectLayerIds(layer, removedLayerIds);
const document = removeInpaintRegionsForTargets(withoutArtboard, removedLayerIds);
return {
...state,
@@ -630,18 +635,19 @@ export const documentAddLayerMaskCommand: Command<DocumentAddLayerMaskPayload> =
const existingMaskId = getLayerMask(targetLocation.layer)?.maskLayerId;
if (existingMaskId) {
const existingMaskLocation = findLayerLocation(state.document, existingMaskId);
if (existingMaskLocation?.layer.type === "group") return state;
if (existingMaskLocation) {
const existingMaskLayer = existingMaskLocation?.layer;
if (existingMaskLayer && existingMaskLayer.type !== "image" && existingMaskLayer.type !== "raster") return state;
if (existingMaskLocation && existingMaskLayer) {
return {
...state,
editor: {
...state.editor,
selection: { artboardId: targetLocation.artboardId, layerIds: [payload.layerId] },
maskEdit: { targetLayerId: payload.layerId, maskLayerId: existingMaskId },
maskEdit: { kind: "layerMask", targetLayerId: payload.layerId, maskLayerId: existingMaskId, maskAssetId: existingMaskLayer.assetId },
tools: {
...state.editor.tools,
activeTool: "brush",
interactionMode: { type: "tool", tool: "brush" },
activeTool: payload.activeTool ?? "brush",
interactionMode: { type: "tool", tool: payload.activeTool ?? "brush" },
},
},
};
@@ -670,11 +676,11 @@ export const documentAddLayerMaskCommand: Command<DocumentAddLayerMaskPayload> =
editor: {
...state.editor,
selection: { artboardId: targetLocation.artboardId, layerIds: [payload.layerId] },
maskEdit: { targetLayerId: payload.layerId, maskLayerId: maskLayer.id },
maskEdit: { kind: "layerMask", targetLayerId: payload.layerId, maskLayerId: maskLayer.id, maskAssetId: payload.asset.id },
tools: {
...state.editor.tools,
activeTool: "brush",
interactionMode: { type: "tool", tool: "brush" },
activeTool: payload.activeTool ?? "brush",
interactionMode: { type: "tool", tool: payload.activeTool ?? "brush" },
},
},
};
@@ -749,7 +755,8 @@ export const documentRemoveLayerCommand: Command<DocumentRemoveLayerPayload> = {
const removedLayerIds = collectLayerIds(removed.layer);
const removedMaskLayerIds = collectClippingMaskIds([removed.layer]);
const cleanedReferences = removeMissingMaskReferences(removed.document);
const document = [...removedMaskLayerIds].reduce((nextDocument, maskLayerId) => removeUnreferencedMaskLayer(nextDocument, maskLayerId), cleanedReferences);
const withoutMasks = [...removedMaskLayerIds].reduce((nextDocument, maskLayerId) => removeUnreferencedMaskLayer(nextDocument, maskLayerId), cleanedReferences);
const document = removeInpaintRegionsForTargets(withoutMasks, removedLayerIds);
const selection = {
...state.editor.selection,
layerIds: state.editor.selection.layerIds.filter((id) => !removedLayerIds.has(id)),

View File

@@ -231,11 +231,14 @@ function generationCandidate(id: string, inpaint = false): GenerationCandidate {
maskImage: "mask",
inpaint: {
targetLayerId: "source-layer",
maskLayerId: "mask-layer",
regionId: "region",
sourceAssetId: "source-asset",
maskAssetId: "mask-asset",
inputImage: "input",
maskImage: "mask",
editMaskImage: "edit-mask",
blendMaskImage: "blend-mask",
revision: { source: "source-revision", mask: "mask-revision" },
crop: {
assetBounds: { x: 0, y: 0, w: 64, h: 64 },
documentBounds: { x: 0, y: 0, w: 64, h: 64 },

View File

@@ -23,6 +23,7 @@ export type GenerationSetCompareModePayload = {
export type GenerationRemoveCandidatePayload = {
candidateId: GenerationCandidateId;
};
export type GenerationToggleCandidateFavoritePayload = { candidateId: GenerationCandidateId };
export type GenerationReuseCandidateSettingsPayload = {
candidateId: GenerationCandidateId;
@@ -41,6 +42,7 @@ export type GenerationReplaceCandidatePixelsPayload = {
};
export type GenerationStartJobPayload = { jobId: GenerationJobId; kind: GenerationJobKind; label: string; startedAt: number };
export type GenerationUpdateJobPayload = { jobId: GenerationJobId; progress: number; detail: string };
export type GenerationSucceedJobPayload = { jobId: GenerationJobId; finishedAt: number };
export type GenerationFailJobPayload = { jobId: GenerationJobId; finishedAt: number; error: string };
export type GenerationCancelJobPayload = { jobId: GenerationJobId; finishedAt: number };
@@ -57,7 +59,8 @@ export const generationAddCandidateCommand: Command<GenerationAddCandidatePayloa
name: "Add generation candidate",
history: { mode: "ignore" },
execute({ state }, payload) {
const candidates = retainCandidateBudget([payload.candidate, ...state.editor.generation.candidates.filter((candidate) => candidate.id !== payload.candidate.id)]);
const existing = state.editor.generation.candidates.filter((candidate) => candidate.id !== payload.candidate.id);
const candidates = retainCandidateBudget([payload.candidate, ...existing.filter((candidate) => candidate.favorite), ...existing.filter((candidate) => !candidate.favorite)]);
return {
...state,
editor: {
@@ -130,6 +133,25 @@ export const generationRemoveCandidateCommand: Command<GenerationRemoveCandidate
},
};
export const generationToggleCandidateFavoriteCommand: Command<GenerationToggleCandidateFavoritePayload> = {
id: commandIds.generationToggleCandidateFavorite,
name: "Favorite generation candidate",
history: { mode: "ignore" },
execute({ state }, payload) {
if (!state.editor.generation.candidates.some((candidate) => candidate.id === payload.candidateId)) return state;
return {
...state,
editor: {
...state.editor,
generation: {
...state.editor.generation,
candidates: state.editor.generation.candidates.map((candidate) => candidate.id === payload.candidateId ? { ...candidate, favorite: !candidate.favorite } : candidate),
},
},
};
},
};
export const generationClearCandidatesCommand: Command = {
id: commandIds.generationClearCandidates,
name: "Clear generation candidates",
@@ -260,6 +282,17 @@ export const generationStartJobCommand: Command<GenerationStartJobPayload> = {
},
};
export const generationUpdateJobCommand: Command<GenerationUpdateJobPayload> = {
id: commandIds.generationUpdateJob,
name: "Update generation progress",
history: { mode: "ignore" },
execute({ state }, payload) {
const job = state.editor.generation.jobs.find((candidate) => candidate.id === payload.jobId);
if (!job || job.status !== "running" || !Number.isFinite(payload.progress) || !payload.detail.trim()) return state;
return updateJobs(state, state.editor.generation.jobs.map((candidate) => candidate.id === payload.jobId ? { ...candidate, progress: Math.max(0, Math.min(1, payload.progress)), detail: payload.detail.trim() } : candidate));
},
};
export const generationSucceedJobCommand: Command<GenerationSucceedJobPayload> = {
id: commandIds.generationSucceedJob,
name: "Complete generation job",
@@ -322,11 +355,13 @@ export const generationCommands = [
generationSelectCandidateCommand,
generationSetCompareModeCommand,
generationRemoveCandidateCommand,
generationToggleCandidateFavoriteCommand,
generationClearCandidatesCommand,
generationReuseCandidateSettingsCommand,
generationApplyCandidateAsLayerCommand,
generationReplaceCandidatePixelsCommand,
generationStartJobCommand,
generationUpdateJobCommand,
generationSucceedJobCommand,
generationFailJobCommand,
generationCancelJobCommand,
@@ -364,7 +399,7 @@ function retainCandidateBudget(candidates: GenerationCandidate[]): GenerationCan
}
function candidateRetainedBytes(candidate: GenerationCandidate): number {
return [candidate.source, candidate.inputImage, candidate.maskImage, candidate.inpaint?.inputImage, candidate.inpaint?.maskImage]
return [candidate.source, candidate.inputImage, candidate.maskImage, candidate.blendMaskImage, candidate.inpaint?.inputImage, candidate.inpaint?.maskImage, candidate.inpaint?.editMaskImage, candidate.inpaint?.blendMaskImage]
.reduce((total, source) => total + (source?.length ?? 0) * 2, 0);
}
@@ -441,11 +476,14 @@ function generationProvenance(candidate: GenerationCandidate, acceptance: Genera
scheduler: candidate.settings.scheduler,
width: candidate.settings.width,
height: candidate.settings.height,
batchSize: candidate.settings.batchSize,
refinePass: candidate.settings.refinePass,
refineStrength: candidate.settings.refineStrength,
},
inpaint: candidate.inpaint
? {
targetLayerId: candidate.inpaint.targetLayerId,
maskLayerId: candidate.inpaint.maskLayerId,
regionId: candidate.inpaint.regionId,
sourceAssetId: candidate.inpaint.sourceAssetId,
maskAssetId: candidate.inpaint.maskAssetId,
crop: {
@@ -465,6 +503,11 @@ function generationProvenance(candidate: GenerationCandidate, acceptance: Genera
maskFeather: candidate.inpaint.backend.maskFeather,
maskExpand: candidate.inpaint.backend.maskExpand,
cropPadding: candidate.inpaint.backend.cropPadding,
profile: candidate.settings.inpaint.profile,
structureControl: candidate.settings.inpaint.structureControl,
controlStrength: candidate.settings.inpaint.controlStrength,
controlModel: candidate.settings.inpaint.controlModel,
colorMatch: candidate.settings.inpaint.colorMatch,
},
}
: undefined,

View File

@@ -30,6 +30,9 @@ export const commandIds = {
documentAddLayerMask: "document.addLayerMask",
documentApplyLayerMaskOperation: "document.applyLayerMaskOperation",
documentRemoveLayerMask: "document.removeLayerMask",
documentAddInpaintRegion: "document.addInpaintRegion",
documentApplyInpaintRegionMaskOperation: "document.applyInpaintRegionMaskOperation",
documentRemoveInpaintRegion: "document.removeInpaintRegion",
selectionSet: "selection.set",
selectionClear: "selection.clear",
selectionAddLayer: "selection.addLayer",
@@ -37,12 +40,17 @@ export const commandIds = {
toolSetGenerateSettings: "tool.setGenerateSettings",
toolChooseGenerateIntent: "tool.chooseGenerateIntent",
toolSetBrushSettings: "tool.setBrushSettings",
toolSetFeatherSettings: "tool.setFeatherSettings",
toolSetChromaKeySettings: "tool.setChromaKeySettings",
toolSetMagicWandSettings: "tool.setMagicWandSettings",
toolSetBrushPreview: "tool.setBrushPreview",
toolSetBrushStrokePreview: "tool.setBrushStrokePreview",
toolBeginMaskShape: "tool.beginMaskShape",
toolAppendMaskShape: "tool.appendMaskShape",
toolClearMaskShape: "tool.clearMaskShape",
toolSetMaskViewMode: "tool.setMaskViewMode",
toolEnterMaskEdit: "tool.enterMaskEdit",
toolEnterInpaintRegionEdit: "tool.enterInpaintRegionEdit",
toolExitMaskEdit: "tool.exitMaskEdit",
toolEnterTemporaryPan: "tool.enterTemporaryPan",
toolExitTemporaryPan: "tool.exitTemporaryPan",
@@ -50,11 +58,13 @@ export const commandIds = {
generationSelectCandidate: "generation.selectCandidate",
generationSetCompareMode: "generation.setCompareMode",
generationRemoveCandidate: "generation.removeCandidate",
generationToggleCandidateFavorite: "generation.toggleCandidateFavorite",
generationClearCandidates: "generation.clearCandidates",
generationReuseCandidateSettings: "generation.reuseCandidateSettings",
generationApplyCandidateAsLayer: "generation.applyCandidateAsLayer",
generationReplaceCandidatePixels: "generation.replaceCandidatePixels",
generationStartJob: "generation.startJob",
generationUpdateJob: "generation.updateJob",
generationSucceedJob: "generation.succeedJob",
generationFailJob: "generation.failJob",
generationCancelJob: "generation.cancelJob",

View File

@@ -101,10 +101,12 @@ export type { CommandRegistry } from "./registry";
export { createCommandRegistry } from "./registry";
export { selectionAddLayerCommand, selectionClearCommand, selectionCommands, selectionSetCommand } from "./selection";
export type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
export { toolChooseGenerateIntentCommand, toolCommands, toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetChromaKeySettingsCommand, toolSetGenerateSettingsCommand, toolSetMagicWandSettingsCommand, toolSetMaskViewModeCommand } from "./tool";
export { toolChooseGenerateIntentCommand, toolCommands, toolEnterInpaintRegionEditCommand, toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetChromaKeySettingsCommand, toolSetGenerateSettingsCommand, toolSetMagicWandSettingsCommand, toolSetMaskViewModeCommand } from "./tool";
export { transformBeginCommand, transformCommands, transformEndCommand, transformSetBoundsCommand, transformSetRotationCommand, transformUpdateCommand } from "./transform";
export type { TransformBeginPayload, TransformSetBoundsPayload, TransformSetRotationPayload, TransformUpdatePayload } from "./transform";
export type { ToolChooseGenerateIntentPayload, ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool";
export type { ToolChooseGenerateIntentPayload, ToolEnterInpaintRegionEditPayload, ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool";
export { inpaintRegionCommands } from "./inpaint-region";
export type { DocumentAddInpaintRegionPayload, DocumentApplyInpaintRegionMaskOperationPayload, DocumentRemoveInpaintRegionPayload } from "./inpaint-region";
export {
viewportCommands,
viewportPanCommand,

View File

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

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

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

View File

@@ -35,11 +35,13 @@ import type {
GenerationAddCandidatePayload,
GenerationApplyCandidateAsLayerPayload,
GenerationRemoveCandidatePayload,
GenerationToggleCandidateFavoritePayload,
GenerationReuseCandidateSettingsPayload,
GenerationReplaceCandidatePixelsPayload,
GenerationSelectCandidatePayload,
GenerationSetCompareModePayload,
GenerationStartJobPayload,
GenerationUpdateJobPayload,
GenerationSucceedJobPayload,
GenerationFailJobPayload,
GenerationCancelJobPayload,
@@ -52,11 +54,12 @@ import type {
CommandPaletteSetSelectedIndexPayload,
} from "./palette";
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
import type { ToolChooseGenerateIntentPayload, ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool";
import type { ToolAppendMaskShapePayload, ToolBeginMaskShapePayload, ToolChooseGenerateIntentPayload, ToolEnterInpaintRegionEditPayload, ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetFeatherSettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool";
import type { TransformBeginPayload, TransformSetBoundsPayload, TransformSetRotationPayload, TransformUpdatePayload } from "./transform";
import type { WorkspaceSetPanelPayload } from "./workspace";
import type { EditorSetPointerSessionPayload } from "./editor";
import type { ProjectOpenPayload } from "./project";
import type { DocumentAddInpaintRegionPayload, DocumentApplyInpaintRegionMaskOperationPayload, DocumentRemoveInpaintRegionPayload } from "./inpaint-region";
import type {
ViewportFitArtboardPayload,
ViewportPanPayload,
@@ -86,6 +89,9 @@ export type CommandPayloads = {
[commandIds.documentAddLayerMask]: DocumentAddLayerMaskPayload;
[commandIds.documentApplyLayerMaskOperation]: DocumentApplyLayerMaskOperationPayload;
[commandIds.documentRemoveLayerMask]: DocumentRemoveLayerMaskPayload;
[commandIds.documentAddInpaintRegion]: DocumentAddInpaintRegionPayload;
[commandIds.documentApplyInpaintRegionMaskOperation]: DocumentApplyInpaintRegionMaskOperationPayload;
[commandIds.documentRemoveInpaintRegion]: DocumentRemoveInpaintRegionPayload;
[commandIds.documentMoveLayer]: DocumentMoveLayerPayload;
[commandIds.documentGroupLayers]: DocumentGroupLayersPayload;
[commandIds.documentUngroupLayer]: DocumentUngroupLayerPayload;
@@ -104,12 +110,17 @@ export type CommandPayloads = {
[commandIds.toolSetGenerateSettings]: ToolSetGenerateSettingsPayload;
[commandIds.toolChooseGenerateIntent]: ToolChooseGenerateIntentPayload;
[commandIds.toolSetBrushSettings]: ToolSetBrushSettingsPayload;
[commandIds.toolSetFeatherSettings]: ToolSetFeatherSettingsPayload;
[commandIds.toolSetChromaKeySettings]: ToolSetChromaKeySettingsPayload;
[commandIds.toolSetMagicWandSettings]: ToolSetMagicWandSettingsPayload;
[commandIds.toolSetBrushPreview]: ToolSetBrushPreviewPayload;
[commandIds.toolSetBrushStrokePreview]: ToolSetBrushStrokePreviewPayload;
[commandIds.toolBeginMaskShape]: ToolBeginMaskShapePayload;
[commandIds.toolAppendMaskShape]: ToolAppendMaskShapePayload;
[commandIds.toolClearMaskShape]: void;
[commandIds.toolSetMaskViewMode]: ToolSetMaskViewModePayload;
[commandIds.toolEnterMaskEdit]: ToolEnterMaskEditPayload;
[commandIds.toolEnterInpaintRegionEdit]: ToolEnterInpaintRegionEditPayload;
[commandIds.toolExitMaskEdit]: void;
[commandIds.toolEnterTemporaryPan]: void;
[commandIds.toolExitTemporaryPan]: void;
@@ -117,11 +128,13 @@ export type CommandPayloads = {
[commandIds.generationSelectCandidate]: GenerationSelectCandidatePayload;
[commandIds.generationSetCompareMode]: GenerationSetCompareModePayload;
[commandIds.generationRemoveCandidate]: GenerationRemoveCandidatePayload;
[commandIds.generationToggleCandidateFavorite]: GenerationToggleCandidateFavoritePayload;
[commandIds.generationClearCandidates]: void;
[commandIds.generationReuseCandidateSettings]: GenerationReuseCandidateSettingsPayload;
[commandIds.generationApplyCandidateAsLayer]: GenerationApplyCandidateAsLayerPayload;
[commandIds.generationReplaceCandidatePixels]: GenerationReplaceCandidatePixelsPayload;
[commandIds.generationStartJob]: GenerationStartJobPayload;
[commandIds.generationUpdateJob]: GenerationUpdateJobPayload;
[commandIds.generationSucceedJob]: GenerationSucceedJobPayload;
[commandIds.generationFailJob]: GenerationFailJobPayload;
[commandIds.generationCancelJob]: GenerationCancelJobPayload;

View File

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

View File

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

View File

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

View File

@@ -10,12 +10,14 @@ export const workspaceSetPanelCommand: Command<WorkspaceSetPanelPayload> = {
history: { mode: "ignore" },
execute({ state }, payload) {
if (!workspacePanels.has(payload.panel)) return state;
if (state.editor.workspace.panel === payload.panel) return state;
if (state.editor.workspace.panel === payload.panel && !state.editor.brushPreview && !state.editor.brushStrokePreview) return state;
return {
...state,
editor: {
...state.editor,
workspace: { panel: payload.panel },
brushPreview: undefined,
brushStrokePreview: undefined,
},
};
},

View File

@@ -1,5 +1,5 @@
import type { Rect, Size } from "./geometry";
import type { AssetId, GenerationCandidateId, LayerId } from "./id";
import type { AssetId, GenerationCandidateId, InpaintRegionId, LayerId } from "./id";
export type GeneratedAssetMode = "text-to-image" | "image-to-image" | "inpaint" | "outpaint";
@@ -26,10 +26,13 @@ export type AssetGenerationProvenance = {
scheduler: string;
width: number;
height: number;
batchSize: number;
refinePass: boolean;
refineStrength: number;
};
inpaint?: {
targetLayerId: LayerId;
maskLayerId: LayerId;
regionId: InpaintRegionId;
sourceAssetId: AssetId;
maskAssetId: AssetId;
crop: {
@@ -49,6 +52,11 @@ export type AssetGenerationProvenance = {
maskFeather: number;
maskExpand: number;
cropPadding: number;
profile: string;
structureControl: string;
controlStrength: number;
controlModel: string;
colorMatch: boolean;
};
};
};

View File

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

View File

@@ -5,5 +5,6 @@ export type DocumentId = OpaqueId<"DocumentId">;
export type ArtboardId = OpaqueId<"ArtboardId">;
export type LayerId = OpaqueId<"LayerId">;
export type AssetId = OpaqueId<"AssetId">;
export type InpaintRegionId = OpaqueId<"InpaintRegionId">;
export type GenerationCandidateId = OpaqueId<"GenerationCandidateId">;
export type GenerationJobId = OpaqueId<"GenerationJobId">;

View File

@@ -13,7 +13,8 @@ export type {
Transform,
Vec2D,
} from "./geometry";
export type { ArtboardId, AssetId, DocumentId, GenerationCandidateId, GenerationJobId, LayerId } from "./id";
export type { ArtboardId, AssetId, DocumentId, GenerationCandidateId, GenerationJobId, InpaintRegionId, LayerId } from "./id";
export type { InpaintRegion } from "./inpaint-region";
export type { ImageLayer } from "./image-layer";
export type { AdjustmentLayer, ColorAdjustment } from "./adjustment-layer";
export { neutralColorAdjustment } from "./adjustment-layer";

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

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

View File

@@ -4,6 +4,7 @@ import type { Layer } from "@core/layer";
import { createDocumentReadIndex, forEachLayerBackToFront, resolveIndexedLayerBounds } from "./document-indexes";
const document: ImageDocument = {
inpaintRegions: [],
id: "d1",
name: "Indexed Document",
version: 1,

View File

@@ -1,5 +1,5 @@
export type { AppState, EditorState, SelectionState, ViewportState } from "./state";
export type { BrushSettings, ChromaKeySettings, MagicWandSettings, InteractionMode, OperationId, ToolId, ToolState } from "./tools";
export type { BrushSettings, ChromaKeySettings, FeatherSettings, MagicWandSettings, InteractionMode, OperationId, ToolId, ToolState } from "./tools";
export { initialToolState } from "./tools";
export { createInitialAppState, initialEditorState } from "./initial-state";
export type { AppStore, StateListener } from "./store";

View File

@@ -31,6 +31,7 @@ export const initialEditorState: EditorState = {
maskEdit: undefined,
brushPreview: undefined,
brushStrokePreview: undefined,
maskShapeSession: undefined,
pointerSession: undefined,
};
@@ -42,6 +43,7 @@ export function createInitialAppState(name = "Untitled"): AppState {
version: 1,
artboards: [],
assets: [],
inpaintRegions: [],
},
editor: initialEditorState,
history: { past: [], future: [] },

View File

@@ -1,6 +1,6 @@
import type { ImageDocument } from "@core/document";
import type { Angle, Rect, Size, Transform, Vec2D } from "@core/geometry";
import type { ArtboardId, AssetId, GenerationCandidateId, GenerationJobId, LayerId } from "@core/id";
import type { ArtboardId, AssetId, GenerationCandidateId, GenerationJobId, InpaintRegionId, LayerId } from "@core/id";
import type { GenerateArchitecture, GenerateMode, GenerateSettings, ToolState } from "./tools";
import type { TransformSession } from "./transform";
@@ -19,8 +19,11 @@ export type SelectionState = {
export type MaskViewMode = "composite" | "blackWhite" | "alpha" | "overlay";
export type MaskEditState = {
kind: "layerMask" | "inpaintRegion";
targetLayerId: LayerId;
maskLayerId: LayerId;
maskAssetId: AssetId;
maskLayerId?: LayerId;
inpaintRegionId?: InpaintRegionId;
viewMode?: MaskViewMode;
};
@@ -32,6 +35,14 @@ export type BrushStrokePreviewState = {
layerId: LayerId;
assetId: AssetId;
source: string;
pendingTargetLayerId?: LayerId;
intrinsicSize?: Size;
};
export type MaskShapeSession = {
shape: "lasso" | "rectangle";
points: Vec2D[];
mode: "replace" | "add" | "subtract";
};
export type GenerationCandidate = {
@@ -42,10 +53,12 @@ export type GenerationCandidate = {
mode: GenerateSettings["mode"];
settings: GenerateSettings;
seed: number;
favorite?: boolean;
width: number;
height: number;
inputImage?: string;
maskImage?: string;
blendMaskImage?: string;
placement: {
artboardId: ArtboardId;
layerName: string;
@@ -53,11 +66,14 @@ export type GenerationCandidate = {
};
inpaint?: {
targetLayerId: LayerId;
maskLayerId: LayerId;
regionId: InpaintRegionId;
sourceAssetId: AssetId;
maskAssetId: AssetId;
inputImage: string;
maskImage: string;
editMaskImage: string;
blendMaskImage: string;
revision: { source: string; mask: string };
crop: {
assetBounds: Rect;
documentBounds: Rect;
@@ -81,7 +97,7 @@ export type GenerationCandidate = {
export type GenerationCompareMode = "result" | "before" | "split";
export type GenerationJobKind = "generate" | "regenerate" | "refine" | "replace";
export type GenerationJobKind = "generate" | "regenerate" | "refine" | "replace" | "mask";
export type GenerationJobStatus = "running" | "succeeded" | "failed" | "cancelled";
export type GenerationJob = {
@@ -92,6 +108,8 @@ export type GenerationJob = {
startedAt: number;
finishedAt?: number;
error?: string;
progress?: number;
detail?: string;
};
export type GenerationState = {
@@ -113,10 +131,15 @@ export type GenerationArchitectureOption = {
export type GenerationOptions = {
architectures?: GenerationArchitectureOption[];
models?: string[];
inpaintModels?: string[];
textEncoders?: string[];
vaes?: string[];
samplers?: string[];
schedulers?: string[];
controlModels?: string[];
structureControls?: Array<"canny" | "depth" | "pose">;
semanticSelection?: boolean;
sam3Models?: string[];
};
export type GenerationResourcesState = { status: "idle" | "loading" | "ready" | "failed"; options?: GenerationOptions; error?: string };
@@ -129,6 +152,10 @@ export type CommandPaletteState = {
export type WorkspacePanel = "none" | "generate" | "chromaKey" | "layers";
export function isOperationWorkspacePanel(panel: WorkspacePanel): boolean {
return panel === "generate" || panel === "chromaKey";
}
export type WorkspaceState = {
panel: WorkspacePanel;
};
@@ -144,6 +171,7 @@ export type EditorState = {
maskEdit?: MaskEditState;
brushPreview?: BrushPreviewState;
brushStrokePreview?: BrushStrokePreviewState;
maskShapeSession?: MaskShapeSession;
pointerSession?: { type: "pan" };
};

View File

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

View File

@@ -4,6 +4,7 @@ import { resolveTransformTargetBounds, selectedTransformTarget } from "./transfo
import { applyTransformTargetBounds } from "@commands/transform-document";
const document: ImageDocument = {
inpaintRegions: [],
id: "d1",
name: "Test",
version: 1,

View File

@@ -6,6 +6,7 @@ const server = serve({
routes: {
"/api/comfy/models": handleComfyApi,
"/api/comfy/generate": handleComfyApi,
"/api/comfy/segment": handleComfyApi,
"/*": index,
},

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import type { KeybindEvent } from "./keyboard";
const toolKeybinds = {
b: "brush",
e: "eraser",
f: "feather",
w: "magicWand",
p: "pan",
s: "select",

View File

@@ -16,7 +16,7 @@ import type { PointerInputEvent } from "./pointer";
type TransformHandle = "body" | "nw" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w";
type InputToolId = "select" | "brush" | "eraser" | "magicWand" | "pan";
type InputToolId = "select" | "brush" | "eraser" | "feather" | "magicWand" | "semanticSelect" | "maskLasso" | "maskRectangle" | "pan";
type InputInteractionMode =
| { type: "tool"; tool: InputToolId }

View File

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

View File

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

View File

@@ -1,22 +1,28 @@
import type { Asset } from "@core/asset";
import type { ImageDocument } from "@core/document";
import type { Rect } from "@core/geometry";
import type { AssetId, InpaintRegionId, LayerId } from "@core/id";
import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
import type { SelectionState } from "@editor/state";
import type { GenerateSettings } from "@editor/tools";
import { createDocumentReadIndex, resolveIndexedLayerBounds } from "@editor/document-indexes";
import { createNormalizedMaskSource, cropCanvas, cropMaskValuesToDataUrl, expandRectWithinBounds, loadImageCanvas } from "@platform/browser/maskRaster";
import { createNormalizedMaskSource, cropCanvas, cropMaskValuesToDataUrl, expandRectWithinBounds, loadImageCanvas, sampleDocumentCanvasInLayerSpace } from "@platform/browser/maskRaster";
import { renderArtboardCanvas } from "@platform/browser/exportArtboardPng";
export type InpaintBundle = {
inputImage: string;
sourceImage: string;
contextImage: string;
maskImage: string;
editMaskImage: string;
blendMaskImage: string;
width: number;
height: number;
targetLayerId: string;
maskLayerId: string;
sourceAssetId: string;
maskAssetId: string;
targetLayerId: LayerId;
regionId: InpaintRegionId;
sourceAssetId: AssetId;
maskAssetId: AssetId;
revision: { source: string; mask: string };
crop: {
assetBounds: Rect;
documentBounds: Rect;
@@ -46,10 +52,8 @@ type InpaintTarget = {
artboardId: string;
layer: Extract<Layer, { type: "image" | "raster" }>;
asset: Asset;
bounds: Rect;
maskLayer: Extract<Layer, { type: "image" | "raster" }>;
maskAsset: Asset;
maskBounds: Rect;
regionId: InpaintRegionId;
};
const modelMultiple = 8;
@@ -62,41 +66,65 @@ export async function buildInpaintBundle(document: ImageDocument, selection: Sel
const width = Math.max(1, Math.round(target.asset.intrinsicSize.w));
const height = Math.max(1, Math.round(target.asset.intrinsicSize.h));
const normalizedMask = await createNormalizedMaskSource(target.maskAsset.source, width, height, {
polarity: settings.inpaint.maskPolarity,
const sourceLimit = target.layer.sourceRect ?? { x: 0, y: 0, w: width, h: height };
const editMask = await createNormalizedMaskSource(target.maskAsset.source, width, height, {
polarity: "revealed",
despeckle: settings.inpaint.maskDespeckle,
limit: sourceLimit,
});
const noiseMask = await createNormalizedMaskSource(target.maskAsset.source, width, height, {
polarity: "revealed",
expand: settings.inpaint.maskExpand,
feather: settings.inpaint.maskFeather,
blur: settings.inpaint.maskBlur,
despeckle: settings.inpaint.maskDespeckle,
limit: sourceLimit,
});
const blendMask = await createNormalizedMaskSource(target.maskAsset.source, width, height, {
polarity: "revealed",
feather: settings.inpaint.maskFeather,
despeckle: settings.inpaint.maskDespeckle,
limit: sourceLimit,
});
if (!normalizedMask.bounds) throw new Error("The selected layer mask has no inpaint pixels.");
if (!editMask.bounds || !noiseMask.bounds) throw new Error("Paint over the area you want AI to replace.");
const crop = settings.inpaint.maskedAreaOnly
? expandRectWithinBounds(normalizedMask.bounds, settings.inpaint.cropPadding, { w: width, h: height }, modelMultiple, minModelSize)
? expandRectWithinBounds(noiseMask.bounds, settings.inpaint.cropPadding, { w: width, h: height }, modelMultiple, minModelSize)
: { x: 0, y: 0, w: width, h: height };
const outputWidth = toModelSize(crop.w, "width");
const outputHeight = toModelSize(crop.h, "height");
const inputCanvas = prepareMaskedContentInputCanvas(await loadImageCanvas(target.asset.source, width, height), normalizedMask.values, settings.inpaint.maskedContent);
const inputImage = cropCanvas(inputCanvas, crop, outputWidth, outputHeight);
const maskImage = cropMaskValuesToDataUrl(normalizedMask.values, width, height, crop, outputWidth, outputHeight);
const scaleX = target.bounds.w / width;
const scaleY = target.bounds.h / height;
const sourceCanvas = await loadImageCanvas(target.asset.source, width, height);
const sourceImage = cropCanvas(sourceCanvas, crop, outputWidth, outputHeight);
const preparedCanvas = prepareMaskedContentInputCanvas(await loadImageCanvas(target.asset.source, width, height), editMask.values, settings.inpaint.maskedContent);
const preparedSourceImage = cropCanvas(preparedCanvas, crop, outputWidth, outputHeight);
const artboard = document.artboards.find((candidate) => candidate.id === target.artboardId);
if (!artboard) throw new Error("The target artboard no longer exists.");
const contextImage = sampleDocumentCanvasInLayerSpace(await renderArtboardCanvas(artboard, document.assets), artboard.bounds, target.layer, target.asset.intrinsicSize, crop, outputWidth, outputHeight);
const inputImage = await mergePreparedRegionIntoContext(contextImage, preparedSourceImage, cropMaskValuesToDataUrl(editMask.values, width, height, crop, outputWidth, outputHeight), outputWidth, outputHeight);
const maskImage = cropMaskValuesToDataUrl(noiseMask.values, width, height, crop, outputWidth, outputHeight);
const editMaskImage = cropMaskValuesToDataUrl(editMask.values, width, height, crop, outputWidth, outputHeight);
const blendMaskImage = cropMaskValuesToDataUrl(blendMask.values, width, height, crop, outputWidth, outputHeight);
const scaleX = target.layer.transform.scale.x;
const scaleY = target.layer.transform.scale.y;
const documentBounds = {
x: target.bounds.x + crop.x * scaleX,
y: target.bounds.y + crop.y * scaleY,
x: target.layer.transform.position.x + crop.x * scaleX,
y: target.layer.transform.position.y + crop.y * scaleY,
w: outputWidth * scaleX,
h: outputHeight * scaleY,
};
return {
inputImage,
sourceImage,
contextImage,
maskImage,
editMaskImage,
blendMaskImage,
width: outputWidth,
height: outputHeight,
targetLayerId: target.layer.id,
maskLayerId: target.maskLayer.id,
regionId: target.regionId,
sourceAssetId: target.asset.id,
maskAssetId: target.maskAsset.id,
crop: {
@@ -106,14 +134,14 @@ export async function buildInpaintBundle(document: ImageDocument, selection: Sel
maskedAreaOnly: settings.inpaint.maskedAreaOnly,
},
mask: {
polarity: settings.inpaint.maskPolarity,
activeBounds: normalizedMask.bounds,
polarity: "revealed",
activeBounds: editMask.bounds,
},
placement: {
artboardId: target.artboardId,
layerName: `${target.layer.name} inpaint`,
transform: {
position: { x: documentBounds.x, y: documentBounds.y },
position: rotatedCropPosition(target.layer, target.asset, { ...crop, w: outputWidth, h: outputHeight }),
scale: { x: documentBounds.w / outputWidth, y: documentBounds.h / outputHeight },
rotation: target.layer.transform.rotation,
},
@@ -126,6 +154,10 @@ export async function buildInpaintBundle(document: ImageDocument, selection: Sel
maskExpand: settings.inpaint.maskExpand,
cropPadding: settings.inpaint.cropPadding,
},
revision: {
source: await createContentRevision(target.asset.source),
mask: await createContentRevision(target.maskAsset.source),
},
};
}
@@ -138,20 +170,14 @@ function resolveInpaintTarget(document: ImageDocument, selection: SelectionState
const asset = documentIndex.assetById.get(layerInfo.layer.assetId);
if (!asset) throw new Error("The selected layer is missing its source image.");
const layerMask = getLayerMask(layerInfo.layer);
if (!layerMask?.enabled) throw new Error("Add a layer mask before running inpaint.");
const region = document.inpaintRegions.find((candidate) => candidate.targetLayerId === layerInfo.layer.id && candidate.enabled);
if (!region) throw new Error("Add an AI edit region before running inpaint.");
const maskAsset = documentIndex.assetById.get(region.maskAssetId);
if (!maskAsset) throw new Error("The AI edit region is missing its mask data.");
const maskLayer = documentIndex.layerById.get(layerMask.maskLayerId);
if (!maskLayer || (maskLayer.type !== "image" && maskLayer.type !== "raster")) throw new Error("The selected layer mask is missing.");
if (!resolveIndexedLayerBounds(documentIndex, layerInfo.layer)) throw new Error("Unable to resolve the selected layer bounds.");
const maskAsset = documentIndex.assetById.get(maskLayer.assetId);
if (!maskAsset) throw new Error("The selected layer mask is missing its image data.");
const bounds = resolveIndexedLayerBounds(documentIndex, layerInfo.layer);
const maskBounds = resolveIndexedLayerBounds(documentIndex, maskLayer);
if (!bounds || !maskBounds) throw new Error("Unable to resolve the selected layer and mask bounds.");
return { artboardId: layerInfo.artboardId, layer: layerInfo.layer, asset, bounds, maskLayer, maskAsset, maskBounds };
return { artboardId: layerInfo.artboardId, layer: layerInfo.layer, asset, maskAsset, regionId: region.id };
}
function validateInpaintTarget(target: InpaintTarget) {
@@ -160,13 +186,6 @@ function validateInpaintTarget(target: InpaintTarget) {
if (Math.round(target.asset.intrinsicSize.w) !== Math.round(target.maskAsset.intrinsicSize.w) || Math.round(target.asset.intrinsicSize.h) !== Math.round(target.maskAsset.intrinsicSize.h)) {
throw new Error("The selected layer and mask image sizes do not match.");
}
if (!rectsAligned(target.bounds, target.maskBounds) || Math.abs(target.layer.transform.rotation - target.maskLayer.transform.rotation) > 0.001) {
throw new Error("The selected layer and mask are not aligned.");
}
}
function rectsAligned(a: Rect, b: Rect): boolean {
return Math.abs(a.x - b.x) <= 0.5 && Math.abs(a.y - b.y) <= 0.5 && Math.abs(a.w - b.w) <= 0.5 && Math.abs(a.h - b.h) <= 0.5;
}
function toModelSize(value: number, axis: "width" | "height"): number {
@@ -258,3 +277,52 @@ function blendChannel(previous: number, next: number, amount: number): number {
function clampNumber(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
async function mergePreparedRegionIntoContext(contextSource: string, preparedSource: string, editMaskSource: string, width: number, height: number): Promise<string> {
const [contextCanvas, preparedCanvas, maskCanvas] = await Promise.all([
loadImageCanvas(contextSource, width, height),
loadImageCanvas(preparedSource, width, height),
loadImageCanvas(editMaskSource, width, height),
]);
const context = contextCanvas.getContext("2d");
const prepared = preparedCanvas.getContext("2d");
const mask = maskCanvas.getContext("2d");
if (!context || !prepared || !mask) return contextSource;
const contextData = context.getImageData(0, 0, width, height);
const preparedData = prepared.getImageData(0, 0, width, height);
const maskData = mask.getImageData(0, 0, width, height);
for (let pixel = 0; pixel < width * height; pixel += 1) {
const amount = (maskData.data[pixel * 4] ?? 0) / 255;
if (amount <= 0) continue;
for (let channel = 0; channel < 4; channel += 1) {
const index = pixel * 4 + channel;
contextData.data[index] = blendChannel(contextData.data[index] ?? 0, preparedData.data[index] ?? 0, amount);
}
}
context.putImageData(contextData, 0, 0);
return contextCanvas.toDataURL("image/png");
}
function rotatedCropPosition(layer: InpaintTarget["layer"], asset: Asset, crop: Rect) {
const source = layer.sourceRect ?? { x: 0, y: 0, ...asset.intrinsicSize };
const scale = layer.transform.scale;
const originalCenter = {
x: layer.transform.position.x + (source.x + source.w / 2) * scale.x,
y: layer.transform.position.y + (source.y + source.h / 2) * scale.y,
};
const cropCenter = {
x: layer.transform.position.x + (crop.x + crop.w / 2) * scale.x,
y: layer.transform.position.y + (crop.y + crop.h / 2) * scale.y,
};
const dx = cropCenter.x - originalCenter.x;
const dy = cropCenter.y - originalCenter.y;
const cos = Math.cos(layer.transform.rotation);
const sin = Math.sin(layer.transform.rotation);
const rotatedCenter = { x: originalCenter.x + dx * cos - dy * sin, y: originalCenter.y + dx * sin + dy * cos };
return { x: rotatedCenter.x - crop.w * scale.x / 2, y: rotatedCenter.y - crop.h * scale.y / 2 };
}
export async function createContentRevision(source: string): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(source));
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
}

View File

@@ -42,8 +42,8 @@ describe("generated output placement", () => {
settings: settings("inpaint"),
intrinsicSize: { w: 128, h: 64 },
inpaintBundle: {
inputImage: "input", maskImage: "mask", width: 256, height: 128,
targetLayerId: "source", maskLayerId: "mask", sourceAssetId: "source-asset", maskAssetId: "mask-asset",
inputImage: "input", sourceImage: "source", contextImage: "context", maskImage: "mask", editMaskImage: "edit", blendMaskImage: "blend", width: 256, height: 128,
targetLayerId: "source", regionId: "region", sourceAssetId: "source-asset", maskAssetId: "mask-asset", revision: { source: "source-revision", mask: "mask-revision" },
crop: { assetBounds: { x: 0, y: 0, w: 256, h: 128 }, documentBounds: { x: 140, y: 150, w: 384, h: 64 }, padding: 16, maskedAreaOnly: true },
mask: { polarity: "hidden", activeBounds: { x: 20, y: 20, w: 40, h: 40 } },
placement: { artboardId: "artboard", layerName: "Source inpaint", transform: { position: { x: 140, y: 150 }, scale: { x: 1.5, y: 0.5 }, rotation: 12 } },
@@ -81,7 +81,7 @@ function selected() {
function document(): ImageDocument {
return {
id: "document", name: "Test", version: 1,
id: "document", name: "Test", version: 1, inpaintRegions: [],
assets: [
{ id: "source-asset", name: "Source", mimeType: "image/png", source: "source", intrinsicSize: { w: 100, h: 500 } },
{ id: "mask-asset", name: "Mask", mimeType: "image/png", source: "mask", intrinsicSize: { w: 100, h: 500 } },

View File

@@ -15,7 +15,7 @@ describe("generation preconditions", () => {
});
test("requires an enabled mask for inpaint", () => {
expect(checkGenerationPreconditions(document(), selected(), settings("inpaint"))).toEqual({ ready: false, message: "Paint a mask over the area you want AI to replace.", repair: "add-mask" });
expect(checkGenerationPreconditions(document(), selected(), settings("inpaint"))).toEqual({ ready: false, message: "Paint an AI edit region over the area you want to replace.", repair: "add-mask" });
});
test("allows inpaint when the selected source has an aligned enabled mask", () => {
@@ -46,6 +46,7 @@ function document(masked = false): ImageDocument {
{ id: "source-asset", name: "Source", mimeType: "image/png", source: "source", intrinsicSize: { w: 100, h: 100 } },
{ id: "mask-asset", name: "Mask", mimeType: "image/png", source: "mask", intrinsicSize: { w: 100, h: 100 } },
],
inpaintRegions: masked ? [{ id: "region", name: "AI edit", targetLayerId: "source", maskAssetId: "mask-asset", enabled: true }] : [],
artboards: [{
id: "artboard",
name: "Artboard",
@@ -64,7 +65,6 @@ function document(masked = false): ImageDocument {
opacity: 1,
assetId: "source-asset",
transform,
layerMask: masked ? { kind: "raster", maskLayerId: "mask", enabled: true, inverted: false } : undefined,
},
],
}],

View File

@@ -1,5 +1,4 @@
import type { ImageDocument } from "@core/document";
import { getLayerMask } from "@core/layer-mask-utils";
import { createDocumentReadIndex, resolveIndexedLayerBounds } from "@editor/document-indexes";
import type { SelectionState } from "@editor/state";
import type { GenerateSettings } from "@editor/tools";
@@ -44,21 +43,15 @@ export function checkGenerationPreconditions(
if (settings.mode !== "inpaint") return { ready: true };
const layerMask = getLayerMask(layerInfo.layer);
if (!layerMask?.enabled) return missing("Paint a mask over the area you want AI to replace.", "add-mask");
const maskLayer = index.layerById.get(layerMask.maskLayerId);
if (!maskLayer || (maskLayer.type !== "image" && maskLayer.type !== "raster")) return missing("The selected layer mask is missing.");
const maskAsset = index.assetById.get(maskLayer.assetId);
if (!maskAsset) return missing("The selected layer mask is missing its image data.");
const region = document.inpaintRegions.find((candidate) => candidate.targetLayerId === layerInfo.layer.id && candidate.enabled);
if (!region) return missing("Paint an AI edit region over the area you want to replace.", "add-mask");
const maskAsset = index.assetById.get(region.maskAssetId);
if (!maskAsset) return missing("The AI edit region is missing its mask data.");
if (Math.round(asset.intrinsicSize.w) !== Math.round(maskAsset.intrinsicSize.w) || Math.round(asset.intrinsicSize.h) !== Math.round(maskAsset.intrinsicSize.h)) {
return missing("The selected layer and mask image sizes must match.");
}
const layerBounds = resolveIndexedLayerBounds(index, layerInfo.layer);
const maskBounds = resolveIndexedLayerBounds(index, maskLayer);
if (!layerBounds || !maskBounds || !rectsAligned(layerBounds, maskBounds) || Math.abs(layerInfo.layer.transform.rotation - maskLayer.transform.rotation) > 0.001) {
return missing("Align the selected layer and its mask before inpainting.");
}
if (!resolveIndexedLayerBounds(index, layerInfo.layer)) return missing("The selected layer has invalid geometry.");
return { ready: true };
}
@@ -72,7 +65,3 @@ function modeSelectionMessage(mode: GenerateSettings["mode"]): string {
function missing(message: string, repair?: Extract<GenerationPrecondition, { ready: false }>["repair"]): GenerationPrecondition {
return { ready: false, message, ...(repair ? { repair } : {}) };
}
function rectsAligned(a: { x: number; y: number; w: number; h: number }, b: { x: number; y: number; w: number; h: number }): boolean {
return Math.abs(a.x - b.x) <= 0.5 && Math.abs(a.y - b.y) <= 0.5 && Math.abs(a.w - b.w) <= 0.5 && Math.abs(a.h - b.h) <= 0.5;
}

View File

@@ -18,6 +18,7 @@ export async function runGenerate(options: {
settings: GenerateSettings;
dispatch: AppStore["dispatch"];
signal?: AbortSignal;
onProgress?: (progress: number, detail: string) => void;
}) {
const { document, selection, settings, dispatch } = options;
const precondition = checkGenerationPreconditions(document, selection, settings);
@@ -41,17 +42,18 @@ export async function runGenerate(options: {
maskImage,
inpaintBundle,
signal: options.signal,
onProgress: options.onProgress,
});
const intrinsicSize = await loadImageSize(generated.source);
for (const result of generated.results) {
const intrinsicSize = await loadImageSize(result.source);
const placement = resolveGeneratedOutputPlacement({ document, selection, settings, intrinsicSize, inpaintBundle });
dispatch(commandIds.generationAddCandidate, {
candidate: createGenerationCandidate({
source: generated.source,
mimeType: generated.mimeType,
source: result.source,
mimeType: result.mimeType,
intrinsicSize,
settings: requestSettings,
seed,
seed: result.seed || seed,
width,
height,
inputImage,
@@ -60,6 +62,7 @@ export async function runGenerate(options: {
inpaintBundle,
}),
});
}
}
export async function runGenerateFromCandidate(options: {
@@ -67,6 +70,7 @@ export async function runGenerateFromCandidate(options: {
settings?: GenerateSettings;
dispatch: AppStore["dispatch"];
signal?: AbortSignal;
onProgress?: (progress: number, detail: string) => void;
}) {
const settings = options.settings ?? options.candidate.settings;
const seed = resolveSeed(settings.seed);
@@ -79,20 +83,22 @@ export async function runGenerateFromCandidate(options: {
maskImage: options.candidate.maskImage,
inpaintCandidate: options.candidate,
signal: options.signal,
onProgress: options.onProgress,
});
const intrinsicSize = await loadImageSize(generated.source);
for (const result of generated.results) {
const intrinsicSize = await loadImageSize(result.source);
options.dispatch(commandIds.generationAddCandidate, {
candidate: {
...options.candidate,
id: crypto.randomUUID(),
source: generated.source,
mimeType: generated.mimeType,
source: result.source,
mimeType: result.mimeType,
intrinsicSize,
settings: requestSettings,
seed,
seed: result.seed || seed,
},
});
}
}
function createGenerationCandidate(options: {
@@ -120,15 +126,19 @@ function createGenerationCandidate(options: {
height: options.height,
inputImage: options.inputImage,
maskImage: options.maskImage,
blendMaskImage: options.inpaintBundle?.blendMaskImage,
placement: options.placement,
inpaint: options.inpaintBundle
? {
targetLayerId: options.inpaintBundle.targetLayerId,
maskLayerId: options.inpaintBundle.maskLayerId,
regionId: options.inpaintBundle.regionId,
sourceAssetId: options.inpaintBundle.sourceAssetId,
maskAssetId: options.inpaintBundle.maskAssetId,
inputImage: options.inpaintBundle.inputImage,
maskImage: options.inpaintBundle.maskImage,
editMaskImage: options.inpaintBundle.editMaskImage,
blendMaskImage: options.inpaintBundle.blendMaskImage,
revision: options.inpaintBundle.revision,
crop: options.inpaintBundle.crop,
mask: options.inpaintBundle.mask,
backend: options.inpaintBundle.backend,
@@ -146,6 +156,7 @@ async function requestGenerate(options: {
inpaintBundle?: InpaintBundle;
inpaintCandidate?: GenerationCandidate;
signal?: AbortSignal;
onProgress?: (progress: number, detail: string) => void;
}) {
return requestGeneration({
architecture: options.settings.architecture,
@@ -163,11 +174,14 @@ async function requestGenerate(options: {
scheduler: options.settings.scheduler,
width: options.width,
height: options.height,
batchSize: options.settings.batchSize,
refinePass: options.settings.refinePass,
refineStrength: options.settings.refineStrength,
outpaint: options.settings.outpaint,
inpaint: resolveInpaintRequest(options.inpaintBundle, options.inpaintCandidate, options.settings),
inputImage: options.inputImage,
maskImage: options.maskImage,
}, options.signal);
}, options.signal, options.onProgress);
}
function resolveInpaintRequest(inpaintBundle: InpaintBundle | undefined, inpaintCandidate: GenerationCandidate | undefined, settings: GenerateSettings) {
@@ -182,6 +196,9 @@ function resolveInpaintRequest(inpaintBundle: InpaintBundle | undefined, inpaint
maskPolarity: inpaintBundle.mask.polarity,
crop: inpaintBundle.crop,
placement: inpaintBundle.placement,
structureControl: settings.inpaint.structureControl,
controlStrength: settings.inpaint.controlStrength,
controlModel: settings.inpaint.controlModel,
};
}
@@ -196,6 +213,9 @@ function resolveInpaintRequest(inpaintBundle: InpaintBundle | undefined, inpaint
maskPolarity: inpaintCandidate.inpaint.mask.polarity,
crop: inpaintCandidate.inpaint.crop,
placement: inpaintCandidate.placement,
structureControl: settings.inpaint.structureControl,
controlStrength: settings.inpaint.controlStrength,
controlModel: settings.inpaint.controlModel,
};
}

View File

@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test";
import { documentCommands } from "@commands/document";
import { inpaintRegionCommands } from "@commands/inpaint-region";
import { generationCommands } from "@commands/generation";
import { commandIds } from "@commands/ids";
import { createCommandRegistry } from "@commands/registry";
@@ -46,14 +47,14 @@ describe("generation workflow", () => {
state.document.assets.push({ id: "source-asset", name: "Source", mimeType: "image/png", source: "source", intrinsicSize: { w: 80, h: 60 } });
state.document.artboards[0]!.layers.push({ id: "source", type: "raster", name: "Source", visible: true, locked: false, opacity: 1, assetId: "source-asset", transform: { position: { x: 4, y: 8 }, scale: { x: 2, y: 2 }, rotation: 0 } });
state.editor.selection = { artboardId: "artboard", layerIds: ["source"] };
const ids = ["mask-asset", "mask-layer"];
const ids = ["mask-asset", "region"];
const workflow = createGenerationWorkflow(app.store, dependencies({ createId: () => ids.shift() ?? "unused" }));
await workflow.prepareInpaintMask();
const next = app.store.getState();
expect(next.document.artboards[0]?.layers[0]).toMatchObject({ id: "mask-layer", transform: { position: { x: 4, y: 8 }, scale: { x: 2, y: 2 }, rotation: 0 } });
expect(next.editor.maskEdit).toEqual({ targetLayerId: "source", maskLayerId: "mask-layer" });
expect(next.document.inpaintRegions).toContainEqual({ id: "region", name: "Source AI edit", targetLayerId: "source", maskAssetId: "mask-asset", enabled: true });
expect(next.editor.maskEdit).toEqual({ kind: "inpaintRegion", targetLayerId: "source", inpaintRegionId: "region", maskAssetId: "mask-asset", viewMode: "overlay" });
});
test("cancels the active operation and records a cancelled job", async () => {
@@ -82,6 +83,7 @@ function dependencies(overrides: Partial<GenerationWorkflowDependencies>): Gener
runGenerateFromCandidate: async () => undefined,
createMaskedPixelReplacementSource: async () => "replacement",
createRefinementMask: async () => "mask",
createInpaintRegionMask: async () => "mask",
loadGenerationResources: async () => undefined,
createId: () => crypto.randomUUID(),
...overrides,
@@ -118,6 +120,6 @@ function createTestApp() {
locked: false,
layers: [],
});
const registry = createCommandRegistry([...documentCommands, ...toolCommands, ...generationCommands]);
const registry = createCommandRegistry([...documentCommands, ...inpaintRegionCommands, ...toolCommands, ...generationCommands]);
return { store: createAppStore(state, registry) };
}

View File

@@ -3,7 +3,7 @@ import type { Layer } from "@core/layer";
import type { GenerationCandidate, GenerationJobKind } from "@editor/state";
import type { AppStore } from "@editor/store";
import type { GenerateSettings } from "@editor/tools";
import { createRefinementMask } from "@operations/masks/rasterActions";
import { createInpaintRegionMask, createRefinementMask } from "@operations/masks/rasterActions";
import { createMaskedPixelReplacementSource } from "./candidateActions";
import { runGenerationJob } from "./generationJob";
import { loadGenerationResources } from "./loadResources";
@@ -17,6 +17,7 @@ export type GenerationWorkflowDependencies = {
runGenerateFromCandidate: typeof runGenerateFromCandidate;
createMaskedPixelReplacementSource: typeof createMaskedPixelReplacementSource;
createRefinementMask: typeof createRefinementMask;
createInpaintRegionMask: typeof createInpaintRegionMask;
loadGenerationResources: typeof loadGenerationResources;
createId(): string;
};
@@ -26,13 +27,14 @@ const defaultDependencies: GenerationWorkflowDependencies = {
runGenerateFromCandidate,
createMaskedPixelReplacementSource,
createRefinementMask,
createInpaintRegionMask,
loadGenerationResources,
createId: () => crypto.randomUUID(),
};
export function createGenerationWorkflow(store: AppStore, dependencies: GenerationWorkflowDependencies = defaultDependencies) {
let activeController: AbortController | undefined;
const job = async (kind: GenerationJobKind, label: string, task: (signal: AbortSignal) => Promise<void>) => {
const job = async (kind: GenerationJobKind, label: string, task: (signal: AbortSignal, report: (progress: number, detail: string) => void) => Promise<void>) => {
if (store.getState().editor.generation.jobs.some((candidate) => candidate.status === "running")) return;
const controller = new AbortController();
activeController = controller;
@@ -60,26 +62,22 @@ export function createGenerationWorkflow(store: AppStore, dependencies: Generati
if (!layer || (layer.type !== "image" && layer.type !== "raster")) return;
const asset = state.document.assets.find((candidate) => candidate.id === layer.assetId);
if (!asset) return;
const source = await dependencies.createRefinementMask(asset.intrinsicSize.w, asset.intrinsicSize.h);
const existing = state.document.inpaintRegions.find((region) => region.targetLayerId === layerId && region.enabled);
if (existing) {
store.dispatch(commandIds.toolEnterInpaintRegionEdit, { targetLayerId: layerId, regionId: existing.id });
return;
}
const source = await dependencies.createInpaintRegionMask(asset.intrinsicSize.w, asset.intrinsicSize.h);
const maskAssetId = dependencies.createId();
const maskLayerId = dependencies.createId();
store.dispatch(commandIds.documentAddLayerMask, {
layerId,
asset: { id: maskAssetId, name: `${layer.name} AI edit mask`, mimeType: "image/png", source, intrinsicSize: { ...asset.intrinsicSize } },
maskLayer: {
id: maskLayerId,
type: "raster",
name: `${layer.name} AI edit mask`,
visible: true,
locked: false,
opacity: 1,
assetId: maskAssetId,
transform: { position: { ...layer.transform.position }, scale: { ...layer.transform.scale }, rotation: layer.transform.rotation },
},
const regionId = dependencies.createId();
store.dispatch(commandIds.documentAddInpaintRegion, {
region: { id: regionId, name: `${layer.name} AI edit`, targetLayerId: layer.id, maskAssetId, enabled: true },
maskAsset: { id: maskAssetId, name: `${layer.name} AI edit mask`, mimeType: "image/png", source, intrinsicSize: { ...asset.intrinsicSize } },
});
store.dispatch(commandIds.toolEnterInpaintRegionEdit, { targetLayerId: layer.id, regionId });
},
generate: () => job("generate", "Generating", async (signal) => {
generate: () => job("generate", "Generating", async (signal, report) => {
const state = store.getState();
await dependencies.runGenerate({
document: state.document,
@@ -88,15 +86,34 @@ export function createGenerationWorkflow(store: AppStore, dependencies: Generati
settings: state.editor.tools.generate,
dispatch: store.dispatch,
signal,
onProgress: report,
});
}),
regenerate: (candidateId: string, settings?: GenerateSettings, label = "Regenerate") =>
job("regenerate", label, async (signal) => {
job("regenerate", label, async (signal, report) => {
const candidate = findCandidate(store, candidateId);
const nextSettings = settings ?? candidate.settings;
store.dispatch(commandIds.toolSetGenerateSettings, nextSettings);
await dependencies.runGenerateFromCandidate({ candidate, settings: nextSettings, dispatch: store.dispatch, signal });
await dependencies.runGenerateFromCandidate({ candidate, settings: nextSettings, dispatch: store.dispatch, signal, onProgress: report });
}),
rebuildFromCurrentRegion: (candidateId: string) =>
job("regenerate", "Rebuilding from current edit region", async (signal, report) => {
const candidate = findCandidate(store, candidateId);
if (!candidate.inpaint) throw new Error("Only inpaint candidates can rebuild from an edit region.");
store.dispatch(commandIds.selectionSet, { artboardId: candidate.placement.artboardId, layerIds: [candidate.inpaint.targetLayerId] });
store.dispatch(commandIds.toolSetGenerateSettings, candidate.settings);
const state = store.getState();
await dependencies.runGenerate({
document: state.document,
selection: state.editor.selection,
viewport: state.editor.viewport,
settings: candidate.settings,
dispatch: store.dispatch,
signal,
onProgress: report,
});
}),
applyCandidateAsLayer: (candidateId: string) => {

52
operations/masks/lasso.ts Normal file
View File

@@ -0,0 +1,52 @@
import { commandIds } from "@commands/ids";
import type { Vec2D } from "@core/geometry";
import type { Layer } from "@core/layer";
import type { AppStore } from "@editor/store";
import { applyPolygonMask } from "@platform/browser/maskRaster";
export async function commitInpaintLasso(store: AppStore) {
const state = store.getState();
const edit = state.editor.maskEdit;
const session = state.editor.maskShapeSession;
const minimumPoints = session?.shape === "rectangle" ? 2 : 3;
if (edit?.kind !== "inpaintRegion" || !edit.inpaintRegionId || !session || session.points.length < minimumPoints) {
store.dispatch(commandIds.toolClearMaskShape, undefined);
return;
}
const region = state.document.inpaintRegions.find((candidate) => candidate.id === edit.inpaintRegionId);
const target = findLayer(state.document.artboards.flatMap((artboard) => artboard.layers), edit.targetLayerId);
const asset = region ? state.document.assets.find((candidate) => candidate.id === region.maskAssetId) : undefined;
if (!region || !target || (target.type !== "image" && target.type !== "raster") || !asset) {
store.dispatch(commandIds.toolClearMaskShape, undefined);
return;
}
const documentPoints = session.shape === "rectangle" ? rectanglePoints(session.points[0]!, session.points[1]!) : session.points;
const points = documentPoints.map((point) => documentPointToAssetPoint(point, target, asset.intrinsicSize));
const source = await applyPolygonMask(asset.source, asset.intrinsicSize.w, asset.intrinsicSize.h, points, session.mode);
store.dispatch(commandIds.documentApplyInpaintRegionMaskOperation, { regionId: region.id, source, mimeType: "image/png", operation: { type: "paint" } });
store.dispatch(commandIds.toolClearMaskShape, undefined);
}
function rectanglePoints(start: Vec2D, end: Vec2D): Vec2D[] {
return [start, { x: end.x, y: start.y }, end, { x: start.x, y: end.y }];
}
function documentPointToAssetPoint(point: Vec2D, layer: Extract<Layer, { type: "image" | "raster" }>, intrinsicSize: { w: number; h: number }): Vec2D {
const source = layer.sourceRect ?? { x: 0, y: 0, ...intrinsicSize };
const destination = { x: layer.transform.position.x + source.x * layer.transform.scale.x, y: layer.transform.position.y + source.y * layer.transform.scale.y, w: source.w * layer.transform.scale.x, h: source.h * layer.transform.scale.y };
const center = { x: destination.x + destination.w / 2, y: destination.y + destination.h / 2 };
const dx = point.x - center.x;
const dy = point.y - center.y;
const cos = Math.cos(-layer.transform.rotation);
const sin = Math.sin(-layer.transform.rotation);
const x = center.x + dx * cos - dy * sin;
const y = center.y + dx * sin + dy * cos;
return { x: source.x + (x - destination.x) / Math.max(0.0001, layer.transform.scale.x), y: source.y + (y - destination.y) / Math.max(0.0001, layer.transform.scale.y) };
}
function findLayer(layers: readonly Layer[], id: string): Layer | undefined {
for (const layer of layers) {
if (layer.id === id) return layer;
if (layer.type === "group") { const child = findLayer(layer.children, id); if (child) return child; }
}
}

View File

@@ -11,12 +11,17 @@ import { createWandMask } from "@platform/browser/magicWandRaster";
export async function applyMagicWandAt(store: AppStore, point: Vec2D, modeOverride?: EditorState["tools"]["magicWand"]["mode"]) {
const state = store.getState();
if (state.editor.tools.activeTool !== "magicWand") return false;
const target = resolveTarget(state.document, state.editor);
const target = resolveMaskSelectionTarget(state.document, state.editor);
if (!target) return true;
const x = Math.floor((point.x - target.layer.transform.position.x) / Math.max(0.0001, target.layer.transform.scale.x));
const y = Math.floor((point.y - target.layer.transform.position.y) / Math.max(0.0001, target.layer.transform.scale.y));
const assetPoint = documentPointToAssetPoint(point, target.layer, target.asset.intrinsicSize);
const x = Math.floor(assetPoint.x);
const y = Math.floor(assetPoint.y);
if (x < 0 || y < 0 || x >= target.asset.intrinsicSize.w || y >= target.asset.intrinsicSize.h) return true;
const source = await createWandMask(target.asset.source, target.maskAsset?.source, Math.round(target.asset.intrinsicSize.w), Math.round(target.asset.intrinsicSize.h), x, y, { ...state.editor.tools.magicWand, mode: modeOverride ?? state.editor.tools.magicWand.mode });
const source = await createWandMask(target.asset.source, target.maskAsset?.source, Math.round(target.asset.intrinsicSize.w), Math.round(target.asset.intrinsicSize.h), x, y, { ...state.editor.tools.magicWand, mode: modeOverride ?? state.editor.tools.magicWand.mode, target: target.inpaintRegion ? "inpaint" : "visibility" });
if (target.inpaintRegion && target.maskAsset) {
store.dispatch(commandIds.documentApplyInpaintRegionMaskOperation, { regionId: target.inpaintRegion.id, source, mimeType: "image/png", operation: { type: "magicWand" } });
return true;
}
if (target.maskAsset && target.maskLayer && (target.maskLayer.type === "image" || target.maskLayer.type === "raster")) {
store.dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId: target.maskLayer.id, source, mimeType: "image/png", operation: { type: "magicWand" } });
return true;
@@ -35,7 +40,7 @@ export async function applyMagicWandAt(store: AppStore, point: Vec2D, modeOverri
return true;
}
function resolveTarget(document: ImageDocument, editor: EditorState) {
export function resolveMaskSelectionTarget(document: ImageDocument, editor: EditorState) {
const layerId = editor.selection.layerIds[0];
if (!layerId || editor.selection.layerIds.length !== 1) return undefined;
const layer = findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId);
@@ -43,9 +48,23 @@ function resolveTarget(document: ImageDocument, editor: EditorState) {
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id });
const layerMask = getLayerMask(layer);
const inpaintRegion = editor.maskEdit?.kind === "inpaintRegion" ? document.inpaintRegions.find((candidate) => candidate.id === editor.maskEdit?.inpaintRegionId && candidate.targetLayerId === layer.id) : undefined;
const maskLayer = layerMask?.enabled ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerMask.maskLayerId) : undefined;
const maskAsset = maskLayer && (maskLayer.type === "image" || maskLayer.type === "raster") ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
return asset && bounds ? { layer, asset, bounds, maskLayer, maskAsset } : undefined;
const maskAsset = inpaintRegion ? document.assets.find((candidate) => candidate.id === inpaintRegion.maskAssetId) : maskLayer && (maskLayer.type === "image" || maskLayer.type === "raster") ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
return asset && bounds ? { layer, asset, bounds, maskLayer, maskAsset, inpaintRegion } : undefined;
}
export function documentPointToAssetPoint(point: Vec2D, layer: Extract<Layer, { type: "image" | "raster" }>, intrinsicSize: { w: number; h: number }): Vec2D {
const source = layer.sourceRect ?? { x: 0, y: 0, ...intrinsicSize };
const destination = { x: layer.transform.position.x + source.x * layer.transform.scale.x, y: layer.transform.position.y + source.y * layer.transform.scale.y, w: source.w * layer.transform.scale.x, h: source.h * layer.transform.scale.y };
const center = { x: destination.x + destination.w / 2, y: destination.y + destination.h / 2 };
const dx = point.x - center.x;
const dy = point.y - center.y;
const cos = Math.cos(-layer.transform.rotation);
const sin = Math.sin(-layer.transform.rotation);
const x = center.x + dx * cos - dy * sin;
const y = center.y + dx * sin + dy * cos;
return { x: source.x + (x - destination.x) / Math.max(0.0001, layer.transform.scale.x), y: source.y + (y - destination.y) / Math.max(0.0001, layer.transform.scale.y) };
}
function findLayer(layers: readonly Layer[], layerId: string): Layer | undefined {

View File

@@ -1,10 +1,13 @@
import { commandIds } from "@commands/ids";
import type { Asset } from "@core/asset";
import type { LayerId } from "@core/id";
import type { InpaintRegionId } from "@core/id";
import type { AppStore } from "@editor/store";
import { analyzeMaskSource, applyMaskRasterOperation, createSolidMaskSource, type MaskAnalysis, type MaskRasterOperation } from "@platform/browser/maskRaster";
export type { MaskAnalysis, MaskRasterOperation };
export function analyzeMask(asset: Asset): Promise<MaskAnalysis> { return analyzeMaskSource(asset.source, asset.intrinsicSize.w, asset.intrinsicSize.h); }
export async function runMaskOperation(maskLayerId: LayerId, asset: Asset, operation: MaskRasterOperation, dispatch: AppStore["dispatch"]) { const source = await applyMaskRasterOperation(asset.source, asset.intrinsicSize.w, asset.intrinsicSize.h, operation); dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId, source, mimeType: "image/png", operation }); }
export async function runInpaintRegionOperation(regionId: InpaintRegionId, asset: Asset, operation: MaskRasterOperation, dispatch: AppStore["dispatch"]) { const source = await applyMaskRasterOperation(asset.source, asset.intrinsicSize.w, asset.intrinsicSize.h, operation); dispatch(commandIds.documentApplyInpaintRegionMaskOperation, { regionId, source, mimeType: "image/png", operation }); }
export function createRefinementMask(width: number, height: number) { return createSolidMaskSource(width, height, "white"); }
export function createInpaintRegionMask(width: number, height: number) { return createSolidMaskSource(width, height, "black"); }

View File

@@ -0,0 +1,34 @@
import { commandIds } from "@commands/ids";
import type { Vec2D } from "@core/geometry";
import type { AppStore } from "@editor/store";
import { mergeMaskSources } from "@platform/browser/maskRaster";
import { requestSemanticSelection } from "@platform/comfy/generationClient";
import { runGenerationJob } from "@operations/generation/generationJob";
import { documentPointToAssetPoint, resolveMaskSelectionTarget } from "./magic-wand";
export async function applySemanticSelectionAt(store: AppStore, point: Vec2D, mode: "replace" | "add" | "subtract") {
const state = store.getState();
if (state.editor.tools.activeTool !== "semanticSelect") return false;
const target = resolveMaskSelectionTarget(state.document, state.editor);
if (!target?.inpaintRegion || !target.maskAsset) return true;
const region = target.inpaintRegion;
const maskAsset = target.maskAsset;
const assetPoint = documentPointToAssetPoint(point, target.layer, target.asset.intrinsicSize);
if (assetPoint.x < 0 || assetPoint.y < 0 || assetPoint.x >= target.asset.intrinsicSize.w || assetPoint.y >= target.asset.intrinsicSize.h) return true;
const controller = new AbortController();
await runGenerationJob({
kind: "mask",
label: "Selecting object",
dispatch: store.dispatch,
signal: controller.signal,
task: async (signal, report) => {
report(0.1, "Sending point to SAM3");
const result = await requestSemanticSelection({ inputImage: target.asset.source, x: assetPoint.x, y: assetPoint.y }, signal);
report(0.85, "Merging object mask");
const source = await mergeMaskSources(maskAsset.source, result.source, Math.round(target.asset.intrinsicSize.w), Math.round(target.asset.intrinsicSize.h), mode);
store.dispatch(commandIds.documentApplyInpaintRegionMaskOperation, { regionId: region.id, source, mimeType: "image/png", operation: { type: "magicWand" } });
report(1, "Object selected");
},
});
return true;
}

View File

@@ -1,12 +1,14 @@
import { commandIds } from "@commands/ids";
import type { ImageDocument } from "@core/document";
import type { Vec2D } from "@core/geometry";
import type { AssetId, LayerId } from "@core/id";
import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
import type { RasterLayer } from "@core/raster-layer";
import type { MaskEditState, SelectionState } from "@editor/state";
import { isPanInteractionMode, type ToolState } from "@editor/tools";
import type { AppStore } from "@editor/store";
import { brushSurfaceDataUrl, brushSurfaceObjectUrl, cancelFrame, createBrushSurface, drawBrushSegment, releaseObjectUrl, scheduleFrame, type BrushSurface } from "@platform/browser/brushRaster";
import { brushSurfaceDataUrl, brushSurfaceObjectUrl, cancelFrame, createBrushSurface, drawBrushSegment, drawFeatherSegment, releaseObjectUrl, scheduleFrame, type BrushSurface } from "@platform/browser/brushRaster";
export type BrushSession = {
layerId: string;
@@ -16,7 +18,7 @@ export type BrushSession = {
surface: BrushSurface;
ready: Promise<boolean>;
previousPoint: Vec2D;
mode: "brush" | "eraser";
mode: "brush" | "eraser" | "feather";
changed?: boolean;
pending?: Promise<void>;
cancelled?: boolean;
@@ -25,6 +27,13 @@ export type BrushSession = {
previewInFlight?: boolean;
previewFrame?: number;
previewSource?: string;
targetLayer: RasterLayer;
featherRadius?: number;
pendingLayerMask?: {
targetLayerId: LayerId;
asset: ImageDocument["assets"][number];
maskLayer: RasterLayer;
};
};
export type BrushTargetEditorState = {
@@ -34,11 +43,9 @@ export type BrushTargetEditorState = {
};
export function beginBrushSession(document: ImageDocument, editor: BrushTargetEditorState, point: Vec2D): BrushSession | undefined {
const layer = resolveBrushTargetLayer(document, editor);
if (!layer || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
if (!asset) return undefined;
const target = resolveBrushTarget(document, editor);
if (!target || !isPaintTool(editor.tools.activeTool)) return undefined;
const { layer, asset } = target;
const surface = createBrushSurface(asset.intrinsicSize.w, asset.intrinsicSize.h, asset.source);
if (!surface) return undefined;
@@ -52,17 +59,20 @@ export function beginBrushSession(document: ImageDocument, editor: BrushTargetEd
ready: surface.ready,
previousPoint: point,
mode: editor.tools.activeTool,
targetLayer: layer,
pendingLayerMask: target.pendingLayerMask,
};
return session;
}
export function canPreviewBrush(document: ImageDocument, editor: BrushTargetEditorState): boolean {
return Boolean(resolveBrushTargetLayer(document, editor));
if (editor.tools.activeTool === "feather" && !editor.maskEdit) return Boolean(resolveSelectedFeatherLayer(document, editor));
return Boolean(resolveBrushTarget(document, editor));
}
export function brushUnavailableHint(document: ImageDocument, editor: BrushTargetEditorState): string | undefined {
if (isPanInteractionMode(editor.tools.interactionMode) || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
if (resolveBrushTargetLayer(document, editor)) return undefined;
if (isPanInteractionMode(editor.tools.interactionMode) || !isPaintTool(editor.tools.activeTool)) return undefined;
if (canPreviewBrush(document, editor)) return undefined;
const layerId = editor.maskEdit?.maskLayerId ?? editor.selection.layerIds[0];
if (!layerId) {
@@ -73,20 +83,73 @@ export function brushUnavailableHint(document: ImageDocument, editor: BrushTarge
const layer = findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId);
if (!layer) return "Select a raster layer or layer mask to paint.";
if (layer.locked) return "Unlock this layer before painting.";
if (layer.type === "image") return "Image layers are non-destructive. Add a layer mask to paint or erase.";
if (layer.type === "image" && editor.tools.activeTool !== "feather") return "Image layers are non-destructive. Add a layer mask to paint or erase.";
if (layer.type === "group") return "Select a raster layer inside the group to paint.";
if (!editor.maskEdit && !layer.visible) return "Show this layer before painting.";
return "Select a raster layer or layer mask to paint.";
}
function resolveBrushTargetLayer(document: ImageDocument, editor: BrushTargetEditorState): RasterLayer | undefined {
if (isPanInteractionMode(editor.tools.interactionMode) || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
function resolveBrushTarget(document: ImageDocument, editor: BrushTargetEditorState): { layer: RasterLayer; asset: ImageDocument["assets"][number]; pendingLayerMask?: BrushSession["pendingLayerMask"] } | undefined {
if (isPanInteractionMode(editor.tools.interactionMode) || !isPaintTool(editor.tools.activeTool)) return undefined;
if (editor.maskEdit?.kind === "inpaintRegion") {
const target = findLayer(document.artboards.flatMap((artboard) => artboard.layers), editor.maskEdit.targetLayerId);
const asset = document.assets.find((candidate) => candidate.id === editor.maskEdit?.maskAssetId);
if (!target || (target.type !== "image" && target.type !== "raster") || target.locked || !asset) return undefined;
return {
layer: { ...target, type: "raster", assetId: asset.id },
asset,
};
}
const editingMask = Boolean(editor.maskEdit);
const layerId = editor.maskEdit?.maskLayerId ?? editor.selection.layerIds[0];
if (!layerId) return undefined;
const layer = findRasterLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId);
if (!layer || layer.locked || (!editingMask && !layer.visible)) return undefined;
return layer;
const layers = document.artboards.flatMap((artboard) => artboard.layers);
const selectedLayer = findLayer(layers, layerId);
if (!selectedLayer || selectedLayer.locked || (!editingMask && !selectedLayer.visible)) return undefined;
if (editor.tools.activeTool === "feather" && !editingMask && (selectedLayer.type === "image" || selectedLayer.type === "raster")) {
const maskLayerId = getLayerMask(selectedLayer)?.maskLayerId;
const attachedMask = maskLayerId ? findLayer(layers, maskLayerId) : undefined;
const attachedMaskLayer = attachedMask && (attachedMask.type === "image" || attachedMask.type === "raster") ? { ...attachedMask, type: "raster" as const } : undefined;
const maskAsset = attachedMaskLayer ? document.assets.find((candidate) => candidate.id === attachedMaskLayer.assetId) : undefined;
if (attachedMaskLayer && maskAsset) return { layer: attachedMaskLayer, asset: maskAsset };
const sourceAsset = document.assets.find((candidate) => candidate.id === selectedLayer.assetId);
if (!sourceAsset) return undefined;
const width = Math.max(1, Math.round(sourceAsset.intrinsicSize.w));
const height = Math.max(1, Math.round(sourceAsset.intrinsicSize.h));
const source = opaqueMaskSource(width, height);
const assetId = crypto.randomUUID() as AssetId;
const newMaskLayerId = crypto.randomUUID() as LayerId;
const sourceRect = selectedLayer.sourceRect ?? { x: 0, y: 0, w: width, h: height };
const maskLayer: RasterLayer = {
id: newMaskLayerId,
type: "raster",
name: `${selectedLayer.name} Mask`,
visible: true,
locked: false,
opacity: 1,
assetId,
transform: {
position: {
x: selectedLayer.transform.position.x + sourceRect.x * selectedLayer.transform.scale.x,
y: selectedLayer.transform.position.y + sourceRect.y * selectedLayer.transform.scale.y,
},
scale: {
x: (sourceRect.w * selectedLayer.transform.scale.x) / width,
y: (sourceRect.h * selectedLayer.transform.scale.y) / height,
},
rotation: selectedLayer.transform.rotation,
},
};
const asset = { id: assetId, name: maskLayer.name, mimeType: "image/svg+xml", source, intrinsicSize: { w: width, h: height } };
return { layer: maskLayer, asset, pendingLayerMask: { targetLayerId: selectedLayer.id, asset, maskLayer } };
}
const layer = selectedLayer.type === "raster" ? selectedLayer : undefined;
if (!layer) return undefined;
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
return asset ? { layer, asset } : undefined;
}
export function updateBrushSession(options: {
@@ -96,27 +159,53 @@ export function updateBrushSession(options: {
color: string;
size: number;
hardness: number;
opacity: number;
flow: number;
smoothing: number;
pressure: number;
pressureSize: boolean;
featherRadius?: number;
featherStrength?: number;
}): BrushSession {
const state = options.store.getState();
const layer = findRasterLayer(state.document.artboards.flatMap((artboard) => artboard.layers), options.session.layerId);
if (!layer || layer.assetId !== options.session.assetId) return options.session;
const asset = state.document.assets.find((candidate) => candidate.id === options.session.assetId);
if (!asset && !options.session.pendingLayerMask) return options.session;
const layer = options.session.targetLayer;
const from = options.session.previousPoint;
const to = options.point;
const smoothing = Math.max(0, Math.min(100, options.smoothing)) / 100;
const follow = 1 - smoothing * 0.85;
const to = { x: from.x + (options.point.x - from.x) * follow, y: from.y + (options.point.y - from.y) * follow };
options.session.previousPoint = to;
options.session.pending = (options.session.pending ?? Promise.resolve())
.then(async () => {
if (options.session.cancelled) return;
if (!(await options.session.ready) || options.session.cancelled) return;
const assetFrom = documentPointToAssetPoint(from, layer, options.session.width, options.session.height);
const assetTo = documentPointToAssetPoint(to, layer, options.session.width, options.session.height);
const size = options.size * (options.pressureSize ? Math.max(0.1, options.pressure) : 1);
if (options.session.mode === "feather") {
options.session.featherRadius = options.featherRadius ?? 16;
drawFeatherSegment(options.session.surface, {
from: assetFrom,
to: assetTo,
size,
radius: options.featherRadius ?? 16,
strength: options.featherStrength ?? 65,
});
} else {
drawBrushSegment(options.session.surface, {
from: documentPointToAssetPoint(from, layer, options.session.width, options.session.height),
to: documentPointToAssetPoint(to, layer, options.session.width, options.session.height),
from: assetFrom,
to: assetTo,
color: state.editor.maskEdit ? "#ffffff" : options.color,
size: options.size,
size,
hardness: options.hardness,
opacity: options.opacity,
flow: options.flow,
mode: options.session.mode,
});
}
if (options.session.cancelled) return;
options.session.changed = true;
@@ -135,8 +224,22 @@ export async function commitBrushSession(options: { store: AppStore; session: Br
if (source) {
const state = options.store.getState();
const maskEdit = state.editor.maskEdit;
if (maskEdit?.maskLayerId === options.session.layerId) {
options.store.dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId: maskEdit.maskLayerId, source, mimeType: "image/png", operation: { type: "paint" } });
const operation = options.session.mode === "feather"
? { type: "feather" as const, radius: Math.round(options.session.featherRadius ?? 16) }
: { type: "paint" as const };
if (options.session.pendingLayerMask) {
options.store.dispatch(commandIds.documentAddLayerMask, {
layerId: options.session.pendingLayerMask.targetLayerId,
asset: { ...options.session.pendingLayerMask.asset, source, mimeType: "image/png" },
maskLayer: options.session.pendingLayerMask.maskLayer,
activeTool: "feather",
});
} else if (maskEdit?.kind === "inpaintRegion" && maskEdit.inpaintRegionId && maskEdit.maskAssetId === options.session.assetId) {
options.store.dispatch(commandIds.documentApplyInpaintRegionMaskOperation, { regionId: maskEdit.inpaintRegionId, source, mimeType: "image/png", operation });
} else if (maskEdit?.maskLayerId === options.session.layerId) {
options.store.dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId: maskEdit.maskLayerId, source, mimeType: "image/png", operation });
} else if (isAttachedLayerMask(state.document, options.session.layerId)) {
options.store.dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId: options.session.layerId, source, mimeType: "image/png", operation });
} else {
options.store.dispatch(commandIds.documentUpdateAssetSource, { assetId: options.session.assetId, source });
}
@@ -145,6 +248,33 @@ export async function commitBrushSession(options: { store: AppStore; session: Br
closeBrushStrokePreview(options.session);
}
function isPaintTool(tool: ToolState["activeTool"]): tool is "brush" | "eraser" | "feather" {
return tool === "brush" || tool === "eraser" || tool === "feather";
}
function resolveSelectedFeatherLayer(document: ImageDocument, editor: BrushTargetEditorState) {
if (isPanInteractionMode(editor.tools.interactionMode)) return undefined;
const layerId = editor.selection.layerIds[0];
if (!layerId) return undefined;
const layer = findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId);
return layer && (layer.type === "image" || layer.type === "raster") && layer.visible && !layer.locked ? layer : undefined;
}
function isAttachedLayerMask(document: ImageDocument, maskLayerId: string) {
const stack = document.artboards.flatMap((artboard) => artboard.layers);
while (stack.length > 0) {
const layer = stack.pop();
if (!layer) continue;
if (getLayerMask(layer)?.maskLayerId === maskLayerId) return true;
if (layer.type === "group") stack.push(...layer.children);
}
return false;
}
function opaqueMaskSource(width: number, height: number) {
return `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><rect width="${width}" height="${height}" fill="white"/></svg>`)}`;
}
export function cancelBrushSession(options: { store: AppStore; session: BrushSession }) {
options.session.cancelled = true;
options.store.dispatch(commandIds.toolSetBrushStrokePreview, undefined);
@@ -152,9 +282,22 @@ export function cancelBrushSession(options: { store: AppStore; session: BrushSes
}
function documentPointToAssetPoint(point: Vec2D, layer: RasterLayer, width: number, height: number): Vec2D {
const source = layer.sourceRect ?? { x: 0, y: 0, w: width, h: height };
const destination = {
x: layer.transform.position.x + source.x * layer.transform.scale.x,
y: layer.transform.position.y + source.y * layer.transform.scale.y,
w: source.w * layer.transform.scale.x,
h: source.h * layer.transform.scale.y,
};
const center = { x: destination.x + destination.w / 2, y: destination.y + destination.h / 2 };
const cos = Math.cos(-layer.transform.rotation);
const sin = Math.sin(-layer.transform.rotation);
const dx = point.x - center.x;
const dy = point.y - center.y;
const unrotated = { x: center.x + dx * cos - dy * sin, y: center.y + dx * sin + dy * cos };
return {
x: ((point.x - layer.transform.position.x) / Math.max(0.0001, layer.transform.scale.x) / width) * width,
y: ((point.y - layer.transform.position.y) / Math.max(0.0001, layer.transform.scale.y) / height) * height,
x: source.x + (unrotated.x - destination.x) / Math.max(0.0001, layer.transform.scale.x),
y: source.y + (unrotated.y - destination.y) / Math.max(0.0001, layer.transform.scale.y),
};
}
@@ -190,7 +333,13 @@ async function publishBrushStrokePreview(options: { store: AppStore; session: Br
const previousSource = options.session.previewSource;
options.session.previewSource = source;
options.store.dispatch(commandIds.toolSetBrushStrokePreview, { layerId: options.session.layerId, assetId: options.session.assetId, source });
options.store.dispatch(commandIds.toolSetBrushStrokePreview, {
layerId: options.session.layerId,
assetId: options.session.assetId,
source,
pendingTargetLayerId: options.session.pendingLayerMask?.targetLayerId,
intrinsicSize: options.session.pendingLayerMask ? { w: options.session.width, h: options.session.height } : undefined,
});
releaseObjectUrl(previousSource);
if (options.session.previewRequested) requestBrushStrokePreview(options);
@@ -208,11 +357,6 @@ function closeBrushStrokePreview(session: BrushSession) {
}
}
function findRasterLayer(layers: Layer[], layerId: string): RasterLayer | undefined {
const layer = findLayer(layers, layerId);
return layer?.type === "raster" ? layer : undefined;
}
function findLayer(layers: Layer[], layerId: string): Layer | undefined {
for (const layer of layers) {
if (layer.id === layerId) return layer;

View File

@@ -14,7 +14,7 @@ describe("project format", () => {
test("migrates a legacy bare document", () => {
const result = parseProject(JSON.stringify(projectDocument()));
expect(result.version).toBe(1);
expect(result.version).toBe(CURRENT_PROJECT_VERSION);
expect(result.savedAt).toBe("1970-01-01T00:00:00.000Z");
});
@@ -76,6 +76,7 @@ function projectDocument(): ImageDocument {
name: "Test Project",
version: 1,
assets: [{ id: "asset-1", name: "pixels.png", mimeType: "image/png", source: "data:image/png;base64,AA==", intrinsicSize: { w: 10, h: 20 } }],
inpaintRegions: [],
artboards: [{
id: "artboard-1",
name: "Board",

View File

@@ -4,7 +4,7 @@ import type { Rect } from "@core/geometry";
import { isValidTextStyle, type TextStyle } from "@core/text-layer";
export const PROJECT_FORMAT = "image-studio-project";
export const CURRENT_PROJECT_VERSION = 1;
export const CURRENT_PROJECT_VERSION = 2;
export type ProjectFile = {
format: typeof PROJECT_FORMAT;
@@ -41,6 +41,14 @@ function migrateProject(value: unknown): ProjectFile {
if (!isRecord(value)) throw new Error("The project file must contain an object.");
if (value.format === PROJECT_FORMAT) {
if (value.version === 1 && isRecord(value.document)) {
return {
format: PROJECT_FORMAT,
version: CURRENT_PROJECT_VERSION,
savedAt: typeof value.savedAt === "string" ? value.savedAt : new Date(0).toISOString(),
document: { ...value.document, inpaintRegions: [] } as unknown as ImageDocument,
};
}
if (value.version !== CURRENT_PROJECT_VERSION) {
throw new Error(`Unsupported project version: ${String(value.version)}.`);
}
@@ -48,13 +56,13 @@ function migrateProject(value: unknown): ProjectFile {
return value as ProjectFile;
}
// Legacy exports stored ImageDocument directly. Loading upgrades them to v1.
// Legacy exports stored ImageDocument directly. Loading upgrades them to the current format.
if (looksLikeDocument(value)) {
return {
format: PROJECT_FORMAT,
version: CURRENT_PROJECT_VERSION,
savedAt: new Date(0).toISOString(),
document: value as ImageDocument,
document: { ...value, inpaintRegions: Array.isArray(value.inpaintRegions) ? value.inpaintRegions : [] } as ImageDocument,
};
}
@@ -65,7 +73,7 @@ function assertImageDocument(value: unknown): asserts value is ImageDocument {
if (!isRecord(value) || typeof value.id !== "string" || typeof value.name !== "string" || typeof value.version !== "number") {
throw new Error("The project contains an invalid document.");
}
if (!Array.isArray(value.artboards) || !Array.isArray(value.assets)) throw new Error("The project document is incomplete.");
if (!Array.isArray(value.artboards) || !Array.isArray(value.assets) || !Array.isArray(value.inpaintRegions)) throw new Error("The project document is incomplete.");
for (const asset of value.assets) {
if (!isRecord(asset) || typeof asset.id !== "string" || typeof asset.name !== "string" || typeof asset.mimeType !== "string" || typeof asset.source !== "string" || !isSize(asset.intrinsicSize)) {
@@ -78,6 +86,11 @@ function assertImageDocument(value: unknown): asserts value is ImageDocument {
}
assertLayers(artboard.layers, false);
}
for (const region of value.inpaintRegions) {
if (!isRecord(region) || typeof region.id !== "string" || typeof region.name !== "string" || typeof region.targetLayerId !== "string" || typeof region.maskAssetId !== "string" || typeof region.enabled !== "boolean") {
throw new Error("The project contains an invalid inpaint region.");
}
}
}
export function assertDocumentAssetOwnership(document: ImageDocument): void {
@@ -98,6 +111,10 @@ export function assertDocumentAssetOwnership(document: ImageDocument): void {
}
});
}
for (const region of document.inpaintRegions) {
if (!assetIds.has(region.maskAssetId)) throw new Error(`Inpaint region ${region.name} references missing mask asset ${region.maskAssetId}.`);
if (!layerIds.has(region.targetLayerId)) throw new Error(`Inpaint region ${region.name} references missing target layer ${region.targetLayerId}.`);
}
}
function isOwnedAssetSource(source: string): boolean {

View File

@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"dev": "bun --hot index.ts",
"dev:full": "./scripts/dev-with-comfy.sh",
"start": "NODE_ENV=production bun index.ts",
"build": "bun run build.ts",
"lint": "eslint .",

View File

@@ -0,0 +1,40 @@
import { describe, expect, test } from "bun:test";
import { blendFeatherPatch } from "./brushRaster";
describe("feather brush raster blending", () => {
test("mixes blurred mask values inside the stroke and preserves pixels outside it", () => {
const original = rgbaRow([255, 255, 255, 255, 255]);
const blurred = rgbaRow([0, 64, 128, 192, 255]);
blendFeatherPatch(original, blurred, 5, 1, { x: 0, y: 0 }, {
from: { x: 2.5, y: 0.5 },
to: { x: 2.5, y: 0.5 },
size: 3,
strength: 100,
});
expect(alphaValues(original)).toEqual([255, 64, 128, 192, 255]);
});
test("applies strength as a non-destructive mix", () => {
const original = rgbaRow([255]);
const blurred = rgbaRow([0]);
blendFeatherPatch(original, blurred, 1, 1, { x: 0, y: 0 }, {
from: { x: 0.5, y: 0.5 },
to: { x: 0.5, y: 0.5 },
size: 10,
strength: 50,
});
expect(alphaValues(original)).toEqual([128]);
});
});
function rgbaRow(alpha: number[]) {
return new Uint8ClampedArray(alpha.flatMap((value) => [255, 255, 255, value]));
}
function alphaValues(data: Uint8ClampedArray) {
return Array.from({ length: data.length / 4 }, (_, index) => data[index * 4 + 3]);
}

View File

@@ -5,19 +5,101 @@ export type BrushSurface = {
readonly resource: object;
};
type InternalSurface = BrushSurface & { canvas: HTMLCanvasElement; context: CanvasRenderingContext2D };
type InternalSurface = BrushSurface & {
canvas: HTMLCanvasElement;
context: CanvasRenderingContext2D;
featherSource: HTMLCanvasElement;
featherBlur: HTMLCanvasElement;
};
export function createBrushSurface(width: number, height: number, source: string): BrushSurface | undefined {
const canvas = document.createElement("canvas"); canvas.width = Math.max(1, Math.round(width)); canvas.height = Math.max(1, Math.round(height));
const context = canvas.getContext("2d"); if (!context) return undefined;
const surface = { width: canvas.width, height: canvas.height, canvas, context, resource: {}, ready: Promise.resolve(false) } as InternalSurface;
const surface = {
width: canvas.width,
height: canvas.height,
canvas,
context,
featherSource: document.createElement("canvas"),
featherBlur: document.createElement("canvas"),
resource: {},
ready: Promise.resolve(false),
} as InternalSurface;
(surface as { ready: Promise<boolean> }).ready = loadImage(source).then((image) => { context.clearRect(0, 0, canvas.width, canvas.height); context.drawImage(image, 0, 0, canvas.width, canvas.height); return true; }).catch(() => false);
return surface;
}
export function drawBrushSegment(surface: BrushSurface, options: { from: { x: number; y: number }; to: { x: number; y: number }; color: string; size: number; hardness: number; mode: "brush" | "eraser" }) {
export function drawBrushSegment(surface: BrushSurface, options: { from: { x: number; y: number }; to: { x: number; y: number }; color: string; size: number; hardness: number; opacity: number; flow: number; mode: "brush" | "eraser" }) {
const context = internal(surface).context; const hardness = Math.max(0, Math.min(100, options.hardness)) / 100;
context.save(); context.globalCompositeOperation = options.mode === "eraser" ? "destination-out" : "source-over"; context.strokeStyle = options.color; context.shadowColor = options.mode === "eraser" ? "rgba(0,0,0,1)" : options.color; context.shadowBlur = (1 - hardness) * options.size; context.lineWidth = options.size; context.lineCap = "round"; context.lineJoin = "round"; context.beginPath(); context.moveTo(options.from.x, options.from.y); context.lineTo(options.to.x, options.to.y); context.stroke(); context.restore();
context.save(); context.globalAlpha = Math.max(0, Math.min(1, options.opacity / 100)) * Math.max(0.01, Math.min(1, options.flow / 100)); context.globalCompositeOperation = options.mode === "eraser" ? "destination-out" : "source-over"; context.strokeStyle = options.color; context.shadowColor = options.mode === "eraser" ? "rgba(0,0,0,1)" : options.color; context.shadowBlur = (1 - hardness) * options.size; context.lineWidth = options.size; context.lineCap = "round"; context.lineJoin = "round"; context.beginPath(); context.moveTo(options.from.x, options.from.y); context.lineTo(options.to.x, options.to.y); context.stroke(); context.restore();
}
export function drawFeatherSegment(surface: BrushSurface, options: {
from: { x: number; y: number };
to: { x: number; y: number };
size: number;
radius: number;
strength: number;
}) {
const target = internal(surface);
const brushRadius = Math.max(0.5, options.size / 2);
const blurRadius = Math.max(1, Math.round(options.radius));
const padding = Math.ceil(blurRadius * 2.5);
const x1 = Math.max(0, Math.floor(Math.min(options.from.x, options.to.x) - brushRadius - padding));
const y1 = Math.max(0, Math.floor(Math.min(options.from.y, options.to.y) - brushRadius - padding));
const x2 = Math.min(target.width, Math.ceil(Math.max(options.from.x, options.to.x) + brushRadius + padding));
const y2 = Math.min(target.height, Math.ceil(Math.max(options.from.y, options.to.y) + brushRadius + padding));
const width = x2 - x1;
const height = y2 - y1;
if (width <= 0 || height <= 0) return;
const scratchWidth = width + padding * 2;
const scratchHeight = height + padding * 2;
resizeCanvas(target.featherSource, scratchWidth, scratchHeight);
resizeCanvas(target.featherBlur, scratchWidth, scratchHeight);
const sourceContext = target.featherSource.getContext("2d");
const blurContext = target.featherBlur.getContext("2d");
if (!sourceContext || !blurContext) return;
sourceContext.clearRect(0, 0, scratchWidth, scratchHeight);
sourceContext.drawImage(target.canvas, x1, y1, width, height, padding, padding, width, height);
blurContext.clearRect(0, 0, scratchWidth, scratchHeight);
blurContext.save();
blurContext.filter = `blur(${blurRadius}px)`;
blurContext.drawImage(target.featherSource, 0, 0);
blurContext.restore();
const original = target.context.getImageData(x1, y1, width, height);
const blurred = blurContext.getImageData(padding, padding, width, height);
blendFeatherPatch(original.data, blurred.data, width, height, { x: x1, y: y1 }, options);
target.context.putImageData(original, x1, y1);
}
export function blendFeatherPatch(
original: Uint8ClampedArray,
blurred: Uint8ClampedArray,
width: number,
height: number,
origin: { x: number; y: number },
options: { from: { x: number; y: number }; to: { x: number; y: number }; size: number; strength: number },
) {
const brushRadius = Math.max(0.5, options.size / 2);
const strength = Math.max(0.01, Math.min(1, options.strength / 100));
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const distance = distanceToSegment(origin.x + x + 0.5, origin.y + y + 0.5, options.from, options.to);
if (distance >= brushRadius) continue;
const normalized = distance / brushRadius;
const coverage = 1 - smoothstep(0.72, 1, normalized);
const mix = coverage * strength;
const index = (y * width + x) * 4;
for (let channel = 0; channel < 4; channel += 1) {
const current = original[index + channel] ?? 0;
const feathered = blurred[index + channel] ?? 0;
original[index + channel] = Math.round(current + (feathered - current) * mix);
}
}
}
}
export function brushSurfaceDataUrl(surface: BrushSurface) { try { return internal(surface).canvas.toDataURL("image/png"); } catch { return undefined; } }
@@ -27,3 +109,22 @@ export function scheduleFrame(callback: () => void) { return requestAnimationFra
export function cancelFrame(id: number) { cancelAnimationFrame(id); }
function internal(surface: BrushSurface) { return surface as InternalSurface; }
function loadImage(source: string) { return new Promise<HTMLImageElement>((resolve, reject) => { const image = new Image(); image.onload = () => resolve(image); image.onerror = () => reject(new Error("Failed to load raster layer")); image.src = source; }); }
function resizeCanvas(canvas: HTMLCanvasElement, width: number, height: number) {
if (canvas.width !== width) canvas.width = width;
if (canvas.height !== height) canvas.height = height;
}
function distanceToSegment(x: number, y: number, from: { x: number; y: number }, to: { x: number; y: number }) {
const dx = to.x - from.x;
const dy = to.y - from.y;
const lengthSquared = dx * dx + dy * dy;
if (lengthSquared <= 0.0001) return Math.hypot(x - from.x, y - from.y);
const amount = Math.max(0, Math.min(1, ((x - from.x) * dx + (y - from.y) * dy) / lengthSquared));
return Math.hypot(x - (from.x + dx * amount), y - (from.y + dy * amount));
}
function smoothstep(edge0: number, edge1: number, value: number) {
const amount = Math.max(0, Math.min(1, (value - edge0) / Math.max(0.0001, edge1 - edge0)));
return amount * amount * (3 - 2 * amount);
}

View File

@@ -7,6 +7,15 @@ import { getLayerMask } from "@core/layer-mask-utils";
import { measureTextLayer } from "@core/text-layer";
export async function downloadArtboardPng(artboard: Artboard, assets: readonly Asset[]) {
const canvas = await renderArtboardCanvas(artboard, assets);
const url = canvas.toDataURL("image/png");
const link = document.createElement("a");
link.href = url;
link.download = `${safeFilename(artboard.name)}.png`;
link.click();
}
export async function renderArtboardCanvas(artboard: Artboard, assets: readonly Asset[]): Promise<HTMLCanvasElement> {
const width = Math.max(1, Math.round(artboard.bounds.w));
const height = Math.max(1, Math.round(artboard.bounds.h));
const canvas = document.createElement("canvas");
@@ -27,11 +36,7 @@ export async function downloadArtboardPng(artboard: Artboard, assets: readonly A
for (const layer of renderStack(artboard.layers)) await drawLayer(context, layer, artboard.layers, assets, artboard.bounds, { maskLayerIds });
context.restore();
const url = canvas.toDataURL("image/png");
const link = document.createElement("a");
link.href = url;
link.download = `${safeFilename(artboard.name)}.png`;
link.click();
return canvas;
}
async function drawLayer(

View File

@@ -1,6 +1,6 @@
import { blurMaskValues, despeckleMaskValues, dilateMaskValues, erodeMaskValues, maskValueFromRgba } from "./maskRaster";
export type MagicWandRasterSettings = { tolerance: number; feather: number; choke: number; despeckle: number; contiguous: boolean; mode: "replace" | "add" | "subtract" };
export type MagicWandRasterSettings = { tolerance: number; feather: number; choke: number; despeckle: number; contiguous: boolean; mode: "replace" | "add" | "subtract"; target?: "visibility" | "inpaint" };
export async function createWandMask(source: string, existingMaskSource: string | undefined, width: number, height: number, startX: number, startY: number, settings: MagicWandRasterSettings) {
const canvas = document.createElement("canvas");
@@ -10,8 +10,9 @@ export async function createWandMask(source: string, existingMaskSource: string
context.drawImage(await loadImage(source), 0, 0, canvas.width, canvas.height);
const data = context.getImageData(0, 0, canvas.width, canvas.height);
const start = (startY * canvas.width + startX) * 4;
const key = [data.data[start] ?? 0, data.data[start + 1] ?? 0, data.data[start + 2] ?? 0];
const selected = settings.contiguous ? floodSelect(data, width, height, startX, startY, key, settings.tolerance) : globalSelect(data, key, settings.tolerance);
const key = rgbToLab(data.data[start] ?? 0, data.data[start + 1] ?? 0, data.data[start + 2] ?? 0);
const keyAlpha = data.data[start + 3] ?? 255;
const selected = settings.contiguous ? floodSelect(data, width, height, startX, startY, key, keyAlpha, settings.tolerance) : globalSelect(data, key, keyAlpha, settings.tolerance);
let values: Uint8ClampedArray<ArrayBufferLike> = toValues(selected);
if (settings.despeckle > 0) values = despeckleMaskValues(values, width, height, Math.round(settings.despeckle));
if (settings.choke > 0) values = erodeMaskValues(values, width, height, Math.round(settings.choke));
@@ -19,20 +20,31 @@ export async function createWandMask(source: string, existingMaskSource: string
if (settings.feather > 0) values = blurMaskValues(values, width, height, Math.round(settings.feather));
const existing = existingMaskSource ? await loadMask(existingMaskSource, width, height) : undefined;
for (let pixel = 0; pixel < values.length; pixel++) {
const current = existing?.[pixel] ?? 255; const selectedValue = values[pixel] ?? 0;
const alpha = settings.mode === "add" ? Math.min(current, 255 - selectedValue) : settings.mode === "subtract" ? Math.max(current, selectedValue) : 255 - selectedValue;
const current = existing?.[pixel] ?? (settings.target === "inpaint" ? 0 : 255); const selectedValue = values[pixel] ?? 0;
const alpha = settings.target === "inpaint"
? settings.mode === "add" ? Math.max(current, selectedValue) : settings.mode === "subtract" ? Math.min(current, 255 - selectedValue) : selectedValue
: settings.mode === "add" ? Math.min(current, 255 - selectedValue) : settings.mode === "subtract" ? Math.max(current, selectedValue) : 255 - selectedValue;
const index = pixel * 4; data.data[index] = data.data[index + 1] = data.data[index + 2] = 255; data.data[index + 3] = alpha;
}
context.putImageData(data, 0, 0); return canvas.toDataURL("image/png");
}
function floodSelect(data: ImageData, width: number, height: number, x: number, y: number, key: number[], tolerance: number) {
function floodSelect(data: ImageData, width: number, height: number, x: number, y: number, key: readonly number[], keyAlpha: number, tolerance: number) {
const result = new Uint8Array(width * height); const queue: Array<[number, number]> = [[x, y]];
while (queue.length) { const [px, py] = queue.pop()!; if (px < 0 || py < 0 || px >= width || py >= height) continue; const i = py * width + px; if (result[i] || !matches(data, i, key, tolerance)) continue; result[i] = 1; queue.push([px + 1, py], [px - 1, py], [px, py + 1], [px, py - 1]); }
while (queue.length) { const [px, py] = queue.pop()!; if (px < 0 || py < 0 || px >= width || py >= height) continue; const i = py * width + px; if (result[i] || !matches(data, i, key, keyAlpha, tolerance)) continue; result[i] = 1; queue.push([px + 1, py], [px - 1, py], [px, py + 1], [px, py - 1]); }
return result;
}
function globalSelect(data: ImageData, key: number[], tolerance: number) { const result = new Uint8Array(data.width * data.height); for (let i = 0; i < result.length; i++) if (matches(data, i, key, tolerance)) result[i] = 1; return result; }
function matches(data: ImageData, pixel: number, key: number[], tolerance: number) { const i = pixel * 4; return Math.hypot((data.data[i] ?? 0) - (key[0] ?? 0), (data.data[i + 1] ?? 0) - (key[1] ?? 0), (data.data[i + 2] ?? 0) - (key[2] ?? 0)) <= tolerance; }
function globalSelect(data: ImageData, key: readonly number[], keyAlpha: number, tolerance: number) { const result = new Uint8Array(data.width * data.height); for (let i = 0; i < result.length; i++) if (matches(data, i, key, keyAlpha, tolerance)) result[i] = 1; return result; }
function matches(data: ImageData, pixel: number, key: readonly number[], keyAlpha: number, tolerance: number) { const i = pixel * 4; const sample = rgbToLab(data.data[i] ?? 0, data.data[i + 1] ?? 0, data.data[i + 2] ?? 0); const colorDistance = Math.hypot((sample[0] ?? 0) - (key[0] ?? 0), (sample[1] ?? 0) - (key[1] ?? 0), (sample[2] ?? 0) - (key[2] ?? 0)); const alphaDistance = Math.abs((data.data[i + 3] ?? 255) - keyAlpha) / 2.55; return Math.hypot(colorDistance, alphaDistance) <= tolerance * 0.45; }
function rgbToLab(red: number, green: number, blue: number): [number, number, number] {
const linear = [red, green, blue].map((value) => { const channel = value / 255; return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; });
const x = ((linear[0] ?? 0) * 0.4124 + (linear[1] ?? 0) * 0.3576 + (linear[2] ?? 0) * 0.1805) / 0.95047;
const y = ((linear[0] ?? 0) * 0.2126 + (linear[1] ?? 0) * 0.7152 + (linear[2] ?? 0) * 0.0722);
const z = ((linear[0] ?? 0) * 0.0193 + (linear[1] ?? 0) * 0.1192 + (linear[2] ?? 0) * 0.9505) / 1.08883;
const f = (value: number) => value > 0.008856 ? Math.cbrt(value) : 7.787 * value + 16 / 116;
return [116 * f(y) - 16, 500 * (f(x) - f(y)), 200 * (f(y) - f(z))];
}
function toValues(selected: Uint8Array) { const values = new Uint8ClampedArray(selected.length); for (let i = 0; i < selected.length; i++) values[i] = selected[i] ? 255 : 0; return values; }
async function loadMask(source: string, width: number, height: number) { const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const context = canvas.getContext("2d"); if (!context) return undefined; context.drawImage(await loadImage(source), 0, 0, width, height); const data = context.getImageData(0, 0, width, height); const values = new Uint8ClampedArray(width * height); for (let i = 0; i < values.length; i++) values[i] = maskValueFromRgba(data.data, i * 4); return values; }
function loadImage(source: string) { return new Promise<HTMLImageElement>((resolve, reject) => { const image = new Image(); image.onload = () => resolve(image); image.onerror = () => reject(new Error("Failed to load image")); image.src = source; }); }

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { blurMaskValues, cropMaskValuesToRgba, dilateMaskValues, erodeMaskValues, expandRectWithinBounds, invertMaskValues } from "./maskRaster";
import { blurMaskValues, cropMaskValuesToRgba, dilateMaskValues, erodeMaskValues, expandRectWithinBounds, invertMaskValues, limitMaskValues } from "./maskRaster";
describe("mask raster utilities", () => {
test("exports the normalized drawn mask without filling the whole crop", () => {
@@ -35,6 +35,15 @@ describe("mask raster utilities", () => {
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));
});
test("clips AI edit regions to a retained source crop", () => {
expect([...limitMaskValues(new Uint8ClampedArray(16).fill(255), 4, 4, { x: 1, y: 1, w: 2, h: 2 })]).toEqual([
0, 0, 0, 0,
0, 255, 255, 0,
0, 255, 255, 0,
0, 0, 0, 0,
]);
});
});
function activeRedPixels(rgba: Uint8ClampedArray) {

View File

@@ -30,6 +30,7 @@ export type NormalizedMaskOptions = {
feather?: number;
blur?: number;
despeckle?: number;
limit?: Rect;
};
export async function createSolidMaskSource(width: number, height: number, fill: MaskFill): Promise<string> {
@@ -58,6 +59,36 @@ export async function applyMaskRasterOperation(source: string, width: number, he
return maskValuesToDataUrl(next, mask.width, mask.height);
}
export async function applyPolygonMask(source: string, width: number, height: number, points: readonly { x: number; y: number }[], mode: "replace" | "add" | "subtract"): Promise<string> {
assertProcessingRasterSize(width, height, "Mask");
if (points.length < 3) return source;
const canvas = await loadImageCanvas(source, width, height);
const context = require2dContext(canvas);
if (mode === "replace") context.clearRect(0, 0, canvas.width, canvas.height);
context.save();
context.globalCompositeOperation = mode === "subtract" ? "destination-out" : "source-over";
context.fillStyle = "#ffffff";
context.beginPath();
context.moveTo(points[0]?.x ?? 0, points[0]?.y ?? 0);
for (let index = 1; index < points.length; index += 1) context.lineTo(points[index]?.x ?? 0, points[index]?.y ?? 0);
context.closePath();
context.fill();
context.restore();
return canvas.toDataURL("image/png");
}
export async function mergeMaskSources(existingSource: string, selectedSource: string, width: number, height: number, mode: "replace" | "add" | "subtract"): Promise<string> {
assertProcessingRasterSize(width, height, "Mask");
const [existing, selected] = await Promise.all([loadMaskValues(existingSource, width, height), loadMaskValues(selectedSource, width, height)]);
const values = new Uint8ClampedArray(width * height);
for (let pixel = 0; pixel < values.length; pixel += 1) {
const current = existing.values[pixel] ?? 0;
const choice = selected.values[pixel] ?? 0;
values[pixel] = mode === "add" ? Math.max(current, choice) : mode === "subtract" ? Math.min(current, 255 - choice) : choice;
}
return maskValuesToDataUrl(values, width, height);
}
export async function analyzeMaskSource(source: string, width: number, height: number): Promise<MaskAnalysis> {
assertProcessingRasterSize(width, height, "Mask");
const mask = await loadMaskValues(source, width, height);
@@ -81,6 +112,7 @@ export async function createNormalizedMaskSource(source: string, width: number,
assertProcessingRasterSize(width, height, "Mask");
const mask = await loadMaskValues(source, width, height);
let values = options.polarity === "hidden" ? invertMaskValues(mask.values) : new Uint8ClampedArray(mask.values);
if (options.limit) values = limitMaskValues(values, mask.width, mask.height, options.limit);
const despeckle = Math.round(clampNumber(options.despeckle ?? 0, 0, 64));
const expand = Math.round(clampNumber(options.expand ?? 0, -256, 256));
@@ -97,6 +129,16 @@ export async function createNormalizedMaskSource(source: string, width: number,
return { source: maskValuesToDataUrl(values, mask.width, mask.height), values, bounds: analysis.bounds };
}
export function limitMaskValues(values: Uint8ClampedArray, width: number, height: number, limit: Rect): Uint8ClampedArray {
const next = new Uint8ClampedArray(values.length);
const x1 = Math.max(0, Math.floor(limit.x));
const y1 = Math.max(0, Math.floor(limit.y));
const x2 = Math.min(width, Math.ceil(limit.x + limit.w));
const y2 = Math.min(height, Math.ceil(limit.y + limit.h));
for (let y = y1; y < y2; y += 1) for (let x = x1; x < x2; x += 1) next[y * width + x] = values[y * width + x] ?? 0;
return next;
}
export async function loadImageCanvas(source: string, width?: number, height?: number): Promise<HTMLCanvasElement> {
const image = await loadImage(source);
assertCanvasRasterSize(width ?? image.naturalWidth, height ?? image.naturalHeight);
@@ -122,6 +164,33 @@ export function cropCanvas(sourceCanvas: HTMLCanvasElement, crop: Rect, outputWi
return canvas.toDataURL("image/png");
}
export function sampleDocumentCanvasInLayerSpace(
documentCanvas: HTMLCanvasElement,
artboardBounds: Rect,
layer: { transform: { position: { x: number; y: number }; scale: { x: number; y: number }; rotation: number }; sourceRect?: Rect },
intrinsicSize: { w: number; h: number },
crop: Rect,
outputWidth = crop.w,
outputHeight = crop.h,
): string {
assertProcessingRasterSize(outputWidth, outputHeight, "Context crop");
const canvas = createCanvas(outputWidth, outputHeight);
const context = require2dContext(canvas);
const source = layer.sourceRect ?? { x: 0, y: 0, ...intrinsicSize };
const center = {
x: layer.transform.position.x + (source.x + source.w / 2) * layer.transform.scale.x,
y: layer.transform.position.y + (source.y + source.h / 2) * layer.transform.scale.y,
};
context.translate(-crop.x, -crop.y);
context.scale(1 / Math.max(0.0001, layer.transform.scale.x), 1 / Math.max(0.0001, layer.transform.scale.y));
context.translate(-layer.transform.position.x, -layer.transform.position.y);
context.translate(center.x, center.y);
context.rotate(-layer.transform.rotation);
context.translate(-center.x, -center.y);
context.drawImage(documentCanvas, artboardBounds.x, artboardBounds.y);
return canvas.toDataURL("image/png");
}
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);

View File

@@ -4,8 +4,40 @@ export async function fetchGenerationOptions(): Promise<unknown> {
return response.json() as Promise<unknown>;
}
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), signal });
export type GenerationResult = { source: string; mimeType: string; seed: number };
export async function requestSemanticSelection(body: { inputImage: string; x: number; y: number; model?: string }, signal?: AbortSignal): Promise<{ source: string; mimeType: string }> {
const response = await fetch("/api/comfy/segment", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal });
if (!response.ok) throw new Error(await response.text());
return response.json() as Promise<{ source: string; mimeType: string }>;
}
export async function requestGeneration(body: unknown, signal?: AbortSignal, onProgress?: (progress: number, detail: string) => void): Promise<{ results: GenerationResult[] }> {
const response = await fetch("/api/comfy/generate", { method: "POST", headers: { "content-type": "application/json", accept: "application/x-ndjson" }, body: JSON.stringify(body), signal });
if (!response.ok) throw new Error(await response.text());
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("application/x-ndjson") && response.body) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let pending = "";
while (true) {
const { value, done } = await reader.read();
pending += decoder.decode(value, { stream: !done });
const lines = pending.split("\n");
pending = lines.pop() ?? "";
for (const line of lines) {
if (!line.trim()) continue;
const event = JSON.parse(line) as { type: "progress"; progress: number; detail: string } | { type: "result"; results: GenerationResult[] } | { type: "error"; message: string };
if (event.type === "progress") onProgress?.(event.progress, event.detail);
if (event.type === "error") throw new Error(event.message);
if (event.type === "result") return { results: event.results };
}
if (done) break;
}
throw new Error("Generation stream ended without results");
}
const payload = await response.json() as { results?: GenerationResult[]; source?: string; mimeType?: string };
if (payload.results?.length) return { results: payload.results };
if (payload.source) return { results: [{ source: payload.source, mimeType: payload.mimeType ?? "image/png", seed: 0 }] };
throw new Error("Generation returned no results");
}

View File

@@ -1,7 +1,7 @@
import type { ImageDocument } from "@core/document";
import type { Vec2D } from "@core/geometry";
import type { Layer } from "@core/layer";
import type { RasterLayer } from "@core/raster-layer";
import { getLayerMask } from "@core/layer-mask-utils";
import type { EditorState } from "@editor/state";
import type { RgbaColor, WebGlRendererContext } from "./types";
@@ -79,13 +79,13 @@ function drawPreviewPass(
}
function resolveBrushPreview(document: ImageDocument, editor: EditorState, canvas: HTMLCanvasElement) {
if (!editor.brushPreview || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
if (!editor.brushPreview || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser" && editor.tools.activeTool !== "feather")) return undefined;
if (editor.tools.interactionMode.type === "temporary-pan" || (editor.tools.interactionMode.type === "tool" && editor.tools.interactionMode.tool === "pan")) return undefined;
const layer = resolveBrushTargetLayer(document, editor);
if (!layer) return undefined;
const size = Math.max(1, editor.tools.brush.size);
const size = Math.max(1, editor.tools.activeTool === "feather" ? editor.tools.feather.size : editor.tools.brush.size);
const zoom = editor.viewport.zoom;
return {
center: documentPointToScreenPoint(canvas, editor.brushPreview.position, editor),
@@ -93,16 +93,19 @@ function resolveBrushPreview(document: ImageDocument, editor: EditorState, canva
x: Math.max(1, Math.abs(layer.transform.scale.x) * size * zoom * 0.5),
y: Math.max(1, Math.abs(layer.transform.scale.y) * size * zoom * 0.5),
},
hardness: Math.max(0, Math.min(1, editor.tools.brush.hardness / 100)),
hardness: editor.tools.activeTool === "feather" ? 0.72 : Math.max(0, Math.min(1, editor.tools.brush.hardness / 100)),
};
}
function resolveBrushTargetLayer(document: ImageDocument, editor: EditorState): RasterLayer | undefined {
function resolveBrushTargetLayer(document: ImageDocument, editor: EditorState): Extract<Layer, { type: "image" | "raster" }> | undefined {
const editingMask = Boolean(editor.maskEdit);
const layerId = editor.maskEdit?.maskLayerId ?? editor.selection.layerIds[0];
const layerId = editor.maskEdit?.kind === "inpaintRegion" ? editor.maskEdit.targetLayerId : editor.maskEdit?.maskLayerId ?? editor.selection.layerIds[0];
if (!layerId) return undefined;
const layer = findRasterLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId);
const layers = document.artboards.flatMap((artboard) => artboard.layers);
const selectedLayer = findPaintableLayer(layers, layerId);
const maskLayerId = editor.tools.activeTool === "feather" && !editingMask && selectedLayer ? getLayerMask(selectedLayer)?.maskLayerId : undefined;
const layer = maskLayerId ? findPaintableLayer(layers, maskLayerId) : selectedLayer;
if (!layer || layer.locked || (!editingMask && !layer.visible)) return undefined;
return layer;
}
@@ -124,11 +127,11 @@ function previewVertices(center: Vec2D, radius: Vec2D) {
return new Float32Array([x1, y1, x2, y1, x1, y2, x1, y2, x2, y1, x2, y2]);
}
function findRasterLayer(layers: readonly Layer[], layerId: string): RasterLayer | undefined {
function findPaintableLayer(layers: readonly Layer[], layerId: string): Extract<Layer, { type: "image" | "raster" }> | undefined {
for (const layer of layers) {
if (layer.id === layerId && layer.type === "raster") return layer;
if (layer.id === layerId && (layer.type === "image" || layer.type === "raster")) return layer;
if (layer.type === "group") {
const child = findRasterLayer(layer.children, layerId);
const child = findPaintableLayer(layer.children, layerId);
if (child) return child;
}
}

View File

@@ -80,6 +80,8 @@ export function maskVisualizationModeValue(mode: MaskVisualizationMode) {
return 1;
case "hiddenOverlay":
return 2;
case "activeOverlay":
return 3;
}
}
@@ -119,7 +121,7 @@ function createMaskVisualizationProgram(gl: WebGL2RenderingContext): MaskVisuali
return;
}
float alpha = (1.0 - maskAlpha) * u_color.a;
float alpha = (u_mode == 3 ? maskAlpha : (1.0 - maskAlpha)) * u_color.a;
outColor = vec4(u_color.rgb * alpha, alpha);
}`,
);

View File

@@ -5,10 +5,10 @@ import type { Rect } from "@core/geometry";
import { rotatedRectBounds, rotatedRectCorners } from "./rotated-rect";
import { textureCoordinatesForCrop, textureCoordinatesForRect } from "./texture-coordinates";
export type MaskVisualizationMode = "blackWhite" | "alpha" | "hiddenOverlay";
export type MaskVisualizationMode = "blackWhite" | "alpha" | "hiddenOverlay" | "activeOverlay";
export type ImageTextureRenderer = {
syncAssets(assets: readonly Asset[]): void;
syncAssets(assets: readonly Pick<Asset, "id">[]): void;
render(asset: Asset, rect: ScreenRect, clipRect?: ScreenRect, opacity?: number, rotation?: number, sourceRect?: Rect): boolean;
renderMasked(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, clipRect?: ScreenRect, opacity?: number, rotation?: number, sourceRect?: Rect): boolean;
renderMaskRevealPreview(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, opacity: number, clipRect?: ScreenRect, layerOpacity?: number, rotation?: number, sourceRect?: Rect): boolean;
@@ -346,7 +346,8 @@ function loadTextureState(
const source = asset.source;
image.onload = () => {
if (isDisposed() || entry.source !== source) return;
const texture = createTexture(context.gl, image);
const textureSource = asset.mimeType === "image/svg+xml" ? rasterizeSvgImage(image, asset) : image;
const texture = createTexture(context.gl, textureSource);
if (entry.previousTexture) context.gl.deleteTexture(entry.previousTexture);
entry.previousTexture = undefined;
entry.current = { status: "ready", texture };
@@ -361,6 +362,17 @@ function loadTextureState(
return { status: "loading", image };
}
function rasterizeSvgImage(image: HTMLImageElement, asset: Asset): HTMLCanvasElement {
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.ceil(asset.intrinsicSize.w));
canvas.height = Math.max(1, Math.ceil(asset.intrinsicSize.h));
const context = canvas.getContext("2d");
if (!context) throw new Error("Failed to create SVG rasterization context");
context.clearRect(0, 0, canvas.width, canvas.height);
context.drawImage(image, 0, 0, canvas.width, canvas.height);
return canvas;
}
function renderableTexture(entry: TextureEntry): WebGLTexture | undefined {
if (entry.current.status === "ready") return entry.current.texture;
return entry.previousTexture;
@@ -392,7 +404,7 @@ function intersectScreenRects(a: ScreenRect, b: ScreenRect): ScreenRect | undefi
return { x: x1, y: y1, w: x2 - x1, h: y2 - y1 };
}
function createTexture(gl: WebGL2RenderingContext, image: HTMLImageElement) {
function createTexture(gl: WebGL2RenderingContext, image: TexImageSource) {
const texture = gl.createTexture();
if (!texture) throw new Error("Failed to create image texture");

View File

@@ -10,11 +10,12 @@ import type { ImageTextureRenderer } from "./image-textures";
import { documentRectToScreenRect } from "./screen-rect";
import type { RgbaColor, ScreenRect, WebGlRendererContext } from "./types";
import type { AdjustmentPass } from "./adjustment-pass";
import { textLayerRenderAsset } from "./text-asset";
import { rasterizedTextLayerRenderAsset } from "./text-asset";
const imageLayerColor: RgbaColor = [0.38, 0.42, 0.5, 1];
const imageLayerInsetColor: RgbaColor = [0.48, 0.54, 0.64, 1];
const hiddenMaskOverlayColor: RgbaColor = [1, 0.08, 0.08, 0.45];
const inpaintRegionOverlayColor: RgbaColor = [1, 0.12, 0.18, 0.48];
const comparisonDividerColor: RgbaColor = [1, 1, 1, 0.9];
const maskRevealPreviewOpacity = 0.28;
@@ -77,7 +78,7 @@ function renderTextLayer(context: WebGlRendererContext, documentIndex: DocumentR
const bounds = resolveIndexedLayerBounds(documentIndex, layer);
if (!bounds) return;
const rect = documentRectToScreenRect(context.canvas, bounds, editor.viewport);
imageTextureRenderer.render(textLayerRenderAsset(layer), rect, clipRect, opacity, layer.transform.rotation);
imageTextureRenderer.render(rasterizedTextLayerRenderAsset(layer), rect, clipRect, opacity, layer.transform.rotation);
}
function renderLeafLayer(
@@ -99,12 +100,39 @@ function renderLeafLayer(
const rect = documentRectToScreenRect(context.canvas, bounds, editor.viewport);
const asset = assetWithBrushStrokePreview(documentIndex.assetById.get(layer.assetId), editor);
const activeInpaintRegion = editor.maskEdit?.kind === "inpaintRegion" && editor.maskEdit.targetLayerId === layer.id
? documentIndex.assetById.get(editor.maskEdit.maskAssetId)
: undefined;
if (asset && activeInpaintRegion) {
const inpaintMaskAsset = assetWithBrushStrokePreview(activeInpaintRegion, editor);
const inpaintMaskRect = documentRectToScreenRect(context.canvas, {
x: layer.transform.position.x,
y: layer.transform.position.y,
w: inpaintMaskAsset.intrinsicSize.w * layer.transform.scale.x,
h: inpaintMaskAsset.intrinsicSize.h * layer.transform.scale.y,
}, editor.viewport);
if (maskViewMode === "blackWhite" && imageTextureRenderer.renderMaskVisualization(inpaintMaskAsset, inpaintMaskRect, "blackWhite", undefined, effectiveClipRect)) return;
if (maskViewMode === "alpha" && imageTextureRenderer.renderMaskVisualization(inpaintMaskAsset, inpaintMaskRect, "alpha", undefined, effectiveClipRect)) return;
if (imageTextureRenderer.render(asset, rect, effectiveClipRect, effectiveOpacity, layer.transform.rotation, layer.sourceRect)) {
imageTextureRenderer.renderMaskVisualization(inpaintMaskAsset, inpaintMaskRect, "activeOverlay", inpaintRegionOverlayColor, effectiveClipRect);
return;
}
}
const layerMask = getLayerMask(layer);
const maskLayer = !editingMaskLayer && layerMask?.enabled ? documentIndex.layerById.get(layerMask.maskLayerId) : undefined;
const maskAsset = assetWithBrushStrokePreview(maskLayer && (maskLayer.type === "image" || maskLayer.type === "raster") ? documentIndex.assetById.get(maskLayer.assetId) : undefined, editor);
const pendingMaskPreview = editor.brushStrokePreview?.pendingTargetLayerId === layer.id && editor.brushStrokePreview.intrinsicSize
? {
id: editor.brushStrokePreview.assetId,
name: `${layer.name} Mask Preview`,
mimeType: "image/png",
source: editor.brushStrokePreview.source,
intrinsicSize: editor.brushStrokePreview.intrinsicSize,
}
: undefined;
const maskAsset = assetWithBrushStrokePreview(maskLayer && (maskLayer.type === "image" || maskLayer.type === "raster") ? documentIndex.assetById.get(maskLayer.assetId) : undefined, editor) ?? pendingMaskPreview;
const maskBounds = maskLayer ? resolveIndexedLayerBounds(documentIndex, maskLayer) : undefined;
const maskRect = maskBounds ? documentRectToScreenRect(context.canvas, maskBounds, editor.viewport) : undefined;
const activeMaskTarget = Boolean(editor.maskEdit?.targetLayerId === layer.id && editor.maskEdit.maskLayerId === layerMask?.maskLayerId);
const maskRect = maskBounds ? documentRectToScreenRect(context.canvas, maskBounds, editor.viewport) : pendingMaskPreview ? rect : undefined;
const activeMaskTarget = Boolean((editor.maskEdit?.targetLayerId === layer.id && editor.maskEdit.maskLayerId === layerMask?.maskLayerId) || pendingMaskPreview);
const showMaskRevealPreview = editor.tools.activeTool === "brush" && activeMaskTarget && maskViewMode === "composite";
if (asset && maskAsset && maskRect && activeMaskTarget) {

View File

@@ -0,0 +1,32 @@
import type { EditorState } from "@editor/state";
import { clearScreenRect } from "./clear-rect";
import type { WebGlRendererContext } from "./types";
const previewColor = [1, 0.18, 0.24, 0.95] as const;
export function renderMaskShapePreview(context: WebGlRendererContext, editor: EditorState) {
const session = editor.maskShapeSession;
const points = session?.shape === "rectangle" && session.points.length > 1
? rectanglePoints(session.points[0]!, session.points[1]!)
: session?.points;
if (!points || points.length === 0) return;
const screenPoints = points.map((point) => ({
x: context.canvas.width / 2 + (point.x - editor.viewport.center.x) * editor.viewport.zoom,
y: context.canvas.height / 2 + (point.y - editor.viewport.center.y) * editor.viewport.zoom,
}));
const closed = screenPoints.length > 2 ? [...screenPoints, screenPoints[0]!] : screenPoints;
for (let index = 1; index < closed.length; index += 1) {
const from = closed[index - 1]!;
const to = closed[index]!;
const distance = Math.max(1, Math.hypot(to.x - from.x, to.y - from.y));
const steps = Math.max(1, Math.ceil(distance / 3));
for (let step = 0; step <= steps; step += 1) {
const amount = step / steps;
clearScreenRect(context, { x: from.x + (to.x - from.x) * amount - 1.5, y: from.y + (to.y - from.y) * amount - 1.5, w: 3, h: 3 }, previewColor);
}
}
}
function rectanglePoints(start: { x: number; y: number }, end: { x: number; y: number }) {
return [start, { x: end.x, y: start.y }, end, { x: start.x, y: end.y }];
}

View File

@@ -1,5 +1,6 @@
import type { ImageDocument } from "@core/document";
import type { EditorState } from "@editor/state";
import { createDocumentReadIndex } from "@editor/document-indexes";
import { renderArtboard } from "./artboard";
import { createBrushPreviewRenderer } from "./brush-preview";
import { createCheckerboardRenderer } from "./checkerboard";
@@ -9,6 +10,7 @@ import { renderSelectionOverlay } from "./selection";
import { renderTransformControls } from "./transform-controls";
import type { WebGlRendererContext } from "./types";
import { createAdjustmentPass } from "./adjustment-pass";
import { renderMaskShapePreview } from "./mask-shape-preview";
export type RenderFrame = {
document: ImageDocument;
@@ -64,7 +66,11 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke
for (const artboard of frame.document.artboards) {
if (artboard.visible) renderArtboard(rendererContext, artboard, frame.editor.viewport, checkerboardRenderer);
}
imageTextureRenderer.syncAssets([...frame.document.assets, ...generationCandidatePreviewAssets(frame.editor)]);
imageTextureRenderer.syncAssets([
...frame.document.assets,
...generationCandidatePreviewAssets(frame.editor),
...textTextureHandles(frame.document),
]);
renderLayers(rendererContext, frame.document, frame.editor, imageTextureRenderer, adjustmentPass);
if (!frame.editor.maskEdit) {
@@ -72,6 +78,7 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke
renderTransformControls(rendererContext, frame.document, frame.editor);
}
brushPreviewRenderer?.render(frame.document, frame.editor);
renderMaskShapePreview(rendererContext, frame.editor);
context.disable(context.SCISSOR_TEST);
},
@@ -86,6 +93,13 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke
return renderer;
}
function textTextureHandles(document: ImageDocument): Array<{ id: string }> {
const index = createDocumentReadIndex(document);
return [...index.layerById.values()]
.filter((layer) => layer.type === "text")
.map((layer) => ({ id: `text-render:${layer.id}` }));
}
function createOptionalBrushPreviewRenderer(context: WebGlRendererContext) {
try {
return createBrushPreviewRenderer(context);

View File

@@ -14,6 +14,34 @@ export function textLayerRenderAsset(layer: TextLayer): Asset {
return { id: `text-render:${layer.id}`, name: layer.name, mimeType: "image/svg+xml", source: `data:image/svg+xml,${encodeURIComponent(svg)}`, intrinsicSize: size };
}
/** Rasterizes authoritative text for the live WebGL renderer without relying on browser SVG text decoding. */
export function rasterizedTextLayerRenderAsset(layer: TextLayer): Asset {
const size = measureTextLayer(layer);
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.ceil(size.w));
canvas.height = Math.max(1, Math.ceil(size.h));
const context = canvas.getContext("2d");
if (!context) throw new Error("Failed to create text rasterization context");
context.clearRect(0, 0, canvas.width, canvas.height);
context.fillStyle = layer.style.color;
context.font = `${layer.style.fontStyle} ${layer.style.fontWeight} ${layer.style.fontSize}px ${JSON.stringify(layer.style.fontFamily)}, sans-serif`;
context.textAlign = layer.style.alignment;
context.textBaseline = "alphabetic";
const x = layer.style.alignment === "left" ? 0 : layer.style.alignment === "center" ? size.w / 2 : size.w;
for (const [index, line] of layer.content.split("\n").entries()) {
context.fillText(line || " ", x, (index + 0.82) * layer.style.fontSize * layer.style.lineHeight);
}
return {
id: `text-render:${layer.id}`,
name: layer.name,
mimeType: "image/png",
source: canvas.toDataURL("image/png"),
intrinsicSize: size,
};
}
function escapeXml(value: string) {
return value.replace(/[&<>"']/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" })[character]!);
}

74
scripts/dev-with-comfy.sh Executable file
View File

@@ -0,0 +1,74 @@
#!/usr/bin/env bash
set -euo pipefail
COMFYUI_HOST="${COMFYUI_HOST:-127.0.0.1}"
COMFYUI_PORT="${COMFYUI_PORT:-8188}"
COMFYUI_URL="${COMFYUI_URL:-http://${COMFYUI_HOST}:${COMFYUI_PORT}}"
COMFYUI_BASE_DIR="${COMFYUI_BASE_DIR:-/Users/syntaxbullet/Documents/ComfyUI}"
COMFYUI_SOURCE_DIR="${COMFYUI_SOURCE_DIR:-/Users/syntaxbullet/ComfyUI-Installs/ComfyUI/ComfyUI}"
COMFYUI_PYTHON="${COMFYUI_PYTHON:-${COMFYUI_BASE_DIR}/.venv/bin/python}"
COMFYUI_MODEL_PATHS_CONFIG="${COMFYUI_MODEL_PATHS_CONFIG:-/Users/syntaxbullet/Library/Application Support/Comfy Desktop/shared_model_paths.yaml}"
COMFYUI_READY_TIMEOUT_SECONDS="${COMFYUI_READY_TIMEOUT_SECONDS:-120}"
comfy_pid=""
cleanup() {
if [[ -n "${comfy_pid}" ]] && kill -0 "${comfy_pid}" 2>/dev/null; then
echo "Stopping ComfyUI (PID ${comfy_pid})..."
kill "${comfy_pid}" 2>/dev/null || true
wait "${comfy_pid}" 2>/dev/null || true
fi
}
trap cleanup EXIT INT TERM
comfy_is_ready() {
curl --fail --silent --show-error --max-time 2 "${COMFYUI_URL}/object_info" >/dev/null 2>&1
}
if comfy_is_ready; then
echo "Using existing ComfyUI server at ${COMFYUI_URL}."
else
if [[ ! -x "${COMFYUI_PYTHON}" ]]; then
echo "ComfyUI Python executable not found: ${COMFYUI_PYTHON}" >&2
exit 1
fi
if [[ ! -f "${COMFYUI_SOURCE_DIR}/main.py" ]]; then
echo "ComfyUI entrypoint not found: ${COMFYUI_SOURCE_DIR}/main.py" >&2
exit 1
fi
echo "Starting ComfyUI at ${COMFYUI_URL}..."
"${COMFYUI_PYTHON}" "${COMFYUI_SOURCE_DIR}/main.py" \
--base-directory "${COMFYUI_BASE_DIR}" \
--user-directory "${COMFYUI_BASE_DIR}/user" \
--input-directory "${COMFYUI_BASE_DIR}/input" \
--output-directory "${COMFYUI_BASE_DIR}/output" \
--extra-model-paths-config "${COMFYUI_MODEL_PATHS_CONFIG}" \
--listen "${COMFYUI_HOST}" \
--port "${COMFYUI_PORT}" &
comfy_pid=$!
deadline=$((SECONDS + COMFYUI_READY_TIMEOUT_SECONDS))
until comfy_is_ready; do
if ! kill -0 "${comfy_pid}" 2>/dev/null; then
wait "${comfy_pid}" || true
echo "ComfyUI exited before becoming ready." >&2
exit 1
fi
if (( SECONDS >= deadline )); then
echo "Timed out waiting for ComfyUI after ${COMFYUI_READY_TIMEOUT_SECONDS} seconds." >&2
exit 1
fi
sleep 1
done
echo "ComfyUI is ready."
fi
echo "Starting Image Studio..."
COMFYUI_URL="${COMFYUI_URL}" bun --hot index.ts

View File

@@ -1,16 +1,34 @@
import { generate, listGenerationOptions, type ComfyGenerateRequest } from "./comfy";
import { generate, listGenerationOptions, segment, type ComfyGenerateRequest, type ComfySegmentRequest } from "./comfy";
export async function handleComfyApi(request: Request) {
try {
const url = new URL(request.url);
if (url.pathname === "/api/comfy/models" && request.method === "GET") return json(await listGenerationOptions());
if (url.pathname === "/api/comfy/generate" && request.method === "POST") return json(await generate(await request.json() as ComfyGenerateRequest, request.signal));
if (url.pathname === "/api/comfy/generate" && request.method === "POST") {
const body = await request.json() as ComfyGenerateRequest;
if (request.headers.get("accept")?.includes("application/x-ndjson")) return generationStream(body, request.signal);
return json(await generate(body, request.signal));
}
if (url.pathname === "/api/comfy/segment" && request.method === "POST") return json(await segment(await request.json() as ComfySegmentRequest, request.signal));
return new Response("Not found", { status: 404 });
} catch (error) {
return new Response(error instanceof Error ? error.message : "ComfyUI request failed", { status: 500 });
}
}
function generationStream(body: ComfyGenerateRequest, signal: AbortSignal) {
const encoder = new TextEncoder();
return new Response(new ReadableStream({
start(controller) {
const send = (value: unknown) => controller.enqueue(encoder.encode(`${JSON.stringify(value)}\n`));
void generate(body, signal, (event) => send({ type: "progress", ...event }))
.then((result) => { send({ type: "result", ...result }); controller.close(); })
.catch((error) => { send({ type: "error", message: error instanceof Error ? error.message : "Generation failed" }); controller.close(); });
},
cancel() {},
}), { headers: { "content-type": "application/x-ndjson; charset=utf-8", "cache-control": "no-store" } });
}
function json(value: unknown) {
return new Response(JSON.stringify(value), { headers: { "content-type": "application/json" } });
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { buildAnimaWorkflow, buildSdxlWorkflow, buildZImageTurboWorkflow, buildZImageWorkflow, selectGeneratedOutputImage } from "./comfy";
import { buildAnimaWorkflow, buildSdxlWorkflow, buildSemanticSelectionWorkflow, buildZImageTurboWorkflow, buildZImageWorkflow, selectGeneratedOutputImage, selectGeneratedOutputImages } from "./comfy";
import { handleComfyApi } from "./comfy-routes";
describe("Comfy adapter", () => {
@@ -26,6 +26,13 @@ describe("Comfy adapter", () => {
expect(image?.filename).toBe("image-studio-inpaint_00002_.png");
});
test("retains every generated image from a batch", () => {
expect(selectGeneratedOutputImages({ outputs: { "8": { images: [
{ filename: "image-studio-inpaint_1.png", type: "output" },
{ filename: "image-studio-inpaint_2.png", type: "output" },
] } } })).toHaveLength(2);
});
test("builds neutral inpaint with VAEEncodeForInpaint", () => {
const workflow = buildSdxlWorkflow(inpaintRequest({ maskedContent: "neutral" }));
@@ -45,6 +52,40 @@ describe("Comfy adapter", () => {
expect(workflow["6"]?.inputs.latent_image).toEqual(["13", 0]);
});
test("repeats inpaint latents for batches and applies Canny ControlNet", () => {
const workflow = buildSdxlWorkflow({
...inpaintRequest({ maskedContent: "neutral" }),
batchSize: 4,
inpaint: { maskedContent: "neutral", structureControl: "canny", controlModel: "controlnet-canny.safetensors", controlStrength: 0.6 },
});
expect(workflow["19"]).toMatchObject({ class_type: "RepeatLatentBatch", inputs: { amount: 4 } });
expect(workflow["20"]).toMatchObject({ class_type: "ControlNetLoader", inputs: { control_net_name: "controlnet-canny.safetensors" } });
expect(workflow["21"]?.class_type).toBe("Canny");
expect(workflow["22"]).toMatchObject({ class_type: "ControlNetApplyAdvanced", inputs: { strength: 0.6 } });
expect(workflow["6"]?.inputs).toMatchObject({ latent_image: ["19", 0], positive: ["22", 0], negative: ["22", 1] });
});
test("uses Xinsir-compatible pose rendering for pose ControlNet", () => {
const workflow = buildSdxlWorkflow({
...inpaintRequest({ maskedContent: "neutral" }),
inpaint: { maskedContent: "neutral", structureControl: "pose", controlModel: "controlnet-openpose-sdxl-1.0.safetensors" },
});
expect(workflow["21"]).toMatchObject({ class_type: "OpenposePreprocessor", inputs: { detect_hand: "enable", detect_body: "enable", detect_face: "enable", scale_stick_for_xinsr_cn: "enable" } });
});
test("adds an optional low-denoise detail pass", () => {
const workflow = buildSdxlWorkflow({ ...inpaintRequest({ maskedContent: "neutral" }), refinePass: true, refineStrength: 18, seed: 40 });
expect(workflow["30"]).toMatchObject({ class_type: "KSampler", inputs: { seed: 41, denoise: 0.18, latent_image: ["6", 0] } });
expect(workflow["7"]?.inputs.samples).toEqual(["30", 0]);
});
test("builds native SAM3 point selection as a mask output", () => {
const workflow = buildSemanticSelectionWorkflow({ inputImage: "input.png", model: "sam3.safetensors", x: 24.4, y: 18.6 });
expect(workflow["1"]).toMatchObject({ class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "sam3.safetensors" } });
expect(workflow["3"]).toMatchObject({ class_type: "SAM3_Detect", inputs: { positive_coords: '[{"x":24,"y":19}]', refine_iterations: 2 } });
expect(workflow["4"]).toMatchObject({ class_type: "MaskToImage", inputs: { mask: ["3", 0] } });
});
test("builds Z-Image text-to-image with separated model loaders", () => {
const workflow = buildZImageWorkflow(textRequest({ architecture: "z-image", model: "z_image_bf16.safetensors", steps: 30, cfg: 4 }));
@@ -90,7 +131,7 @@ describe("Comfy adapter", () => {
test("lists branded non-Z diffusion models under Anima", async () => {
const originalFetch = globalThis.fetch;
const mockFetch: typeof fetch = Object.assign(async () => new Response(JSON.stringify({
CheckpointLoaderSimple: { input: { required: { ckpt_name: [["sd_xl_base_1.0.safetensors"]] } } },
CheckpointLoaderSimple: { input: { required: { ckpt_name: [["sd_xl_base_1.0.safetensors", "sam3.1_multiplex_fp16.safetensors"]] } } },
KSampler: { input: { required: { sampler_name: [["euler"]], scheduler: [["normal"]] } } },
UNETLoader: {
input: {
@@ -107,12 +148,29 @@ describe("Comfy adapter", () => {
},
CLIPLoader: { input: { required: { clip_name: [["qwen_3_06b_base.safetensors"]] } } },
VAELoader: { input: { required: { vae_name: [["qwen_image_vae.safetensors"]] } } },
ControlNetLoader: { input: { required: { control_net_name: [[
"controlnet-canny-sdxl-1.0-fp16.safetensors",
"controlnet-depth-sdxl-1.0-fp16.safetensors",
"controlnet-openpose-sdxl-1.0.safetensors",
]] } } },
Canny: {},
"MiDaS-DepthMapPreprocessor": {},
OpenposePreprocessor: {},
ControlNetApplyAdvanced: {},
SAM3_Detect: {},
MaskToImage: {},
}), { headers: { "content-type": "application/json" } }), { preconnect: originalFetch.preconnect });
globalThis.fetch = mockFetch;
try {
const response = await handleComfyApi(new Request("http://image-studio.test/api/comfy/models"));
const body = await response.json() as { architectures: { value: string; models: string[] }[] };
const body = await response.json() as {
architectures: { value: string; models: string[] }[];
controlModels: string[];
structureControls: string[];
semanticSelection: boolean;
sam3Models: string[];
};
const anima = body.architectures.find((architecture) => architecture.value === "anima");
expect(response.status).toBe(200);
@@ -120,6 +178,10 @@ describe("Comfy adapter", () => {
expect(anima?.models).toContain("miaomiaoHarem_anima13.safetensors");
expect(anima?.models).not.toContain("z_image_bf16.safetensors");
expect(anima?.models).not.toContain("z_image_turbo_bf16.safetensors");
expect(body.structureControls).toEqual(["canny", "depth", "pose"]);
expect(body.controlModels).toHaveLength(3);
expect(body.semanticSelection).toBeTrue();
expect(body.sam3Models).toEqual(["sam3.1_multiplex_fp16.safetensors"]);
} finally {
globalThis.fetch = originalFetch;
}

View File

@@ -18,6 +18,9 @@ export type ComfyGenerateRequest = {
scheduler?: string;
width?: number;
height?: number;
batchSize?: number;
refinePass?: boolean;
refineStrength?: number;
outpaint?: {
left?: number;
top?: number;
@@ -35,17 +38,30 @@ export type ComfyGenerateRequest = {
maskedContent?: "neutral" | "original" | "originalColor" | "edges";
crop?: unknown;
placement?: unknown;
structureControl?: "none" | "canny" | "depth" | "pose";
controlStrength?: number;
controlModel?: string;
};
inputImage?: string;
maskImage?: string;
};
export type ComfyProgress = { progress: number; detail: string };
export type ComfySegmentRequest = { inputImage: string; x: number; y: number; model?: string };
type ComfyObjectInfo = {
CheckpointLoaderSimple?: { input?: { required?: { ckpt_name?: [string[]] } } };
KSampler?: { input?: { required?: { sampler_name?: [string[]]; scheduler?: [string[]] } } };
UNETLoader?: { input?: { required?: { unet_name?: [string[]] } } };
CLIPLoader?: { input?: { required?: { clip_name?: [string[]] } } };
VAELoader?: { input?: { required?: { vae_name?: [string[]] } } };
ControlNetLoader?: { input?: { required?: { control_net_name?: [string[]] } } };
Canny?: unknown;
"MiDaS-DepthMapPreprocessor"?: unknown;
OpenposePreprocessor?: unknown;
ControlNetApplyAdvanced?: unknown;
SAM3_Detect?: unknown;
MaskToImage?: unknown;
};
const comfyBaseUrl = process.env.COMFYUI_URL ?? "http://127.0.0.1:8188";
@@ -59,9 +75,7 @@ const defaultModels: Record<GenerateArchitecture, string> = {
};
export async function listGenerationOptions() {
const response = await fetch(`${comfyBaseUrl}/object_info`);
if (!response.ok) throw new Error(`ComfyUI option lookup failed: ${response.status}`);
const info = await response.json() as ComfyObjectInfo;
const info = await fetchObjectInfo();
const checkpointModels = info.CheckpointLoaderSimple?.input?.required?.ckpt_name?.[0] ?? [];
const diffusionModels = info.UNETLoader?.input?.required?.unet_name?.[0] ?? [];
const textEncoders = info.CLIPLoader?.input?.required?.clip_name?.[0] ?? [];
@@ -69,11 +83,20 @@ export async function listGenerationOptions() {
return {
models: checkpointModels,
inpaintModels: checkpointModels.filter((model) => /inpaint|fill/i.test(model)),
samplers: info.KSampler?.input?.required?.sampler_name?.[0] ?? [],
schedulers: info.KSampler?.input?.required?.scheduler?.[0] ?? [],
diffusionModels,
textEncoders,
vaes,
controlModels: info.ControlNetLoader?.input?.required?.control_net_name?.[0] ?? [],
structureControls: [
...(info.Canny && info.ControlNetApplyAdvanced && info.ControlNetLoader ? ["canny"] : []),
...(info["MiDaS-DepthMapPreprocessor"] && info.ControlNetApplyAdvanced && info.ControlNetLoader ? ["depth"] : []),
...(info.OpenposePreprocessor && info.ControlNetApplyAdvanced && info.ControlNetLoader ? ["pose"] : []),
],
semanticSelection: Boolean(info.SAM3_Detect && info.MaskToImage && checkpointModels.some((model) => /sam.?3/i.test(model))),
sam3Models: checkpointModels.filter((model) => /sam.?3/i.test(model)),
architectures: [
{
value: "sdxl",
@@ -107,21 +130,60 @@ export async function listGenerationOptions() {
};
}
export async function segment(request: ComfySegmentRequest, signal?: AbortSignal) {
if (!request.inputImage) throw new Error("Semantic selection requires an image");
if (!Number.isFinite(request.x) || !Number.isFinite(request.y)) throw new Error("Semantic selection requires a valid point");
const info = await fetchObjectInfo();
const models = info.CheckpointLoaderSimple?.input?.required?.ckpt_name?.[0] ?? [];
const model = request.model && request.model !== "auto" ? request.model : models.find((candidate) => /sam.?3/i.test(candidate));
if (!info.SAM3_Detect || !info.MaskToImage || !model || !models.includes(model)) throw new Error("SAM3 semantic selection is not installed in ComfyUI. Install a SAM3 model and enable the native SAM3 nodes.");
const uploaded = await uploadDataUrl(request.inputImage, `image-studio-segment-${crypto.randomUUID()}.png`, signal);
const prompt = buildSemanticSelectionWorkflow({ ...request, model, inputImage: uploaded });
const queued = await fetch(`${comfyBaseUrl}/prompt`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ client_id: crypto.randomUUID(), prompt }), signal });
if (!queued.ok) throw new Error(`ComfyUI semantic selection failed: ${queued.status} ${await queued.text()}`);
const queuedBody = await queued.json() as { prompt_id?: string; node_errors?: unknown };
const nodeError = nodeErrorsMessage(queuedBody.node_errors);
if (nodeError) throw new Error(`ComfyUI rejected semantic selection: ${nodeError}`);
if (!queuedBody.prompt_id) throw new Error("ComfyUI did not return a semantic selection prompt id");
const history = await waitForHistory(queuedBody.prompt_id, signal);
const historyError = historyErrorMessage(history);
if (historyError) throw new Error(`ComfyUI semantic selection failed: ${historyError}`);
const image = selectGeneratedOutputImage(history);
if (!image) throw new Error("ComfyUI did not return a semantic selection mask");
const response = await fetch(`${comfyBaseUrl}/view?${new URLSearchParams({ filename: image.filename, subfolder: image.subfolder ?? "", type: image.type ?? "output" })}`, { signal });
if (!response.ok) throw new Error(`ComfyUI mask fetch failed: ${response.status}`);
const bytes = Buffer.from(await response.arrayBuffer());
return { source: `data:image/png;base64,${bytes.toString("base64")}`, mimeType: "image/png" };
}
export function buildSemanticSelectionWorkflow(request: ComfySegmentRequest & { model: string }): Workflow {
return {
"1": { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: request.model } },
"2": { class_type: "LoadImage", inputs: { image: request.inputImage } },
"3": { class_type: "SAM3_Detect", inputs: { model: ["1", 0], image: ["2", 0], positive_coords: JSON.stringify([{ x: Math.round(request.x), y: Math.round(request.y) }]), threshold: 0.5, refine_iterations: 2, individual_masks: false } },
"4": { class_type: "MaskToImage", inputs: { mask: ["3", 0] } },
"8": { class_type: "SaveImage", inputs: { filename_prefix: "image-studio-segment", images: ["4", 0] } },
};
}
async function listCheckpointModels() {
return (await listGenerationOptions()).models;
}
export async function generate(request: ComfyGenerateRequest, signal?: AbortSignal) {
export async function generate(request: ComfyGenerateRequest, signal?: AbortSignal, onProgress?: (event: ComfyProgress) => void) {
if (!request.prompt?.trim()) throw new Error("Prompt is required");
onProgress?.({ progress: 0.03, detail: "Preparing workflow" });
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.model || request.model === "auto") request.model = await defaultModelForArchitecture(architecture);
if (!request.model || request.model === "auto") request.model = await defaultModelForArchitecture(architecture, request.mode);
const clientId = crypto.randomUUID();
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`, signal) : undefined;
onProgress?.({ progress: 0.16, detail: "Inputs uploaded" });
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 resolvedRequest = await resolveStructureControl({ ...request, architecture, inputImage: uploaded, maskImage: mask });
const prompt = buildComfyWorkflow(resolvedRequest);
const queued = await fetch(`${comfyBaseUrl}/prompt`, {
method: "POST",
@@ -134,23 +196,28 @@ export async function generate(request: ComfyGenerateRequest, signal?: AbortSign
const nodeError = nodeErrorsMessage(queuedBody.node_errors);
if (nodeError) throw new Error(`ComfyUI rejected the workflow: ${nodeError}`);
if (!queuedBody.prompt_id) throw new Error("ComfyUI did not return a prompt id");
onProgress?.({ progress: 0.24, detail: "Queued in ComfyUI" });
const prompt_id = queuedBody.prompt_id;
let history: unknown;
try {
history = await waitForHistory(prompt_id, signal);
history = await waitForHistory(prompt_id, signal, onProgress);
} catch (error) {
if (signal?.aborted) await cancelComfyPrompt(prompt_id);
throw error;
}
const historyError = historyErrorMessage(history);
if (historyError) throw new Error(`ComfyUI generation failed: ${historyError}`);
const image = selectGeneratedOutputImage(history);
if (!image) throw new Error("ComfyUI did not return an image");
const images = selectGeneratedOutputImages(history);
if (images.length === 0) throw new Error("ComfyUI did not return an image");
onProgress?.({ progress: 0.9, detail: "Downloading results" });
const results = await Promise.all(images.map(async (image, index) => {
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}`);
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", seed: Math.round((resolvedRequest.seed ?? 0) + index) };
}));
onProgress?.({ progress: 1, detail: "Results ready" });
return { results };
}
async function uploadDataUrl(dataUrl: string, filename: string, signal?: AbortSignal) {
@@ -167,7 +234,7 @@ async function uploadDataUrl(dataUrl: string, filename: string, signal?: AbortSi
return uploaded.name;
}
async function waitForHistory(promptId: string, signal?: AbortSignal) {
async function waitForHistory(promptId: string, signal?: AbortSignal, onProgress?: (event: ComfyProgress) => void) {
const startedAt = Date.now();
let attempts = 0;
while (Date.now() - startedAt < comfyHistoryTimeoutMs) {
@@ -177,6 +244,8 @@ async function waitForHistory(promptId: string, signal?: AbortSignal) {
}
const response = await fetch(`${comfyBaseUrl}/history/${promptId}`, { signal });
attempts += 1;
const elapsedRatio = Math.min(1, (Date.now() - startedAt) / comfyHistoryTimeoutMs);
onProgress?.({ progress: 0.25 + elapsedRatio * 0.6, detail: attempts <= 1 ? "Generating" : `Generating · check ${attempts}` });
if (response.ok) {
const history = await response.json() as Record<string, unknown>;
if (history[promptId]) return history[promptId];
@@ -209,14 +278,18 @@ function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
}
export function selectGeneratedOutputImage(history: unknown): { filename: string; subfolder?: string; type?: string } | undefined {
return selectGeneratedOutputImages(history)[0];
}
export function selectGeneratedOutputImages(history: unknown): Array<{ filename: string; subfolder?: string; type?: string }> {
const outputs = (history as { outputs?: Record<string, { images?: { filename: string; subfolder?: string; type?: string }[] }> }).outputs ?? {};
const saveImageOutput = outputs["8"]?.images?.find(isGeneratedImage);
if (saveImageOutput) return saveImageOutput;
const saveImageOutput = outputs["8"]?.images?.filter(isGeneratedImage) ?? [];
if (saveImageOutput.length > 0) return saveImageOutput;
const prefixedOutput = Object.values(outputs).flatMap((output) => output.images ?? []).find((image) => image.filename.startsWith("image-studio-") && image.type !== "input");
if (prefixedOutput) return prefixedOutput;
const prefixedOutput = Object.values(outputs).flatMap((output) => output.images ?? []).filter((image) => image.filename.startsWith("image-studio-") && image.type !== "input");
if (prefixedOutput.length > 0) return prefixedOutput;
return Object.values(outputs).flatMap((output) => output.images ?? []).find(isGeneratedImage);
return Object.values(outputs).flatMap((output) => output.images ?? []).filter(isGeneratedImage);
}
function isGeneratedImage(image: { filename: string; subfolder?: string; type?: string }) {
@@ -260,8 +333,8 @@ export function buildSdxlWorkflow(request: ComfyGenerateRequest): Workflow {
};
if (request.mode === "text-to-image" || !request.inputImage) {
workflow["5"] = { class_type: "EmptyLatentImage", inputs: { width, height, batch_size: 1 } };
return workflow;
workflow["5"] = { class_type: "EmptyLatentImage", inputs: { width, height, batch_size: resolveBatchSize(request) } };
return finalizeSdxlWorkflow(workflow, samplerInputs, request);
}
workflow["4"] = { class_type: "LoadImage", inputs: { image: request.inputImage } };
@@ -277,19 +350,96 @@ export function buildSdxlWorkflow(request: ComfyGenerateRequest): Workflow {
} else {
workflow["5"] = { class_type: "VAEEncodeForInpaint", inputs: { pixels: ["4", 0], vae: ["1", 2], mask: ["11", 0], grow_mask_by: resolveGrowMaskBy(request) } };
}
return workflow;
return finalizeSdxlWorkflow(workflow, samplerInputs, request);
}
if (request.mode === "outpaint") {
workflow["10"] = { class_type: "ImagePadForOutpaint", inputs: { image: ["4", 0], left: Math.round(request.outpaint?.left ?? 0), top: Math.round(request.outpaint?.top ?? 0), right: Math.round(request.outpaint?.right ?? 0), bottom: Math.round(request.outpaint?.bottom ?? 0), feathering: Math.round(request.outpaint?.feathering ?? 0) } };
workflow["5"] = { class_type: "VAEEncodeForInpaint", inputs: { pixels: ["10", 0], vae: ["1", 2], mask: ["10", 1], grow_mask_by: resolveGrowMaskBy(request) } };
return workflow;
return finalizeSdxlWorkflow(workflow, samplerInputs, request);
}
workflow["5"] = { class_type: "VAEEncode", inputs: { pixels: ["4", 0], vae: ["1", 2] } };
return finalizeSdxlWorkflow(workflow, samplerInputs, request);
}
function finalizeSdxlWorkflow(workflow: Workflow, samplerInputs: Record<string, unknown>, request: ComfyGenerateRequest): Workflow {
const batchSize = resolveBatchSize(request);
if (batchSize > 1 && workflow["5"]?.class_type !== "EmptyLatentImage") {
const latent = samplerInputs.latent_image;
workflow["19"] = { class_type: "RepeatLatentBatch", inputs: { samples: latent, amount: batchSize } };
samplerInputs.latent_image = ["19", 0];
}
const control = request.inpaint?.structureControl ?? "none";
if (request.mode !== "inpaint" || control === "none" || !request.inpaint?.controlModel || request.inpaint.controlModel === "auto") return addRefinementPass(workflow, samplerInputs, request);
workflow["20"] = { class_type: "ControlNetLoader", inputs: { control_net_name: request.inpaint.controlModel } };
if (control === "canny") {
workflow["21"] = { class_type: "Canny", inputs: { image: ["4", 0], low_threshold: 100, high_threshold: 200 } };
} else if (control === "depth") {
workflow["21"] = { class_type: "MiDaS-DepthMapPreprocessor", inputs: { image: ["4", 0], a: 6.283, bg_threshold: 0.1, resolution: Math.max(request.width ?? 512, request.height ?? 512) } };
} else {
workflow["21"] = { class_type: "OpenposePreprocessor", inputs: { image: ["4", 0], detect_hand: "enable", detect_body: "enable", detect_face: "enable", scale_stick_for_xinsr_cn: "enable", resolution: Math.max(request.width ?? 512, request.height ?? 512) } };
}
workflow["22"] = {
class_type: "ControlNetApplyAdvanced",
inputs: {
positive: ["2", 0],
negative: ["3", 0],
control_net: ["20", 0],
image: ["21", 0],
strength: Math.max(0, Math.min(1, request.inpaint.controlStrength ?? 0.55)),
start_percent: 0,
end_percent: 0.85,
vae: ["1", 2],
},
};
samplerInputs.positive = ["22", 0];
samplerInputs.negative = ["22", 1];
return addRefinementPass(workflow, samplerInputs, request);
}
function addRefinementPass(workflow: Workflow, samplerInputs: Record<string, unknown>, request: ComfyGenerateRequest): Workflow {
if (!request.refinePass) return workflow;
workflow["30"] = {
class_type: "KSampler",
inputs: {
...samplerInputs,
seed: Math.round((request.seed ?? 0) + 1),
steps: Math.max(6, Math.round((request.steps ?? 30) / 3)),
denoise: Math.max(0, Math.min(1, (request.refineStrength ?? 20) / 100)),
latent_image: ["6", 0],
},
};
if (workflow["7"]) workflow["7"].inputs.samples = ["30", 0];
return workflow;
}
function resolveBatchSize(request: ComfyGenerateRequest) {
return Math.round(Math.max(1, Math.min(8, request.batchSize ?? 1)));
}
async function fetchObjectInfo(): Promise<ComfyObjectInfo> {
const response = await fetch(`${comfyBaseUrl}/object_info`);
if (!response.ok) throw new Error(`ComfyUI option lookup failed: ${response.status}`);
return response.json() as Promise<ComfyObjectInfo>;
}
async function resolveStructureControl(request: ComfyGenerateRequest): Promise<ComfyGenerateRequest> {
const control = request.inpaint?.structureControl ?? "none";
if (request.mode !== "inpaint" || control === "none") return request;
const info = await fetchObjectInfo();
const requiredPreprocessor = control === "canny" ? info.Canny : control === "depth" ? info["MiDaS-DepthMapPreprocessor"] : info.OpenposePreprocessor;
if (!requiredPreprocessor || !info.ControlNetLoader || !info.ControlNetApplyAdvanced) {
throw new Error(`${control === "canny" ? "Canny" : control === "depth" ? "Depth" : "Pose"} structural control is not installed in ComfyUI.`);
}
const models = info.ControlNetLoader.input?.required?.control_net_name?.[0] ?? [];
const requested = request.inpaint?.controlModel;
const model = requested && requested !== "auto" ? requested : models.find((candidate) => candidate.toLowerCase().includes(control));
if (!model || !models.includes(model)) throw new Error(`Install or select a ${control} ControlNet model before using structural control.`);
return { ...request, inpaint: { ...request.inpaint, controlModel: model } };
}
export function buildZImageWorkflow(request: ComfyGenerateRequest): Workflow {
return buildSeparatedTextToImageWorkflow(request, {
architecture: "z-image",
@@ -408,9 +558,10 @@ function usesOriginalLatentContent(request: ComfyGenerateRequest): boolean {
return request.inpaint?.maskedContent === "original" || request.inpaint?.maskedContent === "originalColor" || request.inpaint?.maskedContent === "edges";
}
async function defaultModelForArchitecture(architecture: GenerateArchitecture) {
async function defaultModelForArchitecture(architecture: GenerateArchitecture, mode: GenerateMode) {
if (architecture !== "sdxl") return defaultModels[architecture];
const models = await listCheckpointModels();
if (mode === "inpaint") return models.find((model) => /inpaint|fill/i.test(model)) ?? models[0] ?? defaultModels.sdxl;
return models[0] ?? defaultModels.sdxl;
}

View File

@@ -9,7 +9,6 @@ import { CommandPalette } from "./CommandPalette";
import { GenerateSheet } from "./GenerateSheet";
import { GenerationJobStatus } from "./GenerationJobStatus";
import { LayersSheet } from "./LayersSheet";
import { ShortcutsDisplay } from "./ShortcutsDisplay";
import { ToolOverlay } from "./ToolOverlay";
import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets";
import type { AppState } from "@editor/state";
@@ -142,7 +141,7 @@ export function App({ app }: AppProps) {
const brushHint = brushUnavailableHint(document, { selection, tools, maskEdit });
return (
<main className="relative h-full overflow-hidden bg-[radial-gradient(circle_at_20%_18%,rgba(148,163,184,0.18),transparent_34%),radial-gradient(circle_at_82%_22%,rgba(71,85,105,0.22),transparent_36%),radial-gradient(circle_at_48%_88%,rgba(30,41,59,0.28),transparent_40%),linear-gradient(135deg,#020617_0%,#0f172a_46%,#111827_100%)] text-foreground">
<main className="relative h-full overflow-hidden bg-[radial-gradient(circle_at_50%_35%,rgba(51,65,85,0.28),transparent_48%),linear-gradient(145deg,#080b10_0%,#10151d_52%,#0a0e14_100%)] text-foreground">
{imageImport.input}
{project.input}
<CommandPalette
@@ -161,32 +160,32 @@ export function App({ app }: AppProps) {
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-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>
<header className="app-surface pointer-events-none absolute inset-x-3 top-3 z-10 flex h-12 items-center justify-between gap-4 rounded-xl px-2.5 text-white">
<div className="min-w-0 px-1.5">
<p className="text-[0.58rem] font-semibold uppercase tracking-[0.18em] text-white/35">Image Studio</p>
<h1 className="truncate text-xs font-medium text-white/85" title={document.name}>{document.name}</h1>
</div>
<div className="pointer-events-auto flex items-center gap-2">
<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 />
</button>
<button type="button" className={topBarButtonClass()} onClick={imageImport.openFilePicker} aria-label="Open image" title="Open image (Cmd/Ctrl+O)">
<FolderOpen size={24} />
<FolderOpen size={18} />
</button>
<button type="button" className={topBarButtonClass()} onClick={project.openFilePicker} aria-label="Open project" title="Open project (Cmd/Ctrl+Shift+O)">
<FolderSimple size={24} />
<FolderSimple size={18} />
</button>
<button type="button" className={topBarButtonClass()} onClick={project.save} aria-label="Save project" title="Save project (Cmd/Ctrl+S)">
<FloppyDisk size={24} />
<FloppyDisk size={18} />
</button>
<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={18} weight={generateOpen ? "fill" : "regular"} />
</button>
<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={18} />
</button>
<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={18} weight={layersOpen ? "fill" : "regular"} />
</button>
</div>
</header>
@@ -195,12 +194,16 @@ export function App({ app }: AppProps) {
activeTool={tools.activeTool}
interactionMode={tools.interactionMode}
panel={workspace.panel}
inpaintMaskEditing={maskEdit?.kind === "inpaintRegion"}
dispatch={app.store.dispatch}
/>
</div>
<GenerateSheet
settings={tools.generate}
document={document}
selection={selection}
resources={generation.resources}
generation={generation}
open={generateOpen}
dispatch={app.store.dispatch}
/>
@@ -212,21 +215,23 @@ export function App({ app }: AppProps) {
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-3 z-10 flex justify-center">
<BottomControlsIsland
document={document}
selection={selection}
viewport={viewport}
visible={generateOpen || chromaKeyOpen || tools.activeTool === "brush" || tools.activeTool === "eraser" || tools.activeTool === "magicWand" || Boolean(transformBounds) || viewportActivityIsland.visible}
visible={generateOpen || chromaKeyOpen || tools.activeTool === "brush" || tools.activeTool === "eraser" || tools.activeTool === "feather" || tools.activeTool === "magicWand" || tools.activeTool === "semanticSelect" || tools.activeTool === "maskLasso" || tools.activeTool === "maskRectangle" || Boolean(transformBounds) || viewportActivityIsland.visible}
action={viewportActivityIsland.action}
activeTool={tools.activeTool}
operation={generateOpen ? "generate" : chromaKeyOpen ? "chromaKey" : undefined}
brushSettings={tools.brush}
featherSettings={tools.feather}
generateSettings={tools.generate}
generation={generation}
chromaKeySettings={tools.chromaKey}
magicWandSettings={tools.magicWand}
editingMask={Boolean(maskEdit)}
maskKind={maskEdit?.kind}
maskViewMode={maskEdit?.viewMode ?? "composite"}
brushHint={brushHint}
transformBounds={viewportActivityIsland.visible ? undefined : transformBounds}
@@ -236,17 +241,14 @@ export function App({ app }: AppProps) {
documentActions={app.actions.document}
/>
</div>
<div className="absolute bottom-4 left-4 z-10">
<ShortcutsDisplay />
</div>
<CanvasViewport store={app.store} />
</main>
);
}
function topBarButtonClass(active = false) {
const base = "inline-flex size-12 items-center justify-center rounded-full text-sm font-medium transition disabled:pointer-events-none disabled:opacity-35 focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30";
return active ? `${base} bg-white text-black hover:bg-white hover:text-black` : `${base} text-white/75 hover:bg-white/10 hover:text-white`;
const base = "inline-flex size-8 items-center justify-center rounded-lg text-sm font-medium transition disabled:pointer-events-none disabled:opacity-35 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/50";
return active ? `${base} bg-sky-300 text-slate-950` : `${base} text-white/60 hover:bg-white/[0.07] hover:text-white`;
}
type AppShellState = {

View File

@@ -2,8 +2,9 @@ import { useMemo } from "react";
import type { AppStore } from "@editor/store";
import type { ImageDocument } from "@core/document";
import type { GenerationState, MaskViewMode, SelectionState, ViewportState } from "@editor/state";
import type { BrushSettings, ChromaKeySettings, GenerateSettings, MagicWandSettings, OperationId, ToolId } from "@editor/tools";
import type { BrushSettings, ChromaKeySettings, FeatherSettings, GenerateSettings, MagicWandSettings, OperationId, ToolId } from "@editor/tools";
import { BrushControls } from "./bottom-controls/BrushControls";
import { FeatherControls } from "./bottom-controls/FeatherControls";
import { ChromaKeyControls } from "./bottom-controls/ChromaKeyControls";
import { MagicWandControls } from "./bottom-controls/MagicWandControls";
import { GenerateActionControls } from "./bottom-controls/GenerateActionControls";
@@ -26,11 +27,13 @@ export type BottomControlsIslandProps = {
activeTool: ToolId;
operation?: OperationId;
brushSettings: BrushSettings;
featherSettings: FeatherSettings;
generateSettings: GenerateSettings;
generation: GenerationState;
chromaKeySettings: ChromaKeySettings;
magicWandSettings: MagicWandSettings;
editingMask?: boolean;
maskKind?: "layerMask" | "inpaintRegion";
maskViewMode?: MaskViewMode;
transformBounds?: Rect;
transformTarget?: TransformTarget;
@@ -40,7 +43,7 @@ export type BottomControlsIslandProps = {
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, documentActions }: BottomControlsIslandProps) {
export function BottomControlsIsland({ document, selection, viewport, visible, action, activeTool, operation, brushSettings, featherSettings, generateSettings, generation, chromaKeySettings, magicWandSettings, editingMask = false, maskKind, maskViewMode = "composite", transformBounds, transformTarget, brushHint, dispatch, generationWorkflow, documentActions }: BottomControlsIslandProps) {
const documentIndex = useMemo(() => createDocumentReadIndex(document), [document]);
const selectedLayerInfo = selection.layerIds.length === 1 && selection.layerIds[0] ? documentIndex.layerInfoById.get(selection.layerIds[0]) : undefined;
const zoomPercent = Math.round(viewport.zoom * 100);
@@ -50,20 +53,26 @@ export function BottomControlsIsland({ document, selection, viewport, visible, a
return (
<div
aria-hidden={!visible}
className={`flex min-h-20 min-w-96 rounded-[2rem] px-4 py-2 items-center justify-center gap-4 text-sm text-white backdrop-blur transition-all duration-200 ${
className={`app-surface subtle-scrollbar flex min-h-12 w-fit max-w-[calc(100vw-1.5rem)] min-w-0 flex-nowrap items-center justify-start gap-2 overflow-x-auto overflow-y-hidden rounded-xl px-2 py-1.5 text-xs text-white transition-all duration-200 ${
visible ? "pointer-events-auto translate-y-0 opacity-100" : "pointer-events-none translate-y-3 opacity-0"
}`}
>
{operation === "generate" ? (
<GenerateActionControls settings={generateSettings} generation={generation} dispatch={dispatch} workflow={generationWorkflow} generate={documentActions.generate} />
) : (activeTool === "brush" || activeTool === "eraser") && brushHint ? (
<BrushHint tool={activeTool} hint={brushHint} />
) : activeTool === "brush" || activeTool === "eraser" ? (
<BrushControls tool={activeTool} settings={brushSettings} editingMask={editingMask} maskViewMode={maskViewMode} dispatch={dispatch} />
) : operation === "chromaKey" ? (
<ChromaKeyControls document={document} selection={selection} settings={chromaKeySettings} dispatch={dispatch} />
) : (activeTool === "brush" || activeTool === "eraser" || activeTool === "feather") && brushHint ? (
<BrushHint tool={activeTool} hint={brushHint} />
) : activeTool === "brush" || activeTool === "eraser" ? (
<BrushControls tool={activeTool} settings={brushSettings} editingMask={editingMask} maskKind={maskKind} maskViewMode={maskViewMode} dispatch={dispatch} />
) : activeTool === "feather" ? (
<FeatherControls settings={featherSettings} editingMask={editingMask} dispatch={dispatch} />
) : activeTool === "magicWand" ? (
<MagicWandControls settings={magicWandSettings} dispatch={dispatch} />
) : activeTool === "semanticSelect" ? (
<div className="flex items-center gap-3 px-4 text-sm text-white/75"><strong className="text-white">AI object select</strong><span>Click an object · Shift-click adds · Option-click protects</span></div>
) : activeTool === "maskLasso" || activeTool === "maskRectangle" ? (
<div className="flex items-center gap-3 px-4 text-sm text-white/75"><strong className="text-white">AI region {activeTool === "maskRectangle" ? "rectangle" : "lasso"}</strong><span>Drag to replace · Shift-drag adds · Option-drag protects</span></div>
) : transformBounds && transformTarget ? (
<TransformControls bounds={transformBounds} target={transformTarget} documentIndex={documentIndex} layerInfo={selectedLayerInfo} dispatch={dispatch} />
) : action === "pan" ? (
@@ -75,10 +84,10 @@ export function BottomControlsIsland({ document, selection, viewport, visible, a
);
}
function BrushHint({ tool, hint }: { tool: "brush" | "eraser"; hint: string }) {
function BrushHint({ tool, hint }: { tool: "brush" | "eraser" | "feather"; hint: string }) {
return (
<div className="flex items-center gap-3 px-4 text-center">
<span className="rounded-full bg-white/10 px-3 py-1 text-xs font-medium uppercase tracking-wide text-white/55">{tool}</span>
<span className="rounded bg-white/[0.06] px-2 py-0.5 text-[0.65rem] font-semibold uppercase tracking-[0.12em] text-white/45">{tool}</span>
<span className="max-w-[34rem] text-sm font-medium text-white/80">{hint}</span>
</div>
);

View File

@@ -1,6 +1,6 @@
import { useMemo, useRef } from "react";
import type { ImageDocument } from "@core/document";
import type { AppState, MaskEditState } from "@editor/state";
import { isOperationWorkspacePanel, type AppState, type MaskEditState, type WorkspacePanel } from "@editor/state";
import type { AppStore } from "@editor/store";
import type { InteractionMode } from "@editor/tools";
import type { GlobalKeybindConsumer, GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index";
@@ -40,7 +40,13 @@ export function CanvasViewport({
useCanvasInput(canvasRef, store, inputOptions);
const brushHint = brushUnavailableHint(cursorState.document, cursorState.editor);
const hasBrushPreview = Boolean(cursorState.hasBrushPreview && !brushHint && canPreviewBrush(cursorState.document, cursorState.editor));
const cursorClass = canvasCursorClass(cursorState.editor.tools.interactionMode, cursorState.isPanning, hasBrushPreview, !brushHint);
const cursorClass = canvasCursorClass(
cursorState.editor.tools.interactionMode,
cursorState.isPanning,
hasBrushPreview,
!brushHint,
isOperationWorkspacePanel(cursorState.panel),
);
return (
<canvas
@@ -57,6 +63,7 @@ type CanvasCursorState = {
editor: BrushTargetEditorState;
hasBrushPreview: boolean;
isPanning: boolean;
panel: WorkspacePanel;
};
function selectCanvasCursorState(state: AppState): CanvasCursorState {
@@ -72,6 +79,7 @@ function selectCanvasCursorState(state: AppState): CanvasCursorState {
},
hasBrushPreview: Boolean(state.editor.brushPreview),
isPanning: state.editor.pointerSession?.type === "pan",
panel: state.editor.workspace.panel,
};
}
@@ -80,6 +88,7 @@ function canvasCursorStatesEqual(a: CanvasCursorState, b: CanvasCursorState): bo
a.document === b.document &&
a.hasBrushPreview === b.hasBrushPreview &&
a.isPanning === b.isPanning &&
a.panel === b.panel &&
a.editor.selection === b.editor.selection &&
a.editor.tools.activeTool === b.editor.tools.activeTool &&
interactionModesEqual(a.editor.tools.interactionMode, b.editor.tools.interactionMode) &&
@@ -96,5 +105,5 @@ function interactionModesEqual(a: InteractionMode, b: InteractionMode): boolean
function maskEditStatesEqual(a: MaskEditState | undefined, b: MaskEditState | undefined): boolean {
if (a === b) return true;
if (!a || !b) return false;
return a.targetLayerId === b.targetLayerId && a.maskLayerId === b.maskLayerId && a.viewMode === b.viewMode;
return a.kind === b.kind && a.targetLayerId === b.targetLayerId && a.maskAssetId === b.maskAssetId && a.maskLayerId === b.maskLayerId && a.inpaintRegionId === b.inpaintRegionId && a.viewMode === b.viewMode;
}

View File

@@ -151,7 +151,7 @@ export function CommandPalette({
return (
<div
className="pointer-events-auto absolute inset-0 z-50 flex items-start justify-center bg-black/20 px-3 pt-[10vh] backdrop-blur-sm"
className="pointer-events-auto absolute inset-0 z-50 flex items-start justify-center bg-black/35 px-3 pt-[12vh] backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-label="Command palette"
@@ -159,9 +159,9 @@ export function CommandPalette({
if (event.target === event.currentTarget) close();
}}
>
<div className="w-full max-w-2xl overflow-hidden rounded-2xl bg-slate-950/[0.92] text-white shadow-2xl ring-1 ring-white/[0.12]">
<div className="flex h-16 items-center gap-3 border-b border-white/10 px-4">
<MagnifyingGlass size={22} className="shrink-0 text-white/45" />
<div className="app-surface w-full max-w-xl overflow-hidden rounded-xl text-white shadow-2xl">
<div className="flex h-12 items-center gap-2.5 border-b border-white/[0.07] px-3">
<MagnifyingGlass size={18} className="shrink-0 text-white/35" />
<input
ref={inputRef}
value={state.query}
@@ -169,9 +169,9 @@ export function CommandPalette({
onKeyDown={handleInputKeyDown}
placeholder="Search commands"
aria-label="Search commands"
className="h-full min-w-0 flex-1 bg-transparent text-base text-white outline-none placeholder:text-white/30"
className="h-full min-w-0 flex-1 bg-transparent text-sm text-white outline-none placeholder:text-white/25"
/>
<span className="hidden items-center gap-1 rounded-full bg-white/[0.07] px-2 py-1 text-xs font-semibold text-white/45 sm:inline-flex">
<span className="hidden items-center gap-1 rounded bg-white/[0.05] px-1.5 py-0.5 text-[0.65rem] font-semibold text-white/40 sm:inline-flex">
<Command size={14} weight="bold" /> K
</span>
</div>
@@ -219,15 +219,15 @@ function filterItems(items: PaletteItem[], query: string) {
}
function paletteItemClass(active: boolean, disabled: boolean) {
const base = "flex min-h-14 w-full items-center gap-3 rounded-xl px-3 py-2 text-left transition focus:outline-none";
const base = "flex min-h-11 w-full items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-left transition focus:outline-none";
if (disabled) return `${base} cursor-not-allowed text-white/30 opacity-45`;
return active ? `${base} bg-white text-black` : `${base} text-white/75 hover:bg-white/[0.08] hover:text-white`;
}
function paletteIconClass(active: boolean) {
return active
? "grid size-10 shrink-0 place-items-center rounded-xl bg-black/10 text-black/60"
: "grid size-10 shrink-0 place-items-center rounded-xl bg-white/[0.06] text-white/60";
? "grid size-8 shrink-0 place-items-center rounded-md bg-black/10 text-black/60"
: "grid size-8 shrink-0 place-items-center rounded-md bg-white/[0.05] text-white/55";
}
function paletteSubtitleClass(active: boolean) {
@@ -236,6 +236,6 @@ function paletteSubtitleClass(active: boolean) {
function paletteSectionClass(active: boolean) {
return active
? "shrink-0 rounded-full bg-black/10 px-2.5 py-1 text-[0.65rem] font-semibold uppercase tracking-[0.14em] text-black/45"
: "shrink-0 rounded-full bg-white/[0.06] px-2.5 py-1 text-[0.65rem] font-semibold uppercase tracking-[0.14em] text-white/35";
? "shrink-0 rounded bg-black/10 px-1.5 py-0.5 text-[0.6rem] font-semibold uppercase tracking-[0.12em] text-black/45"
: "shrink-0 rounded bg-white/[0.05] px-1.5 py-0.5 text-[0.6rem] font-semibold uppercase tracking-[0.12em] text-white/30";
}

View File

@@ -1,28 +1,35 @@
import type { GenerateSettings } from "@editor/tools";
import type { AppStore } from "@editor/store";
import type { GenerationResourcesState } from "@editor/state";
import type { GenerationResourcesState, GenerationState } from "@editor/state";
import { GenerateControls } from "./bottom-controls/GenerateControls";
import type { ImageDocument } from "@core/document";
import type { SelectionState } from "@editor/state";
import { CandidateReviewPanel } from "./inpaint/CandidateReviewPanel";
export type GenerateSheetProps = {
settings: GenerateSettings;
document: ImageDocument;
selection: SelectionState;
resources: GenerationResourcesState;
generation: GenerationState;
open: boolean;
dispatch: AppStore["dispatch"];
};
export function GenerateSheet({ settings, resources, open, dispatch }: GenerateSheetProps) {
export function GenerateSheet({ settings, document, selection, resources, generation, open, dispatch }: GenerateSheetProps) {
return (
<aside
id="generate-sheet"
aria-hidden={!open}
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={`app-surface pointer-events-auto absolute bottom-3 right-3 top-[4.5rem] z-20 flex w-[22rem] max-w-[calc(100vw-1.5rem)] flex-col overflow-hidden rounded-xl px-3 text-sm text-white transition-all duration-200 ${
open ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-8 opacity-0"
}`}
>
{open ? (
<div className="subtle-scrollbar min-h-0 flex-1 overflow-auto py-4">
<GenerateControls settings={settings} resources={resources} dispatch={dispatch} />
<CandidateReviewPanel generation={generation} dispatch={dispatch} />
<GenerateControls settings={settings} document={document} selection={selection} resources={resources} dispatch={dispatch} />
</div>
) : null}
</aside>

View File

@@ -17,10 +17,10 @@ export function GenerationJobStatus({ generation, compact = false }: { generatio
if (!job) return null;
const elapsed = Math.max(0, Math.floor(((job.finishedAt ?? Date.now()) - job.startedAt) / 1000));
const label = job.status === "running" ? `${job.label} ${formatElapsed(elapsed)}` : job.status === "failed" ? job.error ?? `${job.label} failed` : job.status === "cancelled" ? `${job.label} cancelled` : `${job.label} complete`;
const label = job.status === "running" ? `${job.detail ?? job.label}${job.progress !== undefined ? ` · ${Math.round(job.progress * 100)}%` : ""} ${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" : 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-48" : "max-w-72"} truncate rounded-md px-2 py-1 text-[0.68rem] font-medium ${tone}`} title={label} aria-live="polite">{label}</span>;
}
function formatElapsed(seconds: number) {

View File

@@ -17,6 +17,9 @@ import { addAdjustmentLayer, addArtboard, addEmptyLayer, addGroupLayer, addLayer
import { MaskOperationButtons, MaskStatus } from "./layers/MaskControls";
import { LayerThumbnail } from "./layers/LayerThumbnail";
import { createLayerThumbnailIndex, type LayerThumbnailModel } from "./layers/thumbnailModel";
import { BottomControlColorPicker } from "./bottom-controls/ColorPicker";
import { BottomControlSlider } from "./bottom-controls/Slider";
import { BottomControlSelectMenu, type BottomControlSelectOption } from "./bottom-controls/SelectMenu";
export type LayersSheetProps = {
document: ImageDocument;
@@ -36,7 +39,7 @@ export function LayersSheet({ document, selection, maskEdit, open, dispatch, doc
id="layers-sheet"
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={`app-surface pointer-events-auto absolute bottom-3 right-3 top-[4.5rem] z-20 flex w-[22rem] flex-col overflow-hidden rounded-xl px-3 text-xs text-white transition-all duration-200 ${
open ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-8 opacity-0"
}`}
>
@@ -81,14 +84,14 @@ function LayersSheetBody({
return (
<>
<header className="flex h-20 items-center">
<header className="flex h-14 items-center border-b border-white/[0.06]">
<div className="flex w-full items-center gap-2">
<button type="button" className={labeledToolbarButtonClass()} aria-label="Add artboard" title="Add artboard" onClick={() => addArtboard(document, dispatch)}>
<span className="grid w-12 flex-none place-items-center"><Plus size={24} /></span>
<span className="grid w-8 flex-none place-items-center"><Plus size={17} /></span>
<span className="min-w-0 flex-1 text-center">Artboard</span>
</button>
<button type="button" className={labeledToolbarButtonClass()} aria-label="Add layer" title="Add layer" disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addEmptyLayer(document, selectedArtboardId, selectedLayer, dispatch)}>
<span className="grid w-12 flex-none place-items-center"><Plus size={24} /></span>
<span className="grid w-8 flex-none place-items-center"><Plus size={17} /></span>
<span className="min-w-0 flex-1 text-center">Layer</span>
</button>
<button type="button" className={toolbarButtonClass()} aria-label="Add group" title="Add group" disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addGroupLayer(selectedArtboardId, dispatch)}>
@@ -100,7 +103,7 @@ function LayersSheetBody({
<button type="button" className={toolbarButtonClass()} aria-label="Add text" title="Add text layer" disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addTextLayer(document, selectedArtboardId, selectedLayer, dispatch)}><TextT size={24} /></button>
</div>
</header>
<div className="mb-4 grid grid-cols-5 gap-2">
<div className="my-2 grid grid-cols-5 gap-1 border-b border-white/[0.06] pb-2">
<button type="button" className={toolbarButtonClass()} aria-label="Group selected" title="Group selected" disabled={!canGroup} onClick={() => selection.artboardId && groupLayers(selection.artboardId, selection.layerIds, dispatch)}>
<Stack size={24} />
</button>
@@ -124,9 +127,9 @@ function LayersSheetBody({
const displayLayerCount = documentIndex.displayLayerCountByArtboardId.get(artboard.id) ?? 0;
return (
<section key={artboard.id} className="mb-5 last:mb-0">
<section key={artboard.id} className="mb-3 last:mb-0">
<div
className={`flex h-12 w-full items-center gap-3 rounded-full px-4 text-left transition ${selection.artboardId === artboard.id && selection.layerIds.length === 0 ? "bg-white text-black" : "text-white/70 hover:bg-white/[0.06] hover:text-white"}`}
className={`flex h-9 w-full items-center gap-2 rounded-lg px-2 text-left transition ${selection.artboardId === artboard.id && selection.layerIds.length === 0 ? "bg-sky-300 text-slate-950" : "text-white/70 hover:bg-white/[0.06] hover:text-white"}`}
onDragOver={(event) => {
if (draggedLayerId.current) event.preventDefault();
}}
@@ -174,11 +177,11 @@ function LayersSheetBody({
>
<DownloadSimple size={24} weight="regular" />
</button>
<span className={selection.artboardId === artboard.id && selection.layerIds.length === 0 ? "min-w-8 rounded-full bg-black/10 px-2 py-1 text-center text-xs text-black/45" : "min-w-8 rounded-full bg-white/10 px-2 py-1 text-center text-xs text-white/45"}>{displayLayerCount}</span>
<span className={selection.artboardId === artboard.id && selection.layerIds.length === 0 ? "min-w-6 rounded bg-black/10 px-1.5 py-0.5 text-center text-[0.65rem] text-black/45" : "min-w-6 rounded bg-white/[0.06] px-1.5 py-0.5 text-center text-[0.65rem] text-white/40"}>{displayLayerCount}</span>
</div>
<div className="mt-2 space-y-2 pl-5">
<div className="mt-1 space-y-1 pl-3">
{displayLayerCount === 0 ? (
<div className="rounded-[1.5rem] border border-dashed border-white/10 px-4 py-5 text-center text-white/35">No layers yet</div>
<div className="rounded-lg border border-dashed border-white/10 px-3 py-3 text-center text-white/30">No layers yet</div>
) : (
artboard.layers.map((layer) => (
<LayerRow
@@ -211,24 +214,49 @@ function LayersSheetBody({
function TextInspector({ layer, dispatch }: { layer: TextLayer; dispatch: AppStore["dispatch"] }) {
const [content, setContent] = useState(layer.content);
const [style, setStyle] = useState<TextStyle>({ ...layer.style });
useEffect(() => { setContent(layer.content); setStyle({ ...layer.style }); }, [layer.id, layer.content, layer.style]);
const commit = () => dispatch(commandIds.documentSetTextLayer, { layerId: layer.id, content, style });
return <section aria-label="Text settings" className="mb-3 space-y-2 rounded-[1.5rem] bg-white/[0.05] p-3">
<textarea aria-label="Text content" className="w-full resize-y rounded-xl bg-black/25 p-2 text-sm outline-none ring-1 ring-white/10" rows={2} value={content} disabled={layer.locked} onChange={(event) => setContent(event.target.value)} onBlur={commit} onKeyDown={(event) => event.stopPropagation()} />
const [fontSizeDraft, setFontSizeDraft] = useState(String(layer.style.fontSize));
const [lineHeightDraft, setLineHeightDraft] = useState(String(layer.style.lineHeight));
useEffect(() => { setContent(layer.content); setStyle({ ...layer.style }); setFontSizeDraft(String(layer.style.fontSize)); setLineHeightDraft(String(layer.style.lineHeight)); }, [layer.id, layer.content, layer.style]);
const updateText = (nextContent: string) => {
setContent(nextContent);
dispatch(commandIds.documentSetTextLayer, { layerId: layer.id, content: nextContent, style });
};
const updateStyle = (nextStyle: TextStyle) => {
setStyle(nextStyle);
dispatch(commandIds.documentSetTextLayer, { layerId: layer.id, content, style: nextStyle });
};
const commitNumber = (field: "fontSize" | "lineHeight", draft: string, reset: (value: string) => void) => {
const next = Number(draft.trim());
if (draft.trim() === "" || !Number.isFinite(next)) { reset(String(layer.style[field])); return; }
const nextStyle = { ...style, [field]: next };
setStyle(nextStyle);
dispatch(commandIds.documentSetTextLayer, { layerId: layer.id, content, style: nextStyle });
};
return <section aria-label="Text settings" className="mb-2 space-y-2 border-b border-white/[0.07] px-0.5 pb-3">
<textarea aria-label="Text content" className="w-full resize-y rounded-md bg-white/[0.035] p-2 text-xs outline-none ring-1 ring-white/[0.07] focus:ring-sky-300/50" rows={2} value={content} disabled={layer.locked} onChange={(event) => updateText(event.target.value)} onKeyDown={(event) => event.stopPropagation()} />
<div className="grid grid-cols-3 gap-2">
<select aria-label="Font family" className={textFieldClass()} value={style.fontFamily} disabled={layer.locked} onChange={(event) => setStyle({ ...style, fontFamily: event.target.value as TextStyle["fontFamily"] })} onBlur={commit}>{builtInTextFonts.map((font) => <option key={font}>{font}</option>)}</select>
<input aria-label="Font size" className={textFieldClass()} type="number" min="1" max="1000" value={style.fontSize} disabled={layer.locked} onChange={(event) => setStyle({ ...style, fontSize: Number(event.target.value) })} onBlur={commit} />
<input aria-label="Text color" className={textFieldClass()} type="color" value={style.color} disabled={layer.locked} onChange={(event) => setStyle({ ...style, color: event.target.value })} onBlur={commit} />
<select aria-label="Font weight" className={textFieldClass()} value={style.fontWeight} disabled={layer.locked} onChange={(event) => setStyle({ ...style, fontWeight: Number(event.target.value) as 400 | 700 })} onBlur={commit}><option value="400">Regular</option><option value="700">Bold</option></select>
<select aria-label="Font style" className={textFieldClass()} value={style.fontStyle} disabled={layer.locked} onChange={(event) => setStyle({ ...style, fontStyle: event.target.value as TextStyle["fontStyle"] })} onBlur={commit}><option value="normal">Normal</option><option value="italic">Italic</option></select>
<select aria-label="Text alignment" className={textFieldClass()} value={style.alignment} disabled={layer.locked} onChange={(event) => setStyle({ ...style, alignment: event.target.value as TextStyle["alignment"] })} onBlur={commit}><option value="left">Left</option><option value="center">Center</option><option value="right">Right</option></select>
<input aria-label="Line height" className={textFieldClass()} type="number" min="0.5" max="5" step="0.1" value={style.lineHeight} disabled={layer.locked} onChange={(event) => setStyle({ ...style, lineHeight: Number(event.target.value) })} onBlur={commit} />
<InspectorSelect value={style.fontFamily} options={fontOptions} ariaLabel="Font family" onValueChange={(fontFamily) => updateStyle({ ...style, fontFamily })} />
<input aria-label="Font size" className={textFieldClass()} type="text" inputMode="numeric" value={fontSizeDraft} disabled={layer.locked} onFocus={(event) => event.currentTarget.select()} onChange={(event) => { const draft = event.target.value; setFontSizeDraft(draft); const next = Number(draft); if (draft.trim() && Number.isFinite(next)) updateStyle({ ...style, fontSize: next }); }} onBlur={() => commitNumber("fontSize", fontSizeDraft, setFontSizeDraft)} onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Enter") event.currentTarget.blur(); if (event.key === "Escape") { setFontSizeDraft(String(layer.style.fontSize)); event.currentTarget.blur(); } }} />
<BottomControlColorPicker value={style.color} aria-label="Text color" onValueChange={(color) => updateStyle({ ...style, color })} />
<InspectorSelect value={String(style.fontWeight)} options={weightOptions} ariaLabel="Font weight" onValueChange={(fontWeight) => updateStyle({ ...style, fontWeight: Number(fontWeight) as 400 | 700 })} />
<InspectorSelect value={style.fontStyle} options={styleOptions} ariaLabel="Font style" onValueChange={(fontStyle) => updateStyle({ ...style, fontStyle })} />
<InspectorSelect value={style.alignment} options={alignmentOptions} ariaLabel="Text alignment" onValueChange={(alignment) => updateStyle({ ...style, alignment })} />
<input aria-label="Line height" className={textFieldClass()} type="text" inputMode="decimal" value={lineHeightDraft} disabled={layer.locked} onFocus={(event) => event.currentTarget.select()} onChange={(event) => { const draft = event.target.value; setLineHeightDraft(draft); const next = Number(draft); if (draft.trim() && Number.isFinite(next)) updateStyle({ ...style, lineHeight: next }); }} onBlur={() => commitNumber("lineHeight", lineHeightDraft, setLineHeightDraft)} onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Enter") event.currentTarget.blur(); if (event.key === "Escape") { setLineHeightDraft(String(layer.style.lineHeight)); event.currentTarget.blur(); } }} />
</div>
<p className="text-[10px] text-white/40">Built-in system fonts only. Missing fonts fall back to Arial/sans-serif; exact glyph shapes can vary by platform.</p>
</section>;
}
function textFieldClass() { return "min-w-0 rounded-lg bg-black/25 px-2 py-1.5 text-xs text-white outline-none ring-1 ring-white/10"; }
const fontOptions = builtInTextFonts.map((font) => ({ value: font, label: font }));
const weightOptions = [{ value: "400", label: "Regular" }, { value: "700", label: "Bold" }] as const;
const styleOptions = [{ value: "normal", label: "Normal" }, { value: "italic", label: "Italic" }] as const;
const alignmentOptions = [{ value: "left", label: "Left" }, { value: "center", label: "Center" }, { value: "right", label: "Right" }] as const;
function InspectorSelect<T extends string>({ value, options, ariaLabel, onValueChange }: { value: T; options: readonly BottomControlSelectOption<T>[]; ariaLabel: string; onValueChange: (value: T) => void }) {
return <BottomControlSelectMenu value={value} options={options} aria-label={ariaLabel} placement="inline" onValueChange={onValueChange} />;
}
function textFieldClass() { return "h-7 min-w-0 rounded bg-black/20 px-1.5 font-mono text-xs text-white outline-none ring-1 ring-white/[0.07] focus:ring-sky-300/50"; }
function AdjustmentInspector({ layer, dispatch }: { layer: Extract<Layer, { type: "adjustment" }>; dispatch: AppStore["dispatch"] }) {
const [draft, setDraft] = useState<ColorAdjustment>(() => cloneAdjustment(layer.adjustment));
@@ -243,13 +271,8 @@ function AdjustmentInspector({ layer, dispatch }: { layer: Extract<Layer, { type
["Blue", "blue", draft.colorBalance.blue],
] as const;
const commit = () => {
if (JSON.stringify(draft) === JSON.stringify(layer.adjustment)) return;
dispatch(commandIds.documentSetAdjustment, { layerId: layer.id, adjustment: draft });
};
return (
<section aria-label="Adjustment settings" className="mb-3 rounded-[1.5rem] bg-white/[0.05] p-3">
<section aria-label="Adjustment settings" className="mb-2 border-b border-white/[0.07] px-0.5 pb-3">
<p className="mb-2 text-xs text-white/45">
Affects visible artboard layers beneath it. Adjustment layers stay at artboard level.
</p>
@@ -257,17 +280,19 @@ function AdjustmentInspector({ layer, dispatch }: { layer: Extract<Layer, { type
{fields.map(([label, key, value]) => (
<label key={key} className="text-[0.7rem] text-white/55">
<span>{label}</span>
<input
className="mt-1 w-full accent-violet-300"
type="range"
min="-1"
max="1"
step="0.01"
<BottomControlSlider
className="mt-1 w-full"
min={-1}
max={1}
step={0.01}
value={value}
disabled={layer.locked}
onChange={(event) => setDraft(withAdjustmentValue(draft, key, Number(event.currentTarget.value)))}
onPointerUp={commit}
onBlur={commit}
aria-label={`${label} adjustment`}
onValueChange={(value) => {
const next = withAdjustmentValue(draft, key, value);
setDraft(next);
dispatch(commandIds.documentSetAdjustment, { layerId: layer.id, adjustment: next });
}}
/>
</label>
))}
@@ -321,8 +346,11 @@ function LayerRow({
const layerMask = getLayerMask(layer);
const maskLayer = layerMask ? documentIndex.layerById.get(layerMask.maskLayerId) : undefined;
const maskAsset = maskLayer && (maskLayer.type === "image" || maskLayer.type === "raster") ? documentIndex.assetById.get(maskLayer.assetId) : undefined;
const inpaintRegion = document.inpaintRegions.find((region) => region.targetLayerId === layer.id && region.enabled);
const inpaintMaskAsset = inpaintRegion ? documentIndex.assetById.get(inpaintRegion.maskAssetId) : undefined;
const canAddMask = Boolean(layerInfo && (layer.type === "image" || layer.type === "raster") && !layerMask);
const editingMask = Boolean(maskEdit && layerMask && maskEdit.targetLayerId === layer.id && maskEdit.maskLayerId === layerMask.maskLayerId);
const editingInpaintRegion = Boolean(maskEdit?.kind === "inpaintRegion" && inpaintRegion && maskEdit.inpaintRegionId === inpaintRegion.id);
const thumbnail = thumbnailByLayerId.get(layer.id) ?? { kind: "empty" };
const maskThumbnail = maskLayer ? thumbnailByLayerId.get(maskLayer.id) : undefined;
const rowPadding = 12 + depth * 16;
@@ -331,7 +359,7 @@ function LayerRow({
<div>
<div
draggable
className={`group flex h-12 w-full items-center gap-3 rounded-full px-4 text-left transition ${editingMask ? "bg-sky-300 text-black" : selected ? "bg-white text-black" : "text-white/75 hover:bg-white/[0.07] hover:text-white"}`}
className={`group flex h-10 w-full items-center gap-2 rounded-lg px-2 text-left transition ${editingMask ? "bg-sky-300 text-slate-950" : selected ? "bg-white text-slate-950" : "text-white/70 hover:bg-white/[0.06] hover:text-white"}`}
style={{ paddingLeft: rowPadding }}
onDragStart={(event) => {
event.dataTransfer.effectAllowed = "move";
@@ -383,13 +411,13 @@ function LayerRow({
</button>
)}
{layerMask ? (
<span className={`inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs ${editingMask || selected ? "bg-black/10 text-black/65" : "bg-sky-400/10 text-sky-100/70"}`}>
<span className={`inline-flex items-center gap-1 rounded px-2 py-0.5 text-[0.68rem] ${editingMask || selected ? "bg-black/10 text-black/65" : "bg-sky-400/10 text-sky-100/70"}`}>
<Stack size={13} weight="fill" /> Mask
</span>
) : canAddMask && layerInfo ? (
<button
type="button"
className={editingMask || selected ? "rounded-full bg-black/10 px-3 py-1 text-xs text-black/65 transition hover:bg-black/15" : "rounded-full bg-white/5 px-3 py-1 text-xs text-white/45 transition hover:bg-sky-400/15 hover:text-sky-100"}
className={editingMask || selected ? "rounded bg-black/10 px-2 py-0.5 text-[0.68rem] text-black/65 transition hover:bg-black/15" : "rounded bg-white/[0.04] px-2 py-0.5 text-[0.68rem] text-white/45 transition hover:bg-sky-400/15 hover:text-sky-100"}
onClick={() => addLayerMask(documentIndex, layerInfo, dispatch)}
>
Add mask
@@ -400,7 +428,7 @@ function LayerRow({
</button>
</div>
{layerMask ? (
<div role="group" aria-label={`Mask attached to ${layer.name}${editingMask ? ", currently editing" : ""}`} className={`relative mt-2 ml-10 flex min-h-14 flex-wrap items-center gap-2 rounded-[1.5rem] border-l-2 py-2 pl-4 pr-2 text-sm transition ${editingMask ? "border-sky-300 bg-sky-300/15 text-sky-50 ring-1 ring-inset ring-sky-300/30" : "border-sky-300/30 text-sky-100/70 hover:bg-white/[0.04]"}`}>
<div role="group" aria-label={`Mask attached to ${layer.name}${editingMask ? ", currently editing" : ""}`} className={`relative mt-1 ml-8 flex min-h-11 flex-wrap items-center gap-1.5 border-l-2 py-1.5 pl-3 pr-1.5 text-xs transition ${editingMask ? "border-sky-300 text-sky-50" : "border-sky-300/25 text-sky-100/65"}`}>
<span aria-hidden="true" className="absolute -left-2 top-1/2 h-px w-2 bg-sky-300/40" />
{maskThumbnail ? <LayerThumbnail model={maskThumbnail} label={`${layer.name} mask preview`} /> : <Stack size={24} weight="fill" className="shrink-0" />}
<span className="min-w-28 flex-1 truncate font-medium">{maskLayer ? editingMask ? "Editing layer mask" : "Layer mask" : "Layer mask missing"}</span>
@@ -409,7 +437,7 @@ function LayerRow({
<div className="flex flex-wrap items-center gap-1">
<button
type="button"
className={`h-9 rounded-full px-4 text-sm font-medium transition ${editingMask ? "bg-sky-300 text-black" : "text-sky-100/75 hover:bg-white/10 hover:text-sky-50"}`}
className={`h-8 rounded-md px-3 text-xs font-medium transition ${editingMask ? "bg-sky-300 text-black" : "text-sky-100/70 hover:bg-white/[0.07] hover:text-sky-50"}`}
onClick={() =>
editingMask
? dispatch(commandIds.toolExitMaskEdit, undefined)
@@ -447,13 +475,23 @@ function LayerRow({
) : null}
<button
type="button"
className="h-9 rounded-full px-4 text-sm font-medium text-sky-100/55 transition hover:bg-red-400/15 hover:text-red-100"
className="h-8 rounded-md px-3 text-xs font-medium text-sky-100/50 transition hover:bg-red-400/15 hover:text-red-100"
onClick={() => dispatch(commandIds.documentRemoveLayerMask, { layerId: layer.id })}
>
Remove
</button>
</div>
) : null}
{inpaintRegion ? (
<div role="group" aria-label={`AI edit region attached to ${layer.name}`} className={`relative mt-1 ml-8 flex min-h-11 flex-wrap items-center gap-1.5 border-l-2 py-1.5 pl-3 pr-1.5 text-xs transition ${editingInpaintRegion ? "border-red-300 text-red-50" : "border-red-300/25 text-red-100/65"}`}>
<span aria-hidden="true" className="absolute -left-2 top-1/2 h-px w-2 bg-red-300/40" />
{inpaintMaskAsset ? <img src={inpaintMaskAsset.source} alt={`${layer.name} AI edit mask preview`} className="h-8 w-11 rounded-md bg-black/30 object-cover ring-1 ring-red-300/30" /> : <Stack size={24} weight="fill" />}
<span className="min-w-28 flex-1 truncate font-medium">{editingInpaintRegion ? "Editing AI region" : inpaintRegion.name}</span>
{inpaintMaskAsset ? <MaskStatus asset={inpaintMaskAsset} purpose="inpaint" /> : null}
<button type="button" className={`h-8 rounded-md px-3 text-xs font-medium ${editingInpaintRegion ? "bg-red-200 text-red-950" : "hover:bg-white/[0.07]"}`} onClick={() => editingInpaintRegion ? dispatch(commandIds.toolExitMaskEdit, undefined) : dispatch(commandIds.toolEnterInpaintRegionEdit, { targetLayerId: layer.id, regionId: inpaintRegion.id })}>{editingInpaintRegion ? "Done" : "Edit"}</button>
<button type="button" className="h-8 rounded-md px-3 text-xs font-medium text-red-100/50 transition hover:bg-red-400/15 hover:text-red-100" onClick={() => dispatch(commandIds.documentRemoveInpaintRegion, { regionId: inpaintRegion.id })}>Remove</button>
</div>
) : null}
{layer.type === "group"
? layer.children.map((child) => (
<LayerRow
@@ -487,7 +525,7 @@ function RenameInput({ value, onChange, onCommit, onCancel }: { value: string; o
<input
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-md bg-white px-2.5 py-1 font-medium text-black outline-none ring-1 ring-black/10 focus:ring-sky-300/50"
value={value}
onChange={(event) => onChange(event.target.value)}
onBlur={onCommit}
@@ -521,13 +559,13 @@ function dropLayer(
}
function toolbarButtonClass() {
return "inline-flex size-12 items-center justify-center rounded-full text-white/75 transition hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-35 focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30";
return "inline-flex size-8 items-center justify-center rounded-lg text-white/60 transition hover:bg-white/[0.07] hover:text-white disabled:pointer-events-none disabled:opacity-30 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/50 [&>svg]:size-[17px]";
}
function labeledToolbarButtonClass() {
return "inline-flex h-12 flex-1 items-center rounded-full pr-4 text-sm font-medium text-white/75 transition hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-35 focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30";
return "inline-flex h-8 flex-1 items-center rounded-lg pr-2 text-xs font-medium text-white/65 transition hover:bg-white/[0.07] hover:text-white disabled:pointer-events-none disabled:opacity-30 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/50";
}
function maskActionButtonClass() {
return "rounded-full bg-white/5 px-2.5 py-1 text-[0.7rem] font-semibold text-white/60 transition hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-35";
return "rounded-md bg-white/[0.05] px-2 py-1 text-[0.68rem] font-semibold text-white/55 transition hover:bg-white/[0.09] hover:text-white disabled:pointer-events-none disabled:opacity-35";
}

View File

@@ -1,77 +0,0 @@
import { useState } from "react";
import { ArrowFatLineUp, Backspace, CaretDown, Command } from "@phosphor-icons/react";
type Shortcut = {
keys: string[];
label: string;
};
const shortcuts: Shortcut[] = [
{ keys: ["S"], label: "Select" },
{ keys: ["G"], label: "Generate" },
{ keys: ["B"], label: "Brush" },
{ keys: ["E"], label: "Eraser" },
{ keys: ["K"], label: "Chroma key" },
{ keys: ["W"], label: "Magic wand" },
{ keys: ["⇧", "Click"], label: "Wand add" },
{ keys: ["Alt", "Click"], label: "Wand subtract" },
{ keys: ["P"], label: "Pan" },
{ keys: ["Space"], label: "Hold to pan" },
{ keys: ["L"], label: "Layers" },
{ keys: ["⌘", "K"], label: "Commands" },
{ keys: ["⌘", "O"], label: "Open image" },
{ keys: ["⌘", "Z"], label: "Undo" },
{ keys: ["⇧", "⌘", "Z"], label: "Redo" },
{ keys: ["Del", "⌫"], label: "Delete" },
];
export function ShortcutsDisplay() {
const [open, setOpen] = useState(true);
return (
<aside
aria-label="Keyboard shortcuts"
className="pointer-events-auto hidden w-[30rem] rounded-[2rem] p-3 text-white backdrop-blur-xl sm:block"
>
<button
type="button"
className="flex h-12 w-full items-center justify-between rounded-full px-5 text-left transition hover:bg-white/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30"
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
>
<span className="text-[0.65rem] font-semibold uppercase tracking-[0.22em] text-white/45">Shortcuts</span>
<CaretDown size={14} className={`text-white/40 transition-transform ${open ? "rotate-0" : "-rotate-90"}`} weight="bold" />
</button>
<dl className={`mt-2 grid grid-cols-2 gap-x-4 gap-y-1 overflow-hidden rounded-[1.5rem] transition-all duration-200 ${open ? "max-h-96 p-1 opacity-100" : "mt-0 max-h-0 p-0 opacity-0"}`}>
{shortcuts.map((shortcut) => (
<div key={`${shortcut.keys.join("+")}-${shortcut.label}`} className="flex h-8 items-center justify-between gap-3 rounded-full px-2">
<dt className="truncate text-xs font-medium text-white/60">{shortcut.label}</dt>
<dd className="flex shrink-0 items-center gap-2">
{shortcut.keys.map((key, index) => (
<span key={key} className="inline-flex items-center gap-2">
{shortcut.label === "Delete" && index > 0 ? <span className="h-3 w-px bg-white/20" aria-hidden="true" /> : null}
<kbd className="inline-flex h-8 min-w-8 items-center justify-center rounded-full bg-white/5 px-3 text-sm font-semibold leading-none text-white/75">
{keyIcon(key) ?? key}
</kbd>
</span>
))}
</dd>
</div>
))}
</dl>
</aside>
);
}
function keyIcon(key: string) {
switch (key) {
case "⌘":
return <Command size={18} weight="bold" />;
case "⇧":
return <ArrowFatLineUp size={18} weight="bold" />;
case "⌫":
return <Backspace size={18} weight="bold" />;
default:
return undefined;
}
}

View File

@@ -1,26 +1,27 @@
import { Cursor, Eraser, Hand, PaintBrush, DropHalf, MagicWand, Sparkle } from "@phosphor-icons/react";
import { Cursor, Eraser, Feather, Hand, PaintBrush, DropHalf, MagicWand, Sparkle, Polygon, Rectangle, Selection } from "@phosphor-icons/react";
import { commandIds } from "@commands/ids";
import type { AppStore } from "@editor/store";
import type { InteractionMode, OperationId, ToolId } from "@editor/tools";
import { availableOperationIds, availableToolIds } from "@editor/tools";
import type { WorkspacePanel } from "@editor/state";
import { isOperationWorkspacePanel, type WorkspacePanel } from "@editor/state";
import { labelForTool } from "./toolLabels";
export type ToolOverlayProps = {
activeTool: ToolId;
interactionMode: InteractionMode;
panel: WorkspacePanel;
inpaintMaskEditing: boolean;
dispatch: AppStore["dispatch"];
};
export function ToolOverlay({ activeTool, interactionMode, panel, dispatch }: ToolOverlayProps) {
export function ToolOverlay({ activeTool, interactionMode, panel, inpaintMaskEditing, dispatch }: ToolOverlayProps) {
return (
<nav
aria-label="Tools"
className="pointer-events-auto flex w-20 flex-col items-center gap-3 rounded-full p-2 text-white backdrop-blur-xl"
className="app-surface pointer-events-auto flex w-11 flex-col items-center gap-1 rounded-xl p-1 text-white"
>
{availableToolIds.map((tool) => {
const active = isToolHighlighted(tool, activeTool, interactionMode);
{availableToolIds.filter((tool) => inpaintMaskEditing || (tool !== "semanticSelect" && tool !== "maskLasso" && tool !== "maskRectangle")).map((tool) => {
const active = !isOperationWorkspacePanel(panel) && isToolHighlighted(tool, activeTool, interactionMode);
const Icon = iconForTool(tool);
return (
@@ -33,11 +34,11 @@ export function ToolOverlay({ activeTool, interactionMode, panel, dispatch }: To
className={buttonClass(active)}
onClick={() => dispatch(commandIds.toolSetActive, { tool })}
>
<Icon size={24} weight={active ? "fill" : "regular"} />
<Icon size={18} weight={active ? "fill" : "regular"} />
</button>
);
})}
<div className="h-px w-8 bg-white/15" aria-hidden="true" />
<div className="my-0.5 h-px w-6 bg-white/10" aria-hidden="true" />
{availableOperationIds.map((operation) => {
const active = panel === operation;
const Icon = iconForOperation(operation);
@@ -51,7 +52,7 @@ export function ToolOverlay({ activeTool, interactionMode, panel, dispatch }: To
className={buttonClass(active)}
onClick={() => dispatch(commandIds.workspaceSetPanel, { panel: active ? "none" : operation })}
>
<Icon size={24} weight={active ? "fill" : "regular"} />
<Icon size={18} weight={active ? "fill" : "regular"} />
</button>
);
})}
@@ -65,8 +66,16 @@ function iconForTool(tool: ToolId) {
return PaintBrush;
case "eraser":
return Eraser;
case "feather":
return Feather;
case "magicWand":
return MagicWand;
case "semanticSelect":
return Selection;
case "maskLasso":
return Polygon;
case "maskRectangle":
return Rectangle;
case "pan":
return Hand;
case "select":
@@ -88,6 +97,6 @@ function isToolHighlighted(tool: ToolId, activeTool: ToolId, interactionMode: In
}
function buttonClass(active: boolean) {
const base = "inline-flex size-12 items-center justify-center rounded-full text-xs font-medium transition focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30";
return active ? `${base} bg-white text-black hover:bg-white hover:text-black` : `${base} text-white/75 hover:bg-white/10 hover:text-white`;
const base = "inline-flex size-9 items-center justify-center rounded-lg text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/50";
return active ? `${base} bg-sky-300 text-slate-950` : `${base} text-white/55 hover:bg-white/[0.07] hover:text-white`;
}

View File

@@ -13,6 +13,7 @@ export type BrushControlsProps = {
tool: Extract<ToolId, "brush" | "eraser">;
settings: BrushSettings;
editingMask?: boolean;
maskKind?: "layerMask" | "inpaintRegion";
maskViewMode?: MaskViewMode;
dispatch: AppStore["dispatch"];
};
@@ -24,7 +25,8 @@ const maskViewModeOptions = [
{ value: "overlay", label: "Overlay" },
] satisfies readonly BottomControlSelectOption<MaskViewMode>[];
export function BrushControls({ tool, settings, editingMask = false, maskViewMode = "composite", dispatch }: BrushControlsProps) {
export function BrushControls({ tool, settings, editingMask = false, maskKind = "layerMask", maskViewMode = "composite", dispatch }: BrushControlsProps) {
const inpaintRegion = editingMask && maskKind === "inpaintRegion";
return (
<div className={bottomControlMenuClass()}>
<span className={bottomControlIconSlotClass()} title={editingMask ? `Mask ${tool === "eraser" ? "hide" : "reveal"}` : tool === "eraser" ? "Eraser" : "Brush"}>
@@ -33,8 +35,8 @@ export function BrushControls({ tool, settings, editingMask = false, maskViewMod
<BottomControlDivider />
{editingMask ? (
<>
<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")} aria-pressed={tool === "eraser"} onClick={() => dispatch(commandIds.toolSetActive, { tool: "eraser" })}>Hide</button>
<button type="button" className={maskModeButtonClass(tool === "brush")} aria-pressed={tool === "brush"} onClick={() => dispatch(commandIds.toolSetActive, { tool: "brush" })}>{inpaintRegion ? "Replace" : "Reveal"}</button>
<button type="button" className={maskModeButtonClass(tool === "eraser")} aria-pressed={tool === "eraser"} onClick={() => dispatch(commandIds.toolSetActive, { tool: "eraser" })}>{inpaintRegion ? "Protect" : "Hide"}</button>
<BottomControlDivider />
</>
) : null}
@@ -54,24 +56,31 @@ export function BrushControls({ tool, settings, editingMask = false, maskViewMod
min={1}
max={200}
value={settings.size}
className="w-48"
className="min-w-10 w-[clamp(2.5rem,9vw,7rem)] shrink"
aria-label={`${tool} size`}
onValueChange={(size) => dispatch(commandIds.toolSetBrushSettings, { size })}
/>
<span className="w-12 text-right text-base text-white">{Math.round(settings.size)}</span>
<span className="w-9 text-right text-xs font-medium text-white/85">{Math.round(settings.size)}</span>
</label>
<BottomControlDivider />
<label className={bottomControlFieldClass()}><span className={bottomControlLabelClass()}>Opacity</span><BottomControlSlider min={0} max={100} value={settings.opacity} className="min-w-10 w-[clamp(2.5rem,7vw,6rem)] shrink" aria-label="Brush opacity" onValueChange={(opacity) => dispatch(commandIds.toolSetBrushSettings, { opacity })} /><span className="w-9 text-right text-xs font-medium text-white/85">{Math.round(settings.opacity)}</span></label>
<BottomControlDivider />
<label className={bottomControlFieldClass()}><span className={bottomControlLabelClass()}>Flow</span><BottomControlSlider min={1} max={100} value={settings.flow} className="min-w-10 w-[clamp(2.5rem,7vw,6rem)] shrink" aria-label="Brush flow" onValueChange={(flow) => dispatch(commandIds.toolSetBrushSettings, { flow })} /><span className="w-9 text-right text-xs font-medium text-white/85">{Math.round(settings.flow)}</span></label>
<BottomControlDivider />
<label className={bottomControlFieldClass()}><span className={bottomControlLabelClass()}>Smooth</span><BottomControlSlider min={0} max={100} value={settings.smoothing} className="min-w-10 w-[clamp(2.5rem,7vw,6rem)] shrink" aria-label="Brush smoothing" onValueChange={(smoothing) => dispatch(commandIds.toolSetBrushSettings, { smoothing })} /><span className="w-9 text-right text-xs font-medium text-white/85">{Math.round(settings.smoothing)}</span></label>
<button type="button" className={maskModeButtonClass(settings.pressureSize)} aria-pressed={settings.pressureSize} title="Use pen pressure for brush size" onClick={() => dispatch(commandIds.toolSetBrushSettings, { pressureSize: !settings.pressureSize })}>Pressure</button>
<BottomControlDivider />
<label className={bottomControlFieldClass()}>
<span className={bottomControlLabelClass()}>Hard</span>
<BottomControlSlider
min={0}
max={100}
value={settings.hardness}
className="w-48"
className="min-w-10 w-[clamp(2.5rem,9vw,7rem)] shrink"
aria-label={`${tool} hardness`}
onValueChange={(hardness) => dispatch(commandIds.toolSetBrushSettings, { hardness })}
/>
<span className="w-12 text-right text-base text-white">{Math.round(settings.hardness)}</span>
<span className="w-9 text-right text-xs font-medium text-white/85">{Math.round(settings.hardness)}</span>
</label>
{editingMask ? (
<>
@@ -88,7 +97,7 @@ export function BrushControls({ tool, settings, editingMask = false, maskViewMod
<BottomControlDivider />
<button
type="button"
className="rounded-full bg-white px-5 py-2 text-base font-medium text-black transition hover:bg-white/90"
className="rounded-md bg-sky-300 px-3 py-1.5 text-xs font-semibold text-slate-950 transition hover:bg-sky-200"
onClick={() => dispatch(commandIds.toolExitMaskEdit, undefined)}
>
Done
@@ -100,5 +109,5 @@ export function BrushControls({ tool, settings, editingMask = false, maskViewMod
}
function maskModeButtonClass(active: boolean) {
return `rounded-full px-4 py-2 text-base font-medium transition ${active ? "bg-white text-black" : "bg-white/10 text-white hover:bg-white/15"}`;
return `rounded-md px-3 py-1.5 text-xs font-medium transition ${active ? "bg-sky-300 text-slate-950" : "bg-white/[0.06] text-white/70 hover:bg-white/10"}`;
}

View File

@@ -66,11 +66,11 @@ export function ChromaKeyControls({ document, selection, settings, dispatch }: C
min={0}
max={255}
value={settings.tolerance}
className="w-36"
className="min-w-7 w-[clamp(1.75rem,5vw,4.5rem)] shrink"
aria-label="Chroma key tolerance"
onValueChange={(tolerance) => dispatch(commandIds.toolSetChromaKeySettings, { tolerance })}
/>
<span className="w-10 text-right text-base text-white">{Math.round(settings.tolerance)}</span>
<span className="w-8 text-right text-xs font-medium text-white/85">{Math.round(settings.tolerance)}</span>
</label>
<BottomControlDivider />
<label className={bottomControlFieldClass()}>
@@ -79,11 +79,11 @@ export function ChromaKeyControls({ document, selection, settings, dispatch }: C
min={0}
max={255}
value={settings.softness}
className="w-36"
className="min-w-7 w-[clamp(1.75rem,5vw,4.5rem)] shrink"
aria-label="Chroma key edge softness"
onValueChange={(softness) => dispatch(commandIds.toolSetChromaKeySettings, { softness })}
/>
<span className="w-10 text-right text-base text-white">{Math.round(settings.softness)}</span>
<span className="w-8 text-right text-xs font-medium text-white/85">{Math.round(settings.softness)}</span>
</label>
<BottomControlDivider />
<label className={bottomControlFieldClass()}>
@@ -92,11 +92,11 @@ export function ChromaKeyControls({ document, selection, settings, dispatch }: C
min={0}
max={20}
value={settings.feather}
className="w-32"
className="min-w-7 w-[clamp(1.75rem,5vw,4rem)] shrink"
aria-label="Chroma key mask feather"
onValueChange={(feather) => dispatch(commandIds.toolSetChromaKeySettings, { feather })}
/>
<span className="w-8 text-right text-base text-white">{Math.round(settings.feather)}</span>
<span className="w-7 text-right text-xs font-medium text-white/85">{Math.round(settings.feather)}</span>
</label>
<BottomControlDivider />
<label className={bottomControlFieldClass()}>
@@ -105,11 +105,11 @@ export function ChromaKeyControls({ document, selection, settings, dispatch }: C
min={-20}
max={20}
value={settings.choke}
className="w-32"
className="min-w-7 w-[clamp(1.75rem,5vw,4rem)] shrink"
aria-label="Chroma key mask choke or expand"
onValueChange={(choke) => dispatch(commandIds.toolSetChromaKeySettings, { choke })}
/>
<span className="w-8 text-right text-base text-white">{Math.round(settings.choke)}</span>
<span className="w-7 text-right text-xs font-medium text-white/85">{Math.round(settings.choke)}</span>
</label>
<BottomControlDivider />
<label className={bottomControlFieldClass()}>
@@ -118,11 +118,11 @@ export function ChromaKeyControls({ document, selection, settings, dispatch }: C
min={0}
max={20}
value={settings.despeckle}
className="w-32"
className="min-w-7 w-[clamp(1.75rem,5vw,4rem)] shrink"
aria-label="Chroma key mask despeckle"
onValueChange={(despeckle) => dispatch(commandIds.toolSetChromaKeySettings, { despeckle })}
/>
<span className="w-8 text-right text-base text-white">{Math.round(settings.despeckle)}</span>
<span className="w-7 text-right text-xs font-medium text-white/85">{Math.round(settings.despeckle)}</span>
</label>
<BottomControlDivider />
<label className={bottomControlFieldClass()}>
@@ -131,16 +131,16 @@ export function ChromaKeyControls({ document, selection, settings, dispatch }: C
min={0}
max={100}
value={settings.spill}
className="w-36"
className="min-w-7 w-[clamp(1.75rem,5vw,4.5rem)] shrink"
aria-label="Chroma key spill suppression"
onValueChange={(spill) => dispatch(commandIds.toolSetChromaKeySettings, { spill })}
/>
<span className="w-10 text-right text-base text-white">{Math.round(settings.spill)}</span>
<span className="w-8 text-right text-xs font-medium text-white/85">{Math.round(settings.spill)}</span>
</label>
<BottomControlDivider />
<button
type="button"
className="rounded-full bg-white px-5 py-2 text-base font-medium text-black transition hover:bg-white/90 disabled:pointer-events-none disabled:opacity-35"
className="rounded-md bg-sky-300 px-4 py-1.5 text-xs font-semibold text-slate-950 transition hover:bg-sky-200 disabled:pointer-events-none disabled:opacity-35"
disabled={!target}
title={target ? "Create or update a layer mask from the chroma key" : "Select one image or raster layer to create a chroma key mask"}
onClick={() => target && void applyChromaKeyMask(target, settings, dispatch)}

View File

@@ -29,12 +29,12 @@ export function BottomControlColorPicker({ value, onValueChange, ...props }: Bot
<div className="flex items-center gap-3">
<button
type="button"
className="grid size-10 place-items-center rounded-full border border-white/40 bg-transparent text-white transition hover:border-white focus:outline-none focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-white"
className="grid size-8 place-items-center rounded-md border border-white/20 bg-white/[0.03] text-white transition hover:border-white/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/50"
aria-label={props["aria-label"]}
title={value}
onClick={() => inputRef.current?.click()}
>
<span className="size-6 rounded-full border border-white/25" style={{ backgroundColor: value }} />
<span className="size-5 rounded border border-white/20" style={{ backgroundColor: value }} />
</button>
<input
ref={inputRef}
@@ -45,7 +45,7 @@ export function BottomControlColorPicker({ value, onValueChange, ...props }: Bot
onChange={(event) => onValueChange(event.target.value)}
/>
<input
className="h-10 w-24 bg-transparent px-2 font-mono text-base uppercase text-white outline-none transition placeholder:text-white/30 focus:text-white"
className="h-8 w-20 bg-transparent px-2 font-mono text-xs uppercase text-white outline-none transition placeholder:text-white/25 focus:text-white"
value={draft}
aria-label={`${props["aria-label"]} hex value`}
spellCheck={false}

View File

@@ -0,0 +1,46 @@
import { Feather } from "@phosphor-icons/react";
import { commandIds } from "@commands/ids";
import type { AppStore } from "@editor/store";
import type { FeatherSettings } from "@editor/tools";
import { BottomControlDivider } from "./Divider";
import { BottomControlSlider } from "./Slider";
import { bottomControlFieldClass, bottomControlIconSlotClass, bottomControlLabelClass, bottomControlMenuClass } from "./styles";
export function FeatherControls({ settings, editingMask, dispatch }: { settings: FeatherSettings; editingMask: boolean; dispatch: AppStore["dispatch"] }) {
return (
<div className={bottomControlMenuClass()}>
<span className={bottomControlIconSlotClass()} title="Feather mask edges"><Feather size={24} weight="regular" /></span>
<BottomControlDivider />
<span className="whitespace-nowrap text-xs font-medium text-white/65">{editingMask ? "Feather mask" : "Feather layer mask"}</span>
<BottomControlDivider />
<FeatherSlider label="Size" min={1} max={400} value={settings.size} onValueChange={(size) => dispatch(commandIds.toolSetFeatherSettings, { size })} />
<BottomControlDivider />
<FeatherSlider label="Radius" min={1} max={128} value={settings.radius} onValueChange={(radius) => dispatch(commandIds.toolSetFeatherSettings, { radius })} />
<BottomControlDivider />
<FeatherSlider label="Strength" min={1} max={100} value={settings.strength} suffix="%" onValueChange={(strength) => dispatch(commandIds.toolSetFeatherSettings, { strength })} />
<BottomControlDivider />
<FeatherSlider label="Smooth" min={0} max={100} value={settings.smoothing} suffix="%" onValueChange={(smoothing) => dispatch(commandIds.toolSetFeatherSettings, { smoothing })} />
<button type="button" className={toggleClass(settings.pressureSize)} aria-pressed={settings.pressureSize} title="Use pen pressure for feather brush size" onClick={() => dispatch(commandIds.toolSetFeatherSettings, { pressureSize: !settings.pressureSize })}>Pressure</button>
{editingMask ? (
<>
<BottomControlDivider />
<button type="button" className="rounded-md bg-sky-300 px-3 py-1.5 text-xs font-semibold text-slate-950 transition hover:bg-sky-200" onClick={() => dispatch(commandIds.toolExitMaskEdit, undefined)}>Done</button>
</>
) : null}
</div>
);
}
function FeatherSlider({ label, min, max, value, suffix = "", onValueChange }: { label: string; min: number; max: number; value: number; suffix?: string; onValueChange: (value: number) => void }) {
return (
<label className={bottomControlFieldClass()}>
<span className={bottomControlLabelClass()}>{label}</span>
<BottomControlSlider min={min} max={max} value={value} className="min-w-10 w-[clamp(2.5rem,8vw,6.5rem)] shrink" aria-label={`Feather ${label.toLowerCase()}`} onValueChange={onValueChange} />
<span className="w-10 text-right text-xs font-medium text-white/85">{Math.round(value)}{suffix}</span>
</label>
);
}
function toggleClass(active: boolean) {
return `rounded-md px-3 py-1.5 text-xs font-medium transition ${active ? "bg-sky-300 text-slate-950" : "bg-white/[0.06] text-white/70 hover:bg-white/10"}`;
}

View File

@@ -23,11 +23,11 @@ export function GenerateActionControls({ settings, generation, dispatch, workflo
const repair = precondition.ready ? undefined : precondition.repair;
return (
<div className="flex max-w-[calc(100vw-2rem)] flex-wrap items-center justify-center gap-2 px-2">
<div className="flex max-w-[calc(100vw-2rem)] flex-nowrap items-center justify-start gap-2 whitespace-nowrap px-2">
<button
type="button"
disabled={!canGenerate}
className="h-12 rounded-full bg-white px-7 text-base font-semibold !text-black transition hover:bg-white/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/40 disabled:pointer-events-none disabled:opacity-35"
className="h-9 rounded-lg bg-sky-300 px-5 text-xs font-semibold !text-slate-950 transition hover:bg-sky-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/50 disabled:pointer-events-none disabled:opacity-35"
title={job?.status === "failed" ? job.error : preconditionMessage ?? "Generate with ComfyUI"}
onClick={() => {
void generate();
@@ -36,17 +36,17 @@ export function GenerateActionControls({ settings, generation, dispatch, workflo
{busy && job?.kind === "generate" ? "Generating..." : "Generate"}
</button>
{preconditionMessage ? (
<span className="flex max-w-80 items-center gap-2 rounded-full bg-amber-300/10 px-3 py-2 text-xs text-amber-100/75" role="status">
<span className="flex max-w-80 items-center gap-2 rounded-md border border-amber-300/10 bg-amber-300/[0.07] px-2.5 py-1.5 text-xs text-amber-100/70" role="status">
<span>{preconditionMessage}</span>
{repair === "add-mask" ? (
<button type="button" className="shrink-0 rounded-full bg-white px-3 py-1 font-semibold text-black" onClick={() => void workflow.prepareInpaintMask()}>Add mask</button>
<button type="button" className="shrink-0 rounded-md bg-white px-2.5 py-1 font-semibold text-black" onClick={() => void workflow.prepareInpaintMask()}>Add mask</button>
) : repair === "set-outpaint-padding" ? (
<button type="button" className="shrink-0 rounded-full bg-white px-3 py-1 font-semibold text-black" onClick={() => dispatch(commandIds.toolSetGenerateSettings, { outpaint: { ...settings.outpaint, left: 128, right: 128 } })}>Add padding</button>
<button type="button" className="shrink-0 rounded-md bg-white px-2.5 py-1 font-semibold text-black" onClick={() => dispatch(commandIds.toolSetGenerateSettings, { outpaint: { ...settings.outpaint, left: 128, right: 128 } })}>Add padding</button>
) : null}
</span>
) : null}
<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}
{busy ? <button type="button" className="h-8 rounded-md bg-white/[0.05] px-2.5 text-xs font-semibold text-white/65 hover:bg-white/[0.09] hover:text-white" onClick={workflow.cancel}>Cancel</button> : null}
{candidate ? (
<>
<CandidatePicker generation={generation} dispatch={dispatch} />
@@ -66,19 +66,19 @@ export function GenerateActionControls({ settings, generation, dispatch, workflo
function CandidatePicker({ generation, dispatch }: { generation: GenerationState; dispatch: AppStore["dispatch"] }) {
return (
<div className="subtle-scrollbar flex max-w-[min(34rem,calc(100vw-2rem))] items-stretch gap-2 overflow-x-auto rounded-[1.25rem] bg-black/20 p-2 ring-1 ring-white/[0.08]" role="group" aria-label="Provisional generation candidates">
<div className="subtle-scrollbar flex max-w-[min(34rem,calc(100vw-2rem))] items-stretch gap-1.5 overflow-x-auto border-l border-white/[0.08] pl-2" role="group" aria-label="Provisional generation candidates">
{generation.candidates.map((candidate) => {
const selected = candidate.id === (generation.selectedCandidateId ?? generation.candidates[0]?.id);
return (
<button
key={candidate.id}
type="button"
className={`relative h-16 w-16 shrink-0 overflow-hidden rounded-xl ring-2 transition ${selected ? "ring-white" : "ring-white/10 hover:ring-white/40"}`}
className={`relative h-16 w-16 shrink-0 overflow-hidden rounded-lg border transition ${selected ? "border-sky-300 shadow-[0_0_0_1px_rgba(125,211,252,0.35)]" : "border-white/10 hover:border-white/30"}`}
title={`Provisional candidate · seed ${candidate.seed}`}
onClick={() => dispatch(commandIds.generationSelectCandidate, { candidateId: candidate.id })}
>
<img src={candidate.source} alt="" className="h-full w-full object-cover" />
{selected ? <span className="absolute inset-x-1 bottom-1 rounded-full bg-black/70 px-1 py-0.5 text-[0.6rem] font-semibold text-white">Reviewing</span> : null}
{selected ? <span className="absolute inset-x-1 bottom-1 rounded bg-black/75 px-1 py-0.5 text-[0.58rem] font-semibold uppercase tracking-wide text-sky-100">Reviewing</span> : null}
</button>
);
})}
@@ -116,11 +116,12 @@ function CandidateControls({
const disabled = busy;
return (
<div className="flex flex-wrap items-center justify-center gap-1 rounded-[1.25rem] bg-white/[0.04] px-2 py-2 ring-1 ring-white/[0.05]">
<div className="flex flex-nowrap items-center justify-center gap-1 border-l border-white/[0.08] pl-2">
<CandidatePreview candidate={candidate} />
<span className="px-2 text-xs font-medium text-white/55" title={`${candidate.settings.model} · ${candidate.mode}`}><strong className="block font-semibold text-white/75">Provisional result</strong>Seed {candidate.seed}</span>
<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 || !candidate.inpaint} label="Current mask" title="Rebuild the crop and generate from the current AI edit region" onClick={() => void workflow.rebuildFromCurrentRegion(candidate.id)} />
<CandidateButton
disabled={disabled}
label="Lower"
@@ -159,7 +160,7 @@ function CandidateControls({
onClick={() => {
if (!candidate.inpaint) return;
dispatch(commandIds.selectionSet, { artboardId: candidate.placement.artboardId, layerIds: [candidate.inpaint.targetLayerId] });
dispatch(commandIds.toolEnterMaskEdit, { targetLayerId: candidate.inpaint.targetLayerId, maskLayerId: candidate.inpaint.maskLayerId });
dispatch(commandIds.toolEnterInpaintRegionEdit, { targetLayerId: candidate.inpaint.targetLayerId, regionId: candidate.inpaint.regionId });
}}
/>
<CandidateButton disabled={disabled} label="Dismiss" title="Remove this candidate" onClick={() => dispatch(commandIds.generationRemoveCandidate, { candidateId: candidate.id })} />
@@ -170,14 +171,14 @@ function CandidateControls({
function CandidateCompareControls({ compareMode, disabled, dispatch }: { compareMode: GenerationCompareMode; disabled: boolean; dispatch: AppStore["dispatch"] }) {
return (
<span className="flex items-center gap-1 rounded-full bg-black/20 p-1" aria-label="Compare candidate">
<span className="flex items-center gap-0.5 border-l border-white/[0.08] pl-1" aria-label="Compare candidate">
{generationCompareOptions.map((option) => {
const active = compareMode === option.mode;
return (
<button
key={option.mode}
type="button"
className={`h-7 rounded-full px-2 text-[0.7rem] font-semibold transition ${
className={`h-7 rounded px-2 text-[0.68rem] font-semibold transition ${
active ? "bg-white text-black" : "text-white/55 hover:bg-white/10 hover:text-white"
} disabled:pointer-events-none disabled:opacity-35`}
disabled={disabled}
@@ -200,14 +201,14 @@ const generationCompareOptions: Array<{ mode: GenerationCompareMode; label: stri
function CandidatePreview({ candidate }: { candidate: GenerationCandidate }) {
if (!candidate.inputImage) {
return <img src={candidate.source} alt="" className="h-10 w-10 rounded-full bg-black/25 object-cover ring-1 ring-white/10" />;
return <img src={candidate.source} alt="" className="h-10 w-10 rounded-md bg-black/25 object-cover ring-1 ring-white/10" />;
}
return (
<span className="flex items-center -space-x-2" title={candidate.maskImage ? "Input crop, normalized mask, generated candidate" : "Before and generated candidate"}>
<img src={candidate.inputImage} alt="" className="h-10 w-10 rounded-full bg-black/25 object-cover ring-1 ring-white/10" />
{candidate.maskImage ? <img src={candidate.maskImage} alt="" className="h-10 w-10 rounded-full bg-black/25 object-cover ring-1 ring-white/20" /> : null}
<img src={candidate.source} alt="" className="h-10 w-10 rounded-full bg-black/25 object-cover ring-2 ring-white/40" />
<img src={candidate.inputImage} alt="" className="h-10 w-10 rounded-md bg-black/25 object-cover ring-1 ring-white/10" />
{candidate.maskImage ? <img src={candidate.maskImage} alt="" className="h-10 w-10 rounded-md bg-black/25 object-cover ring-1 ring-white/20" /> : null}
<img src={candidate.source} alt="" className="h-10 w-10 rounded-md bg-black/25 object-cover ring-1 ring-sky-300/40" />
</span>
);
}
@@ -216,7 +217,7 @@ function CandidateButton({ label, title, disabled, busy, onClick }: { label: str
return (
<button
type="button"
className="h-9 rounded-full bg-white/5 px-3 text-xs font-semibold text-white/70 transition hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-35"
className="h-8 rounded-md bg-white/[0.05] px-2.5 text-xs font-semibold text-white/65 transition hover:bg-white/[0.09] hover:text-white disabled:pointer-events-none disabled:opacity-35"
disabled={disabled}
title={title}
onClick={onClick}

View File

@@ -7,6 +7,9 @@ import type { GenerateArchitecture, GenerateIntent, GenerateMode, GenerateSettin
import { resolveGenerationModeOptions, resolveGenerationModelOptions, resolveGenerationStringOptions, resolveGenerationSupportOptions } from "@operations/generation/options";
import { BottomControlSelectMenu, type BottomControlSelectOption } from "./SelectMenu";
import { BottomControlSlider } from "./Slider";
import type { ImageDocument } from "@core/document";
import type { SelectionState } from "@editor/state";
import { InpaintRegionPanel } from "../inpaint/InpaintRegionPanel";
const architectures = [
{ value: "sdxl", label: "SDXL" },
@@ -30,11 +33,6 @@ const sizePresets = [
{ label: "9:16", w: 768, h: 1344 },
] as const;
const inpaintPolarityOptions = [
{ value: "hidden", label: "Hidden / erased" },
{ value: "revealed", label: "Revealed / painted" },
] satisfies readonly BottomControlSelectOption<GenerateSettings["inpaint"]["maskPolarity"]>[];
const inpaintMaskedContentOptions = [
{ value: "neutral", label: "Neutral fill" },
{ value: "original", label: "Original gray" },
@@ -42,13 +40,31 @@ const inpaintMaskedContentOptions = [
{ value: "edges", label: "Edge map" },
] satisfies readonly BottomControlSelectOption<GenerateSettings["inpaint"]["maskedContent"]>[];
const inpaintProfileOptions = [
{ value: "remove", label: "Remove object" },
{ value: "replace", label: "Replace object" },
{ value: "repair", label: "Repair detail" },
{ value: "material", label: "Change material" },
{ value: "reshape", label: "Change shape" },
{ value: "custom", label: "Custom" },
] satisfies readonly BottomControlSelectOption<GenerateSettings["inpaint"]["profile"]>[];
const structureControlOptions = [
{ value: "none", label: "None" },
{ value: "canny", label: "Canny edges" },
{ value: "depth", label: "Depth" },
{ value: "pose", label: "Pose" },
] satisfies readonly BottomControlSelectOption<GenerateSettings["inpaint"]["structureControl"]>[];
export type GenerateControlsProps = {
settings: GenerateSettings;
document: ImageDocument;
selection: SelectionState;
resources: GenerationResourcesState;
dispatch: AppStore["dispatch"];
};
export function GenerateControls({ settings, resources, dispatch }: GenerateControlsProps) {
export function GenerateControls({ settings, document, selection, resources, dispatch }: GenerateControlsProps) {
const comfyOptions = resources.options;
const [advancedOpen, setAdvancedOpen] = useState(false);
const [outpaintOpen, setOutpaintOpen] = useState(false);
@@ -60,6 +76,7 @@ export function GenerateControls({ settings, resources, dispatch }: GenerateCont
const samplerOptions = resolveGenerationStringOptions(comfyOptions?.samplers, settings.sampler);
const schedulerOptions = resolveGenerationStringOptions(comfyOptions?.schedulers, settings.scheduler);
const modeOptions = resolveGenerationModeOptions(settings, comfyOptions, modes);
const availableStructureControls = structureControlOptions.filter((option) => option.value === "none" || option.value === settings.inpaint.structureControl || comfyOptions?.structureControls?.includes(option.value));
useEffect(() => {
if (!sizeOpen) return;
@@ -72,11 +89,11 @@ export function GenerateControls({ settings, resources, dispatch }: GenerateCont
return (
<div className="grid gap-5 pb-2 tabular-nums">
{resources.error ? <p className="rounded-full bg-red-500/10 px-3 py-2 text-xs text-red-200">{resources.error}</p> : null}
{resources.error ? <p className="rounded-md border border-red-400/15 bg-red-500/10 px-2.5 py-2 text-xs text-red-200">{resources.error}</p> : null}
<section className={panelSectionClass()}>
<div className="px-1">
<h2 className="text-base font-semibold text-white/90">Edit with AI</h2>
<h2 className="text-sm font-semibold text-white/90">Edit with AI</h2>
<p className="mt-1 text-xs leading-5 text-white/45">Choose an outcome. Image Studio configures the matching generation workflow.</p>
</div>
<div className="grid grid-cols-2 gap-2">
@@ -84,18 +101,14 @@ export function GenerateControls({ settings, resources, dispatch }: GenerateCont
<button
key={intent.value}
type="button"
aria-pressed={settings.mode === intent.mode}
className={`rounded-[1.25rem] px-3 py-3 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30 ${settings.mode === intent.mode ? "bg-white text-black" : "bg-white/[0.05] text-white/75 hover:bg-white/10"}`}
aria-pressed={isIntentActive(intent.value, intent.mode, settings)}
className={`border-l-2 px-2.5 py-1.5 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/50 ${isIntentActive(intent.value, intent.mode, settings) ? "border-sky-300 bg-sky-300/10 text-sky-100" : "border-transparent text-white/60 hover:border-white/20 hover:text-white"}`}
onClick={() => dispatch(commandIds.toolChooseGenerateIntent, { intent: intent.value })}
>
<span className="block text-sm font-semibold">{intent.label}</span>
<span className={`mt-1 block text-xs leading-4 ${settings.mode === intent.mode ? "text-black/55" : "text-white/40"}`}>{intent.description}</span>
<span className={`mt-1 block text-xs leading-4 ${isIntentActive(intent.value, intent.mode, settings) ? "text-sky-100/70" : "text-white/40"}`}>{intent.description}</span>
</button>
))}
<button type="button" disabled className="rounded-[1.25rem] bg-white/[0.025] px-3 py-3 text-left text-white/30" title="Dedicated object removal is not supported by the current backend. Use Replace and describe the desired background.">
<span className="block text-sm font-semibold">Remove</span>
<span className="mt-1 block text-xs leading-4">Use Replace for now; describe what should fill the area.</span>
</button>
</div>
</section>
@@ -134,6 +147,9 @@ export function GenerateControls({ settings, resources, dispatch }: GenerateCont
<span className="w-10 text-right text-sm text-white">{Math.round(settings.strength)}</span>
</div>
) : null}
<PanelNumber label="Results" aria-label="Generation result count" value={settings.batchSize} onValueChange={(batchSize) => dispatch(commandIds.toolSetGenerateSettings, { batchSize })} />
<button type="button" className={compactRowButtonClass()} aria-pressed={settings.refinePass} onClick={() => dispatch(commandIds.toolSetGenerateSettings, { refinePass: !settings.refinePass })}><span className={panelLabelClass()}>Detail pass</span><span className="min-w-0 flex-1 text-right text-white">{settings.refinePass ? `${Math.round(settings.refineStrength)}%` : "Off"}</span></button>
{settings.refinePass ? <PanelNumber label="Detail strength" aria-label="Generation detail pass strength" value={settings.refineStrength} onValueChange={(refineStrength) => dispatch(commandIds.toolSetGenerateSettings, { refineStrength })} /> : null}
</section>
<section className={panelSectionClass()}>
@@ -154,7 +170,7 @@ export function GenerateControls({ settings, resources, dispatch }: GenerateCont
<PanelSelect label="VAE" value={settings.vae} options={supportOptions.vaes} ariaLabel="Generate VAE" onValueChange={(vae) => dispatch(commandIds.toolSetGenerateSettings, { vae })} />
</>
) : null}
<PanelNumber label="Seed" aria-label="Generate seed" min={-1} max={Number.MAX_SAFE_INTEGER} value={settings.seed} onValueChange={(seed) => dispatch(commandIds.toolSetGenerateSettings, { seed })} />
<PanelNumber label="Seed" aria-label="Generate seed" value={settings.seed} onValueChange={(seed) => dispatch(commandIds.toolSetGenerateSettings, { seed })} />
<PanelSelect label="Sampler" value={settings.sampler} options={samplerOptions} ariaLabel="Generate sampler" onValueChange={(sampler) => dispatch(commandIds.toolSetGenerateSettings, { sampler })} />
<PanelSelect label="Scheduler" value={settings.scheduler} options={schedulerOptions} ariaLabel="Generate scheduler" onValueChange={(scheduler) => dispatch(commandIds.toolSetGenerateSettings, { scheduler })} />
<div className="grid grid-cols-2 gap-2">
@@ -184,6 +200,7 @@ export function GenerateControls({ settings, resources, dispatch }: GenerateCont
</section> : null}
{settings.mode === "inpaint" ? <section className={panelSectionClass()}>
<InpaintRegionPanel document={document} selection={selection} settings={settings} dispatch={dispatch} />
<button type="button" className={sectionToggleClass()} aria-expanded={inpaintOpen} aria-controls="generate-inpaint-controls" onClick={() => setInpaintOpen((open) => !open)}>
<span>
<span className="block text-sm font-semibold text-white/85">Inpaint</span>
@@ -192,13 +209,7 @@ export function GenerateControls({ settings, resources, dispatch }: GenerateCont
{inpaintOpen ? <CaretUp size={18} weight="bold" /> : <CaretDown size={18} weight="bold" />}
</button>
<div id="generate-inpaint-controls" className={`grid gap-2 overflow-hidden transition-all duration-200 ${inpaintOpen ? "max-h-[38rem] pt-2 opacity-100" : "max-h-0 opacity-0"}`}>
<PanelSelect
label="Polarity"
value={settings.inpaint.maskPolarity}
options={inpaintPolarityOptions}
ariaLabel="Inpaint mask polarity"
onValueChange={(maskPolarity) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { ...settings.inpaint, maskPolarity } })}
/>
<PanelSelect label="Intent" value={settings.inpaint.profile} options={inpaintProfileOptions} ariaLabel="Inpaint intent" onValueChange={(profile) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { profile } })} />
<PanelSelect
label="Content"
value={settings.inpaint.maskedContent}
@@ -206,17 +217,27 @@ export function GenerateControls({ settings, resources, dispatch }: GenerateCont
ariaLabel="Inpaint masked content"
onValueChange={(maskedContent) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { ...settings.inpaint, maskedContent } })}
/>
<PanelSelect label="Structure" value={settings.inpaint.structureControl} options={availableStructureControls} ariaLabel="Inpaint structure control" onValueChange={(structureControl) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { structureControl, profile: "custom" } })} />
{settings.inpaint.structureControl !== "none" ? (
<div className="grid grid-cols-2 gap-2">
<PanelNumber label="Control" aria-label="Structure control strength percent" value={Math.round(settings.inpaint.controlStrength * 100)} onValueChange={(controlStrength) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { controlStrength: controlStrength / 100, profile: "custom" } })} />
<PanelSelect label="Control model" value={settings.inpaint.controlModel} options={resolveGenerationStringOptions(comfyOptions?.controlModels, settings.inpaint.controlModel)} ariaLabel="Inpaint control model" onValueChange={(controlModel) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { controlModel } })} />
</div>
) : null}
<button type="button" className={compactRowButtonClass()} aria-pressed={settings.inpaint.maskedAreaOnly} onClick={() => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { ...settings.inpaint, maskedAreaOnly: !settings.inpaint.maskedAreaOnly } })}>
<span className={panelLabelClass()}>Frame</span>
<span className="min-w-0 flex-1 text-right text-white">{settings.inpaint.maskedAreaOnly ? "Mask crop" : "Full layer"}</span>
</button>
<button type="button" className={compactRowButtonClass()} aria-pressed={settings.inpaint.colorMatch} onClick={() => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { colorMatch: !settings.inpaint.colorMatch, profile: "custom" } })}>
<span className={panelLabelClass()}>Seam color</span><span className="min-w-0 flex-1 text-right text-white">{settings.inpaint.colorMatch ? "Match boundary" : "Preserve result"}</span>
</button>
<div className="grid grid-cols-2 gap-2">
<PanelNumber label="Pad" aria-label="Inpaint crop padding" value={settings.inpaint.cropPadding} max={2048} onValueChange={(cropPadding) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { ...settings.inpaint, cropPadding } })} />
<PanelNumber label="Grow" aria-label="Inpaint backend grow mask" value={settings.inpaint.growMaskBy} max={256} onValueChange={(growMaskBy) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { ...settings.inpaint, growMaskBy } })} />
<PanelNumber label="Expand" aria-label="Inpaint mask expand" value={settings.inpaint.maskExpand} min={-256} max={256} onValueChange={(maskExpand) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { ...settings.inpaint, maskExpand } })} />
<PanelNumber label="Feather" aria-label="Inpaint mask feather" value={settings.inpaint.maskFeather} max={256} onValueChange={(maskFeather) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { ...settings.inpaint, maskFeather } })} />
<PanelNumber label="Blur" aria-label="Inpaint mask blur" value={settings.inpaint.maskBlur} max={256} onValueChange={(maskBlur) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { ...settings.inpaint, maskBlur } })} />
<PanelNumber label="Clean" aria-label="Inpaint mask despeckle" value={settings.inpaint.maskDespeckle} max={64} onValueChange={(maskDespeckle) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { ...settings.inpaint, maskDespeckle } })} />
<PanelNumber label="Pad" aria-label="Inpaint crop padding" value={settings.inpaint.cropPadding} onValueChange={(cropPadding) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { ...settings.inpaint, cropPadding } })} />
<PanelNumber label="Grow" aria-label="Inpaint backend grow mask" value={settings.inpaint.growMaskBy} onValueChange={(growMaskBy) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { ...settings.inpaint, growMaskBy } })} />
<PanelNumber label="Expand" aria-label="Inpaint mask expand" value={settings.inpaint.maskExpand} onValueChange={(maskExpand) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { ...settings.inpaint, maskExpand } })} />
<PanelNumber label="Feather" aria-label="Inpaint mask feather" value={settings.inpaint.maskFeather} onValueChange={(maskFeather) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { ...settings.inpaint, maskFeather } })} />
<PanelNumber label="Blur" aria-label="Inpaint mask blur" value={settings.inpaint.maskBlur} onValueChange={(maskBlur) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { ...settings.inpaint, maskBlur } })} />
<PanelNumber label="Clean" aria-label="Inpaint mask despeckle" value={settings.inpaint.maskDespeckle} onValueChange={(maskDespeckle) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { ...settings.inpaint, maskDespeckle } })} />
</div>
</div>
</section> : null}
@@ -227,35 +248,43 @@ export function GenerateControls({ settings, resources, dispatch }: GenerateCont
const intentOptions: ReadonlyArray<{ value: GenerateIntent; mode: GenerateMode; label: string; description: string }> = [
{ value: "create", mode: "text-to-image", label: "Create", description: "Make a new image from your prompt." },
{ value: "replace", mode: "inpaint", label: "Replace", description: "Regenerate the masked part of one layer." },
{ value: "remove", mode: "inpaint", label: "Remove", description: "Erase an object and reconstruct its background." },
{ value: "extend", mode: "outpaint", label: "Extend", description: "Grow one selected image beyond its edges." },
{ value: "variations", mode: "image-to-image", label: "Variations", description: "Explore alternatives based on one image." },
];
function isIntentActive(intent: GenerateIntent, mode: GenerateMode, settings: GenerateSettings) {
if (settings.mode !== mode) return false;
if (intent === "remove") return settings.inpaint.profile === "remove";
if (intent === "replace") return settings.inpaint.profile !== "remove";
return true;
}
function SectionTitle({ title }: { title: string }) {
return <div className="px-1 text-xs font-semibold uppercase tracking-[0.18em] text-white/35">{title}</div>;
}
function panelSectionClass() {
return "grid gap-3 rounded-[1.75rem] bg-white/[0.035] p-3 ring-1 ring-white/[0.04]";
return "grid gap-2 border-b border-white/[0.07] px-0.5 pb-3 last:border-b-0 last:pb-0";
}
function sectionToggleClass() {
return "flex w-full items-center justify-between gap-3 rounded-[1.25rem] px-2 py-1 text-left transition hover:bg-white/[0.04] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30";
return "flex w-full items-center justify-between gap-2 rounded-md px-1 py-0.5 text-left transition hover:bg-white/[0.04] focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/50";
}
function PanelSelect<TValue extends string>({ label, value, options, ariaLabel, onValueChange }: { label: string; value: TValue; options: readonly BottomControlSelectOption<TValue>[]; ariaLabel: string; onValueChange: (value: TValue) => void }) {
return (
<div className="rounded-[1.25rem] bg-white/[0.04] p-1">
<div className="p-0.5">
<BottomControlSelectMenu label={label} value={value} options={options} aria-label={ariaLabel} placement="inline" onValueChange={onValueChange} />
</div>
);
}
function PanelNumber({ label, value, onValueChange, min = 0, max = 4096, ...props }: { label: string; "aria-label": string; value: number; min?: number; max?: number; onValueChange: (value: number) => void }) {
function PanelNumber({ label, value, onValueChange, ...props }: { label: string; "aria-label": string; value: number; onValueChange: (value: number) => void }) {
return (
<label className={panelRowClass()}>
<span className={panelLabelClass()}>{label}</span>
<NumberInput {...props} min={min} max={max} value={value} onValueChange={onValueChange} />
<NumberInput {...props} value={value} onValueChange={onValueChange} />
</label>
);
}
@@ -269,14 +298,14 @@ function SizeControl({ refRoot, open, setOpen, settings, dispatch }: { refRoot:
<CaretDown size={16} weight="bold" />
</button>
{open ? (
<div className="absolute right-0 top-full z-30 mt-2 grid w-full gap-3 rounded-[1.5rem] bg-slate-950/90 p-3 text-white shadow-2xl ring-1 ring-white/10 backdrop-blur-xl">
<div className="app-surface absolute right-0 top-full z-30 mt-1.5 grid w-full gap-2 rounded-lg p-2.5 text-white shadow-2xl">
<div className="grid grid-cols-2 gap-2">
<PanelNumber label="W" aria-label="Generate width" value={settings.width} onValueChange={(width) => dispatch(commandIds.toolSetGenerateSettings, { width })} />
<PanelNumber label="H" aria-label="Generate height" value={settings.height} onValueChange={(height) => dispatch(commandIds.toolSetGenerateSettings, { height })} />
</div>
<div className="grid grid-cols-5 gap-2">
{sizePresets.map((preset) => (
<button key={preset.label} type="button" className="rounded-full bg-white/5 px-2 py-2 text-xs text-white/75 transition hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30" onClick={() => dispatch(commandIds.toolSetGenerateSettings, { width: preset.w, height: preset.h })}>
<button key={preset.label} type="button" className="rounded-md bg-white/[0.04] px-2 py-1.5 text-xs text-white/65 transition hover:bg-white/[0.08] hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/50" onClick={() => dispatch(commandIds.toolSetGenerateSettings, { width: preset.w, height: preset.h })}>
{preset.label}
</button>
))}
@@ -287,12 +316,19 @@ function SizeControl({ refRoot, open, setOpen, settings, dispatch }: { refRoot:
);
}
function NumberInput({ value, onValueChange, min = 0, max = 4096, ...props }: { "aria-label": string; value: number; min?: number; max?: number; onValueChange: (value: number) => void }) {
return <input {...props} type="number" min={min} max={max} step={1} value={Math.round(value)} className="h-9 w-16 rounded-full bg-white/5 px-2 text-right text-sm text-white outline-none transition hover:bg-white/10 focus:bg-white/10 focus:ring-2 focus:ring-white/30" onChange={(event) => onValueChange(Number(event.currentTarget.value))} />;
function NumberInput({ value, onValueChange, ...props }: { "aria-label": string; value: number; onValueChange: (value: number) => void }) {
const [draft, setDraft] = useState(String(Math.round(value)));
useEffect(() => setDraft(String(Math.round(value))), [value]);
const commit = () => {
const next = Number(draft.trim());
if (draft.trim() !== "" && Number.isFinite(next)) onValueChange(next);
else setDraft(String(Math.round(value)));
};
return <input {...props} type="text" inputMode="numeric" value={draft} className="h-7 w-12 rounded bg-transparent px-1.5 text-right font-mono text-xs text-white/90 outline-none transition hover:bg-white/[0.04] focus:bg-white/[0.06] focus:ring-1 focus:ring-sky-300/50" onFocus={(event) => event.currentTarget.select()} onChange={(event) => setDraft(event.currentTarget.value)} onBlur={commit} onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Enter") event.currentTarget.blur(); if (event.key === "Escape") { setDraft(String(Math.round(value))); event.currentTarget.blur(); } }} />;
}
function panelRowClass() {
return "flex min-h-11 items-center justify-between gap-3 rounded-full bg-white/[0.04] px-4";
return "flex min-h-9 items-center justify-between gap-2 border-b border-white/[0.045] px-1 last:border-b-0";
}
function compactRowButtonClass() {
@@ -304,5 +340,5 @@ function panelLabelClass() {
}
function panelTextAreaClass(extra = "") {
return `${extra} resize-none rounded-[1.25rem] bg-white/5 px-4 py-3 text-sm text-white outline-none transition placeholder:text-white/25 focus:bg-white/[0.07] focus:ring-2 focus:ring-white/30`;
return `${extra} resize-none rounded-lg border border-white/[0.05] bg-white/[0.035] px-3 py-2 text-xs text-white outline-none transition placeholder:text-white/25 focus:bg-white/[0.06] focus:ring-1 focus:ring-sky-300/50`;
}

View File

@@ -19,16 +19,19 @@ export function MagicWandControls({ settings, dispatch }: { settings: MagicWandS
<BottomControlDivider />
<Slider label="Clean" value={settings.despeckle} min={0} max={20} onChange={(despeckle) => dispatch(commandIds.toolSetMagicWandSettings, { despeckle })} />
<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"}`} aria-label="Contiguous selection" aria-pressed={settings.contiguous} title="Select only connected pixels" onClick={() => dispatch(commandIds.toolSetMagicWandSettings, { contiguous: !settings.contiguous })}>Contiguous</button>
<button type="button" className={`rounded-md px-3 py-1.5 text-xs font-medium transition ${settings.contiguous ? "bg-sky-300 text-slate-950" : "bg-white/[0.06] text-white/70"}`} aria-label="Contiguous selection" aria-pressed={settings.contiguous} title="Select only connected pixels" onClick={() => dispatch(commandIds.toolSetMagicWandSettings, { contiguous: !settings.contiguous })}>Contiguous</button>
<BottomControlDivider />
{(["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"}`} aria-pressed={settings.mode === mode} onClick={() => dispatch(commandIds.toolSetMagicWandSettings, { mode })}>{mode}</button>
<button key={mode} type="button" className={`rounded-md px-3 py-1.5 text-xs font-medium capitalize transition ${settings.mode === mode ? "bg-sky-300 text-slate-950" : "bg-white/[0.06] text-white/70"}`} 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="flex items-center gap-1 px-1 text-[0.65rem] text-white/45" aria-label="Shift click adds; Alt click subtracts" title="Shift-click adds · Alt-click subtracts">
<kbd className="rounded border border-white/10 px-1 py-0.5 font-sans text-white/65">⇧ +</kbd>
<kbd className="rounded border border-white/10 px-1 py-0.5 font-sans text-white/65">⌥ −</kbd>
</span>
</div>
);
}
function Slider({ label, value, min, max, onChange }: { label: string; value: number; min: number; max: number; onChange: (value: number) => void }) {
return <label className={bottomControlFieldClass()}><span className={bottomControlLabelClass()}>{label}</span><BottomControlSlider min={min} max={max} value={value} className="w-32" aria-label={`Magic wand ${label}`} onValueChange={onChange} /><span className="w-8 text-right text-base text-white">{Math.round(value)}</span></label>;
return <label className={`${bottomControlFieldClass()} min-w-0`}><span className={bottomControlLabelClass()}>{label}</span><BottomControlSlider min={min} max={max} value={value} className="min-w-8 w-[clamp(2rem,7vw,5rem)] shrink" aria-label={`Magic wand ${label}`} onValueChange={onChange} /><span className="w-7 shrink-0 text-right text-xs font-medium text-white/85">{Math.round(value)}</span></label>;
}

View File

@@ -90,7 +90,7 @@ export function BottomControlSelectMenu<TValue extends string>({ value, options,
<button
ref={buttonRef}
type="button"
className={`inline-flex h-10 min-w-32 max-w-full items-center rounded-full text-base text-white/85 transition hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30 ${placement === "inline" ? "w-full px-4" : ""}`}
className={`inline-flex h-8 min-w-28 max-w-full items-center rounded-md text-xs text-white/75 transition hover:bg-white/[0.07] hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/50 ${placement === "inline" ? "w-full px-2.5" : ""}`}
aria-label={props["aria-label"]}
aria-haspopup="listbox"
aria-expanded={open}
@@ -112,7 +112,7 @@ export function BottomControlSelectMenu<TValue extends string>({ value, options,
value={value}
onValueChange={onValueChange}
setOpen={setOpen}
className="subtle-scrollbar mt-2 max-h-56 w-full overflow-auto rounded-[1.25rem] bg-white/[0.04] p-1 text-white ring-1 ring-white/10"
className="subtle-scrollbar mt-1.5 max-h-56 w-full overflow-auto border-t border-white/[0.07] pt-1 text-white"
aria-label={props["aria-label"]}
/>
) : open ? (
@@ -124,7 +124,7 @@ export function BottomControlSelectMenu<TValue extends string>({ value, options,
value={value}
onValueChange={onValueChange}
setOpen={setOpen}
className="subtle-scrollbar z-50 overflow-auto rounded-[1.5rem] bg-slate-950/90 p-1 text-white shadow-2xl ring-1 ring-white/10 backdrop-blur-xl"
className="app-surface subtle-scrollbar z-50 overflow-auto rounded-lg p-1 text-white shadow-2xl"
style={menuStyle}
aria-label={props["aria-label"]}
/>,
@@ -174,7 +174,7 @@ function SelectOptions<TValue extends string>({ menuRef, id, options, value, cla
<button
key={option.value}
type="button"
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-8 w-full items-center gap-2 rounded-md px-2.5 text-left text-xs transition ${selected ? "bg-sky-300 !text-slate-950" : "text-white/70 hover:bg-white/[0.07] hover:text-white"}`}
role="option"
aria-selected={selected}
tabIndex={selected ? 0 : -1}

View File

@@ -6,6 +6,7 @@ export type BottomControlSliderProps = {
max: number;
value: number;
step?: number;
disabled?: boolean;
className?: string;
onValueChange: (value: number) => void;
};

View File

@@ -138,14 +138,14 @@ export function TransformControls({ bounds, target, documentIndex, layerInfo, di
type CropDraft = Record<BoundsField, string>;
function CropEditor({ draft, setDraft, onApply, onCancel, onReset }: { draft: CropDraft; setDraft: Dispatch<SetStateAction<CropDraft>>; onApply: () => void; onCancel: () => void; onReset: () => void }) {
return <div className="flex items-center gap-2 rounded-2xl bg-black/25 px-3 py-2" aria-label="Crop source pixels">
return <div className="flex items-center gap-2 border-l border-white/[0.08] pl-3" aria-label="Crop source pixels">
{(["x", "y", "w", "h"] as const).map((field) => <label key={field} className={bottomControlFieldClass()}><span className={bottomControlLabelClass()}>{field.toUpperCase()}</span><input className={bottomControlInputClass()} inputMode="decimal" value={draft[field]} aria-label={`Crop ${field}`} onChange={(event) => setDraft((current) => ({ ...current, [field]: event.target.value }))} onKeyDown={(event) => event.stopPropagation()} /></label>)}
<button type="button" className={actionButtonClass()} onClick={onApply}>Apply</button><button type="button" className={actionButtonClass()} onClick={onCancel}>Cancel</button><button type="button" className={actionButtonClass()} onClick={onReset}>Reset</button>
</div>;
}
function ArtboardResizeEditor({ draft, setDraft, onApply, onCancel }: { draft: { w: string; h: string; scaleContents: boolean }; setDraft: Dispatch<SetStateAction<{ w: string; h: string; scaleContents: boolean }>>; onApply: () => void; onCancel: () => void }) {
return <div className="flex items-center gap-2 rounded-2xl bg-black/25 px-3 py-2" aria-label="Resize artboard canvas">
return <div className="flex items-center gap-2 border-l border-white/[0.08] pl-3" aria-label="Resize artboard canvas">
{(["w", "h"] as const).map((field) => <label key={field} className={bottomControlFieldClass()}><span className={bottomControlLabelClass()}>{field.toUpperCase()}</span><input className={bottomControlInputClass()} inputMode="decimal" value={draft[field]} aria-label={`Canvas ${field}`} onChange={(event) => setDraft((current) => ({ ...current, [field]: event.target.value }))} onKeyDown={(event) => event.stopPropagation()} /></label>)}
<label className="flex items-center gap-2 text-xs text-white/70"><input type="checkbox" checked={draft.scaleContents} onChange={(event) => setDraft((current) => ({ ...current, scaleContents: event.target.checked }))} />Scale contents</label>
<span className="max-w-40 text-[10px] text-white/45">Off changes canvas bounds only.</span>
@@ -182,7 +182,7 @@ function rotationDegrees(layerInfo?: IndexedLayerInfo) {
}
function actionButtonClass() {
return "inline-flex h-10 items-center gap-2 rounded-full px-3 text-xs font-semibold text-white/70 transition hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-35";
return "inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-xs font-semibold text-white/65 transition hover:bg-white/[0.07] hover:text-white disabled:pointer-events-none disabled:opacity-35";
}
function BoundsInput({

View File

@@ -1,9 +1,9 @@
export function bottomControlMenuClass() {
return "flex w-full items-center justify-center gap-4 tabular-nums";
return "flex max-w-full min-w-0 flex-nowrap items-center justify-center gap-2 whitespace-nowrap tabular-nums";
}
export function bottomControlFieldClass() {
return "flex items-center gap-3";
return "flex min-w-0 shrink items-center gap-2 whitespace-nowrap";
}
export function bottomControlDividerClass() {
@@ -11,19 +11,19 @@ export function bottomControlDividerClass() {
}
export function bottomControlLabelClass() {
return "text-sm text-white/60";
return "shrink-0 text-xs text-white/60";
}
export function bottomControlValueClass() {
return "min-w-16 text-center text-base text-white";
return "min-w-12 text-center text-xs font-medium text-white/85";
}
export function bottomControlInputClass() {
return "h-10 w-20 px-2 text-center text-base text-white outline-none transition placeholder:text-white/30 focus:text-white";
return "h-7 w-12 rounded bg-transparent px-1.5 text-right font-mono text-xs text-white/90 outline-none transition placeholder:text-white/25 hover:bg-white/[0.04] focus:bg-white/[0.06] focus:ring-1 focus:ring-sky-300/50";
}
export function bottomControlIconSlotClass() {
return "grid size-12 place-items-center rounded-full text-white/80";
return "grid size-8 place-items-center rounded-lg text-white/70";
}
export function bottomControlButtonClass() {

View File

@@ -0,0 +1,16 @@
import { describe, expect, test } from "bun:test";
import { canvasCursorClass } from "./cursor";
describe("canvas cursor", () => {
test("keeps the brush cursor while non-operation UI is open", () => {
expect(canvasCursorClass({ type: "tool", tool: "brush" }, false, false, true, false)).toBe("cursor-crosshair");
});
test("uses the default cursor while an operation suspends canvas tools", () => {
expect(canvasCursorClass({ type: "tool", tool: "brush" }, false, false, true, true)).toBe("cursor-default");
});
test("uses the live brush cursor for feathering", () => {
expect(canvasCursorClass({ type: "tool", tool: "feather" }, false, true, true, false)).toBe("cursor-none");
});
});

View File

@@ -1,12 +1,13 @@
import type { InteractionMode } from "@editor/tools";
import { isPanInteractionMode } from "@editor/tools";
export function canvasCursorClass(interactionMode: InteractionMode, isPanning: boolean, hasBrushPreview = false, canBrush = true) {
export function canvasCursorClass(interactionMode: InteractionMode, isPanning: boolean, hasBrushPreview = false, canBrush = true, operationOpen = false) {
if (operationOpen) return "cursor-default";
if (isPanning) return "cursor-grabbing";
if (isPanInteractionMode(interactionMode)) return "cursor-grab";
if (interactionMode.type === "tool" && (interactionMode.tool === "brush" || interactionMode.tool === "eraser")) {
if (interactionMode.type === "tool" && (interactionMode.tool === "brush" || interactionMode.tool === "eraser" || interactionMode.tool === "feather")) {
if (!canBrush) return "cursor-not-allowed";
return hasBrushPreview ? "cursor-none" : "cursor-crosshair";
}
if (interactionMode.type === "tool" && interactionMode.tool === "magicWand") return "cursor-crosshair";
if (interactionMode.type === "tool" && (interactionMode.tool === "magicWand" || interactionMode.tool === "semanticSelect" || interactionMode.tool === "maskLasso" || interactionMode.tool === "maskRectangle")) return "cursor-crosshair";
return "cursor-default";
}

View File

@@ -78,9 +78,20 @@ describe("canvas render frame selection", () => {
brushPreview: { position: { x: 10, y: 20 } },
},
};
const changedFeather = {
...state,
editor: {
...state.editor,
tools: {
...state.editor.tools,
feather: { ...state.editor.tools.feather, size: state.editor.tools.feather.size + 1 },
},
},
};
expect(canvasRenderFramesEqual(selectCanvasRenderFrame(state), selectCanvasRenderFrame(sameSelectionValues))).toBe(true);
expectFrameChanged(state, changedBrush);
expectFrameChanged(state, changedFeather);
expectFrameChanged(state, changedPreview);
});
@@ -137,7 +148,7 @@ const visualEditorChanges: Array<[string, (state: AppState) => AppState]> = [
...state,
editor: {
...state.editor,
maskEdit: { targetLayerId: "target", maskLayerId: "mask", viewMode: "overlay" },
maskEdit: { kind: "layerMask", targetLayerId: "target", maskLayerId: "mask", maskAssetId: "mask-asset", viewMode: "overlay" },
},
}),
],

View File

@@ -1,7 +1,7 @@
import type { Rect, Vec2D } from "@core/geometry";
import type { RenderFrame } from "@renderer/index";
import type { AppState, BrushPreviewState, BrushStrokePreviewState, EditorState, GenerationState, MaskEditState, SelectionState, ViewportState } from "@editor/state";
import type { BrushSettings, InteractionMode } from "@editor/tools";
import type { BrushSettings, FeatherSettings, InteractionMode } from "@editor/tools";
import type { TransformSession, TransformTarget } from "@editor/transform";
export function selectCanvasRenderFrame(state: AppState): RenderFrame {
@@ -23,6 +23,7 @@ function visualEditorStatesEqual(a: EditorState, b: EditorState): boolean {
maskEditStatesEqual(a.maskEdit, b.maskEdit) &&
brushPreviewStatesEqual(a.brushPreview, b.brushPreview) &&
brushStrokePreviewStatesEqual(a.brushStrokePreview, b.brushStrokePreview) &&
a.maskShapeSession === b.maskShapeSession &&
generationStatesEqual(a.generation, b.generation) &&
visualToolStatesEqual(a.tools, b.tools)
);
@@ -50,7 +51,7 @@ function transformTargetsEqual(a: TransformTarget, b: TransformTarget): boolean
function maskEditStatesEqual(a: MaskEditState | undefined, b: MaskEditState | undefined): boolean {
if (a === b) return true;
if (!a || !b) return false;
return a.targetLayerId === b.targetLayerId && a.maskLayerId === b.maskLayerId && a.viewMode === b.viewMode;
return a.kind === b.kind && a.targetLayerId === b.targetLayerId && a.maskAssetId === b.maskAssetId && a.maskLayerId === b.maskLayerId && a.inpaintRegionId === b.inpaintRegionId && a.viewMode === b.viewMode;
}
function brushPreviewStatesEqual(a: BrushPreviewState | undefined, b: BrushPreviewState | undefined): boolean {
@@ -62,7 +63,12 @@ function brushPreviewStatesEqual(a: BrushPreviewState | undefined, b: BrushPrevi
function brushStrokePreviewStatesEqual(a: BrushStrokePreviewState | undefined, b: BrushStrokePreviewState | undefined): boolean {
if (a === b) return true;
if (!a || !b) return false;
return a.layerId === b.layerId && a.assetId === b.assetId && a.source === b.source;
return a.layerId === b.layerId && a.assetId === b.assetId && a.source === b.source && a.pendingTargetLayerId === b.pendingTargetLayerId && sizesEqual(a.intrinsicSize, b.intrinsicSize);
}
function sizesEqual(a: { w: number; h: number } | undefined, b: { w: number; h: number } | undefined) {
if (a === b) return true;
return Boolean(a && b && a.w === b.w && a.h === b.h);
}
function generationStatesEqual(a: GenerationState, b: GenerationState): boolean {
@@ -70,7 +76,7 @@ function generationStatesEqual(a: GenerationState, b: GenerationState): boolean
}
function visualToolStatesEqual(a: EditorState["tools"], b: EditorState["tools"]): boolean {
return a.activeTool === b.activeTool && interactionModesEqual(a.interactionMode, b.interactionMode) && brushSettingsEqual(a.brush, b.brush);
return a.activeTool === b.activeTool && interactionModesEqual(a.interactionMode, b.interactionMode) && brushSettingsEqual(a.brush, b.brush) && featherSettingsEqual(a.feather, b.feather);
}
function interactionModesEqual(a: InteractionMode, b: InteractionMode): boolean {
@@ -80,7 +86,11 @@ function interactionModesEqual(a: InteractionMode, b: InteractionMode): boolean
}
function brushSettingsEqual(a: BrushSettings, b: BrushSettings): boolean {
return a.color === b.color && a.size === b.size && a.hardness === b.hardness;
return a.color === b.color && a.size === b.size && a.hardness === b.hardness && a.opacity === b.opacity && a.flow === b.flow && a.smoothing === b.smoothing && a.pressureSize === b.pressureSize;
}
function featherSettingsEqual(a: FeatherSettings, b: FeatherSettings): boolean {
return a.size === b.size && a.radius === b.radius && a.strength === b.strength && a.smoothing === b.smoothing && a.pressureSize === b.pressureSize;
}
function vec2Equal(a: Vec2D, b: Vec2D): boolean {

View File

@@ -1,6 +1,7 @@
import { useEffect, useRef, type RefObject } from "react";
import { commandIds } from "@commands/ids";
import type { AppStore } from "@editor/store";
import { isOperationWorkspacePanel } from "@editor/state";
import { isPanInteractionMode } from "@editor/tools";
import type { GlobalKeybindConsumer, GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index";
import {
@@ -14,6 +15,8 @@ import {
} from "@input/index";
import { beginBrushSession, canPreviewBrush, cancelBrushSession, commitBrushSession, updateBrushSession, type BrushSession } from "@operations/paint/brush";
import { applyMagicWandAt } from "@operations/masks/magic-wand";
import { commitInpaintLasso } from "@operations/masks/lasso";
import { applySemanticSelectionAt } from "@operations/masks/semantic-select";
export type CanvasInputOptions = {
globalKeybindConsumer: GlobalKeybindConsumer;
@@ -59,6 +62,10 @@ export function useCanvasInput(
}
const state = store.getState();
if (isOperationWorkspacePanel(state.editor.workspace.panel)) {
clearBrushPreview();
return;
}
if (!canPreviewBrush(state.document, state.editor)) {
clearBrushPreview();
return;
@@ -117,7 +124,13 @@ export function useCanvasInput(
const state = store.getState();
const documentPoint = viewportPointToDocumentPoint(inputEvent.position, state.editor.viewport);
const brush = (inputEvent.buttons & 1) === 1 && !isPanInteractionMode(state.editor.tools.interactionMode)
if (!isOperationWorkspacePanel(state.editor.workspace.panel) && (state.editor.tools.activeTool === "maskLasso" || state.editor.tools.activeTool === "maskRectangle") && state.editor.maskEdit?.kind === "inpaintRegion" && (inputEvent.buttons & 1) === 1) {
store.dispatch(commandIds.toolBeginMaskShape, { point: documentPoint, mode: inputEvent.shiftKey ? "add" : inputEvent.altKey ? "subtract" : "replace" });
canvas.setPointerCapture(event.pointerId);
event.preventDefault();
return;
}
const brush = !isOperationWorkspacePanel(state.editor.workspace.panel) && (inputEvent.buttons & 1) === 1 && !isPanInteractionMode(state.editor.tools.interactionMode)
? beginBrushSession(state.document, state.editor, documentPoint)
: undefined;
if (brush) {
@@ -129,11 +142,16 @@ export function useCanvasInput(
return;
}
if (state.editor.tools.activeTool === "magicWand") {
if (!isOperationWorkspacePanel(state.editor.workspace.panel) && state.editor.tools.activeTool === "magicWand") {
void applyMagicWandAt(store, documentPoint, inputEvent.shiftKey ? "add" : inputEvent.altKey ? "subtract" : undefined);
event.preventDefault();
return;
}
if (!isOperationWorkspacePanel(state.editor.workspace.panel) && state.editor.tools.activeTool === "semanticSelect") {
void applySemanticSelectionAt(store, documentPoint, inputEvent.shiftKey ? "add" : inputEvent.altKey ? "subtract" : "replace");
event.preventDefault();
return;
}
const currentState = store.getState();
const selectionToolActive = currentState.editor.tools.activeTool === "select";
@@ -148,6 +166,12 @@ export function useCanvasInput(
const handlePointerMove = (event: PointerEvent) => {
const inputEvent = pointerInputEventFromPointerEvent(event);
if (store.getState().editor.maskShapeSession) {
const point = viewportPointToDocumentPoint(inputEvent.position, store.getState().editor.viewport);
store.dispatch(commandIds.toolAppendMaskShape, { point });
event.preventDefault();
return;
}
if (brushSession.current) {
if ((inputEvent.buttons & 1) !== 1 || isPanInteractionMode(store.getState().editor.tools.interactionMode)) {
commitActiveBrushSession(inputEvent.position);
@@ -157,8 +181,14 @@ export function useCanvasInput(
const point = viewportPointToDocumentPoint(inputEvent.position, store.getState().editor.viewport);
store.dispatch(commandIds.toolSetBrushPreview, { position: point });
const settings = store.getState().editor.tools.brush;
brushSession.current = updateBrushSession({ store, session: brushSession.current, point, color: settings.color, size: settings.size, hardness: settings.hardness });
const tools = store.getState().editor.tools;
if (brushSession.current.mode === "feather") {
const settings = tools.feather;
brushSession.current = updateBrushSession({ store, session: brushSession.current, point, color: "#ffffff", size: settings.size, hardness: 0, opacity: 100, flow: 100, smoothing: settings.smoothing, pressure: inputEvent.pressure ?? 1, pressureSize: settings.pressureSize, featherRadius: settings.radius, featherStrength: settings.strength });
} else {
const settings = tools.brush;
brushSession.current = updateBrushSession({ store, session: brushSession.current, point, color: settings.color, size: settings.size, hardness: settings.hardness, opacity: settings.opacity, flow: settings.flow, smoothing: settings.smoothing, pressure: inputEvent.pressure ?? 1, pressureSize: settings.pressureSize });
}
event.preventDefault();
return;
}
@@ -182,6 +212,13 @@ export function useCanvasInput(
const handlePointerUp = (event: PointerEvent) => {
const inputEvent = pointerInputEventFromPointerEvent(event);
if (store.getState().editor.maskShapeSession) {
const point = viewportPointToDocumentPoint(inputEvent.position, store.getState().editor.viewport);
store.dispatch(commandIds.toolAppendMaskShape, { point });
void commitInpaintLasso(store);
event.preventDefault();
return;
}
if (brushSession.current) {
commitActiveBrushSession(inputEvent.position);
event.preventDefault();

View File

@@ -33,54 +33,62 @@
}
@layer components {
.app-surface {
background: rgb(13 17 23 / 0.88);
border: 1px solid rgb(255 255 255 / 0.075);
box-shadow: 0 12px 32px rgb(0 0 0 / 0.28), inset 0 1px rgb(255 255 255 / 0.025);
backdrop-filter: blur(18px) saturate(120%);
}
.bottom-control-slider {
@apply h-8 cursor-pointer appearance-none bg-transparent outline-none;
@apply h-6 cursor-pointer appearance-none bg-transparent outline-none;
}
.bottom-control-slider::-webkit-slider-runnable-track {
height: 0.25rem;
border-radius: 0;
background: linear-gradient(to right, rgb(255 255 255 / 0.9) var(--slider-progress), rgb(255 255 255 / 0.22) var(--slider-progress));
height: 0.1875rem;
border-radius: 9999px;
background: linear-gradient(to right, rgb(125 211 252 / 0.9) var(--slider-progress), rgb(255 255 255 / 0.12) var(--slider-progress));
}
.bottom-control-slider::-webkit-slider-thumb {
width: 1.25rem;
height: 1.25rem;
margin-top: -0.5rem;
width: 0.75rem;
height: 0.75rem;
margin-top: -0.28125rem;
appearance: none;
border: 1px solid rgb(255 255 255 / 0.9);
border: 2px solid rgb(125 211 252);
border-radius: 9999px;
background: rgb(255 255 255);
background: rgb(15 23 42);
box-shadow: 0 0 0 2px rgb(15 23 42 / 0.8);
}
.bottom-control-slider::-moz-range-track {
height: 0.25rem;
border-radius: 0;
background: rgb(255 255 255 / 0.22);
height: 0.1875rem;
border-radius: 9999px;
background: rgb(255 255 255 / 0.12);
}
.bottom-control-slider::-moz-range-progress {
height: 0.25rem;
border-radius: 0;
background: rgb(255 255 255 / 0.9);
height: 0.1875rem;
border-radius: 9999px;
background: rgb(125 211 252 / 0.9);
}
.bottom-control-slider::-moz-range-thumb {
width: 1.25rem;
height: 1.25rem;
border: 1px solid rgb(255 255 255 / 0.9);
width: 0.75rem;
height: 0.75rem;
border: 2px solid rgb(125 211 252);
border-radius: 9999px;
background: rgb(255 255 255);
background: rgb(15 23 42);
}
.bottom-control-slider:focus-visible::-webkit-slider-thumb {
outline: 1px solid rgb(255 255 255);
outline-offset: 2px;
outline: 2px solid rgb(125 211 252 / 0.35);
outline-offset: 3px;
}
.bottom-control-slider:focus-visible::-moz-range-thumb {
outline: 1px solid rgb(255 255 255);
outline-offset: 2px;
outline: 2px solid rgb(125 211 252 / 0.35);
outline-offset: 3px;
}
.subtle-scrollbar {

View File

@@ -0,0 +1,25 @@
import { Star } from "@phosphor-icons/react";
import { commandIds } from "@commands/ids";
import type { GenerationState } from "@editor/state";
import type { AppStore } from "@editor/store";
export function CandidateReviewPanel({ generation, dispatch }: { generation: GenerationState; dispatch: AppStore["dispatch"] }) {
if (generation.candidates.length === 0) return null;
const selectedId = generation.selectedCandidateId ?? generation.candidates[0]?.id;
return (
<section className="grid gap-2 border-b border-white/[0.07] pb-3">
<div className="flex items-center justify-between px-1"><strong className="text-sm text-white/85">Results</strong><span className="text-xs text-white/35">{generation.candidates.length} candidates</span></div>
<div className="grid grid-cols-2 gap-2">
{generation.candidates.map((candidate) => (
<div key={candidate.id} className={`group relative overflow-hidden rounded-lg border ${candidate.id === selectedId ? "border-sky-300" : "border-white/10"}`}>
<button type="button" className="block w-full" onClick={() => dispatch(commandIds.generationSelectCandidate, { candidateId: candidate.id })}>
<img src={candidate.source} alt={`Candidate seed ${candidate.seed}`} className="aspect-square w-full bg-black/25 object-cover" />
<span className="flex items-center justify-between px-2 py-1 text-[0.65rem] text-white/45"><span>Seed {candidate.seed}</span><span>{candidate.settings.inpaint.profile}</span></span>
</button>
<button type="button" aria-label={candidate.favorite ? "Remove favorite" : "Favorite candidate"} aria-pressed={candidate.favorite} className={`absolute right-1.5 top-1.5 rounded-md bg-black/65 p-1.5 ${candidate.favorite ? "text-amber-300" : "text-white/60 opacity-0 group-hover:opacity-100"}`} onClick={() => dispatch(commandIds.generationToggleCandidateFavorite, { candidateId: candidate.id })}><Star size={16} weight={candidate.favorite ? "fill" : "regular"} /></button>
</div>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,86 @@
import { useEffect, useMemo, useState } from "react";
import { commandIds } from "@commands/ids";
import type { ImageDocument } from "@core/document";
import type { SelectionState } from "@editor/state";
import type { AppStore } from "@editor/store";
import type { GenerateSettings } from "@editor/tools";
import { runInpaintRegionOperation } from "@operations/masks/rasterActions";
import { createNormalizedMaskSource } from "@platform/browser/maskRaster";
type ProcessedMasks = { edit: string; noise: string; blend: string; coverage: number; bounds?: { x: number; y: number; w: number; h: number } };
export function InpaintRegionPanel({ document, selection, settings, dispatch }: { document: ImageDocument; selection: SelectionState; settings: GenerateSettings; dispatch: AppStore["dispatch"] }) {
const targetLayerId = selection.layerIds.length === 1 ? selection.layerIds[0] : undefined;
const region = document.inpaintRegions.find((candidate) => candidate.targetLayerId === targetLayerId && candidate.enabled);
const asset = region ? document.assets.find((candidate) => candidate.id === region.maskAssetId) : undefined;
const [processed, setProcessed] = useState<ProcessedMasks>();
const [busy, setBusy] = useState(false);
const processingKey = useMemo(() => asset ? [asset.source, settings.inpaint.maskExpand, settings.inpaint.maskFeather, settings.inpaint.maskBlur, settings.inpaint.maskDespeckle].join(":") : "", [asset, settings.inpaint]);
useEffect(() => {
if (!asset) {
setProcessed(undefined);
return;
}
let cancelled = false;
const width = Math.max(1, Math.round(asset.intrinsicSize.w));
const height = Math.max(1, Math.round(asset.intrinsicSize.h));
void Promise.all([
createNormalizedMaskSource(asset.source, width, height, { polarity: "revealed", despeckle: settings.inpaint.maskDespeckle }),
createNormalizedMaskSource(asset.source, width, height, { polarity: "revealed", expand: settings.inpaint.maskExpand, blur: settings.inpaint.maskBlur, despeckle: settings.inpaint.maskDespeckle }),
createNormalizedMaskSource(asset.source, width, height, { polarity: "revealed", feather: settings.inpaint.maskFeather, despeckle: settings.inpaint.maskDespeckle }),
]).then(([edit, noise, blend]) => {
if (cancelled) return;
const active = edit.values.reduce((sum, value) => sum + value / 255, 0);
setProcessed({ edit: edit.source, noise: noise.source, blend: blend.source, coverage: active / edit.values.length, bounds: edit.bounds });
}).catch(() => !cancelled && setProcessed(undefined));
return () => { cancelled = true; };
}, [processingKey, asset, settings.inpaint.maskDespeckle, settings.inpaint.maskExpand, settings.inpaint.maskBlur, settings.inpaint.maskFeather]);
if (!targetLayerId) return <p className="rounded-md bg-amber-300/[0.07] px-2.5 py-2 text-xs text-amber-100/70">Select one image or raster layer to create an AI edit region.</p>;
if (!region || !asset) return <p className="rounded-md bg-white/[0.04] px-2.5 py-2 text-xs text-white/55">No AI edit region yet. Use <strong className="text-white/80">Add region</strong> beside Generate, then paint where pixels should be replaced.</p>;
const operation = async (type: "invert" | "clear" | "fill") => {
setBusy(true);
try {
await runInpaintRegionOperation(region.id, asset, type === "invert" ? { type: "invert" } : { type: "fill", fill: type === "fill" ? "white" : "black" }, dispatch);
} finally {
setBusy(false);
}
};
return (
<div className="grid gap-2 rounded-lg border border-white/[0.07] bg-white/[0.025] p-2.5">
<div className="flex items-center justify-between gap-2">
<div><strong className="block text-xs text-white/80">AI edit region</strong><span className="text-[0.68rem] text-white/40">Painted pixels are replaced; unpainted pixels are protected.</span></div>
<button type="button" className="rounded-md bg-sky-300 px-2.5 py-1 text-xs font-semibold text-slate-950" onClick={() => dispatch(commandIds.toolEnterInpaintRegionEdit, { targetLayerId, regionId: region.id })}>Edit</button>
</div>
{processed ? (
<div className="grid grid-cols-3 gap-2 text-center text-[0.65rem] text-white/45">
<MaskPreview label="Edit" source={processed.edit} />
<MaskPreview label="Noise" source={processed.noise} />
<MaskPreview label="Blend" source={processed.blend} />
</div>
) : <span className="text-xs text-white/35">Preparing mask previews…</span>}
<div className="flex flex-wrap items-center gap-1.5 text-[0.68rem] text-white/45">
{processed ? <span className="mr-auto">Replace {Math.round(processed.coverage * 100)}%{processed.bounds ? ` · ${Math.round(processed.bounds.w)}×${Math.round(processed.bounds.h)} px` : " · empty"}</span> : <span className="mr-auto" />}
<RegionButton disabled={busy} label="Invert" onClick={() => void operation("invert")} />
<RegionButton disabled={busy} label="Color select" onClick={() => { dispatch(commandIds.toolEnterInpaintRegionEdit, { targetLayerId, regionId: region.id }); dispatch(commandIds.toolSetActive, { tool: "magicWand" }); }} />
<RegionButton disabled={busy} label="AI object" onClick={() => { dispatch(commandIds.toolEnterInpaintRegionEdit, { targetLayerId, regionId: region.id }); dispatch(commandIds.toolSetActive, { tool: "semanticSelect" }); }} />
<RegionButton disabled={busy} label="Lasso" onClick={() => { dispatch(commandIds.toolEnterInpaintRegionEdit, { targetLayerId, regionId: region.id }); dispatch(commandIds.toolSetActive, { tool: "maskLasso" }); }} />
<RegionButton disabled={busy} label="Rectangle" onClick={() => { dispatch(commandIds.toolEnterInpaintRegionEdit, { targetLayerId, regionId: region.id }); dispatch(commandIds.toolSetActive, { tool: "maskRectangle" }); }} />
<RegionButton disabled={busy} label="Clear" onClick={() => void operation("clear")} />
<RegionButton disabled={busy} label="Select all" onClick={() => void operation("fill")} />
<RegionButton disabled={busy} label="Remove" danger onClick={() => dispatch(commandIds.documentRemoveInpaintRegion, { regionId: region.id })} />
</div>
</div>
);
}
function MaskPreview({ label, source }: { label: string; source: string }) {
return <div className="grid gap-1"><img src={source} alt={`${label} mask preview`} className="h-14 w-full rounded-md bg-black/30 object-cover ring-1 ring-white/10" /><span>{label}</span></div>;
}
function RegionButton({ label, disabled, danger, onClick }: { label: string; disabled?: boolean; danger?: boolean; onClick: () => void }) {
return <button type="button" disabled={disabled} className={`rounded-md px-2 py-1 font-semibold transition disabled:opacity-35 ${danger ? "bg-red-400/10 text-red-100/70 hover:bg-red-400/20" : "bg-white/[0.05] text-white/55 hover:bg-white/[0.09] hover:text-white"}`} onClick={onClick}>{label}</button>;
}

View File

@@ -3,7 +3,7 @@ import type { Asset } from "@core/asset";
import type { AppStore } from "@editor/store";
import { analyzeMask, runMaskOperation, type MaskAnalysis, type MaskRasterOperation } from "@operations/masks/rasterActions";
export function MaskStatus({ asset }: { asset: Asset }) {
export function MaskStatus({ asset, purpose = "visibility" }: { asset: Asset; purpose?: "visibility" | "inpaint" }) {
const [analysis, setAnalysis] = useState<MaskAnalysis>();
useEffect(() => {
@@ -20,12 +20,11 @@ export function MaskStatus({ asset }: { asset: Asset }) {
};
}, [asset.source, asset.intrinsicSize.w, asset.intrinsicSize.h]);
if (!analysis) return <span className="rounded-full bg-white/5 px-3 py-1 text-xs text-sky-100/45">Reading</span>;
if (!analysis) return <span className="rounded bg-white/[0.04] px-2 py-0.5 text-[0.68rem] text-sky-100/40">Reading</span>;
return (
<span className="inline-flex min-w-0 items-center gap-2 rounded-full bg-white/5 px-2 py-1 text-xs text-sky-100/65">
<span className="inline-flex min-w-0 items-center gap-1.5 rounded-md bg-white/[0.04] px-1.5 py-1 text-[0.68rem] text-sky-100/60">
<img src={analysis.thumbnail} alt="" className="h-7 w-10 rounded-md bg-black/30 object-cover ring-1 ring-white/10" />
<span className="whitespace-nowrap">Reveal {formatPercent(analysis.coverage)}</span>
<span className="whitespace-nowrap text-sky-100/45">Inpaint {formatPercent(analysis.hiddenCoverage)}</span>
{purpose === "inpaint" ? <span className="whitespace-nowrap">Replace {formatPercent(analysis.coverage)}</span> : <><span className="whitespace-nowrap">Reveal {formatPercent(analysis.coverage)}</span><span className="whitespace-nowrap text-sky-100/45">Hidden {formatPercent(analysis.hiddenCoverage)}</span></>}
</span>
);
}
@@ -80,5 +79,5 @@ function MaskOperationButton({
}
function maskActionButtonClass() { return "rounded-full bg-white/5 px-2.5 py-1 text-[0.7rem] font-semibold text-white/60 transition hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-35"; }
function maskActionButtonClass() { return "rounded-md bg-white/[0.05] px-2 py-1 text-[0.68rem] font-semibold text-white/55 transition hover:bg-white/[0.09] hover:text-white disabled:pointer-events-none disabled:opacity-35"; }
function formatPercent(value: number) { return `${Math.round(value * 100)}%`; }

View File

@@ -36,7 +36,7 @@ describe("layer thumbnail read model", () => {
test("indexes nested group previews once for row lookup", () => {
const child = { ...base("child-group", "Child"), type: "group" as const, children: [raster("nested", "asset-b")] };
const group = { ...base("root", "Root"), type: "group" as const, children: [child] };
const document = { id: "doc", version: 1, name: "Doc", assets: [...assets.values()], artboards: [{ id: "artboard", name: "Artboard", bounds: { x: 0, y: 0, w: 100, h: 100 }, backgroundColor: "#000000", visible: true, locked: false, layers: [group] }] };
const document = { id: "doc", version: 1, name: "Doc", assets: [...assets.values()], inpaintRegions: [], artboards: [{ id: "artboard", name: "Artboard", bounds: { x: 0, y: 0, w: 100, h: 100 }, backgroundColor: "#000000", visible: true, locked: false, layers: [group] }] };
const index = createLayerThumbnailIndex(document, assets);
expect(index.get("nested")?.kind).toBe("raster");
expect(index.get("child-group")).toMatchObject({ kind: "group", previews: [{ source: "blob:b" }] });

View File

@@ -1,4 +1,4 @@
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 { ArrowCounterClockwise, ArrowDown, ArrowUp, CornersOut, Cursor, DownloadSimple, DropHalf, Eraser, Eye, EyeSlash, Feather, FolderOpen, FolderPlus, Hand, Lock, LockOpen, MagicWand, Minus, PaintBrush, Plus, Sparkle, Stack, Trash } from "@phosphor-icons/react";
import type { ReactNode } from "react";
import { commandIds } from "@commands/ids";
import type { Artboard } from "@core/artboard";
@@ -343,8 +343,16 @@ function toolIcon(tool: ToolId) {
return <PaintBrush size={20} />;
case "eraser":
return <Eraser size={20} />;
case "feather":
return <Feather size={20} />;
case "magicWand":
return <MagicWand size={20} />;
case "semanticSelect":
return <MagicWand size={20} />;
case "maskLasso":
return <MagicWand size={20} />;
case "maskRectangle":
return <MagicWand size={20} />;
case "pan":
return <Hand size={20} />;
}

View File

@@ -6,8 +6,16 @@ export function labelForTool(tool: ToolId): string {
return "Brush";
case "magicWand":
return "Magic wand";
case "semanticSelect":
return "AI object select";
case "maskLasso":
return "AI region lasso";
case "maskRectangle":
return "AI region rectangle";
case "eraser":
return "Eraser";
case "feather":
return "Feather";
case "pan":
return "Pan";
case "select":