feat: implement inpainting functionality with mask handling
- Added `createMaskedPixelReplacementSource` function to handle pixel replacement using inpainting. - Introduced `buildInpaintBundle` to prepare inpainting data including mask generation and validation. - Created utility functions for mask operations such as `applyMaskedContentModeToRgba`, `expandRectWithinBounds`, and others for mask manipulation. - Developed tests for inpainting preparation and mask raster utilities to ensure functionality and correctness. - Implemented mask raster operations including inversion, feathering, blurring, and more.
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
documentAddImageLayerCommand,
|
||||
documentAddLayerMaskCommand,
|
||||
documentAddRasterLayerCommand,
|
||||
documentApplyLayerMaskOperationCommand,
|
||||
documentGroupLayersCommand,
|
||||
documentMoveLayerCommand,
|
||||
documentRemoveArtboardCommand,
|
||||
@@ -236,6 +237,30 @@ describe("document commands", () => {
|
||||
expect(unmasked.editor.maskEdit).toBeUndefined();
|
||||
});
|
||||
|
||||
test("applies operations only to attached layer mask assets", () => {
|
||||
const state = documentAddLayerMaskCommand.execute(
|
||||
{ state: documentWithLayers([raster("target", "Target"), raster("loose", "Loose", "loose-asset")]) },
|
||||
{ layerId: "target", asset: maskAsset(), maskLayer: raster("mask", "Target Mask", "mask-asset") },
|
||||
);
|
||||
const withLooseAsset = documentAddAssetCommand.execute(
|
||||
{ state },
|
||||
{ asset: { id: "loose-asset", name: "Loose", mimeType: "image/png", source: "loose-source", intrinsicSize: { w: 100, h: 100 } } },
|
||||
);
|
||||
|
||||
const updated = documentApplyLayerMaskOperationCommand.execute(
|
||||
{ state: withLooseAsset },
|
||||
{ maskLayerId: "mask", source: "updated-mask", mimeType: "image/png", operation: { type: "invert" } },
|
||||
);
|
||||
const ignored = documentApplyLayerMaskOperationCommand.execute(
|
||||
{ state: updated },
|
||||
{ maskLayerId: "loose", source: "wrong", operation: { type: "invert" } },
|
||||
);
|
||||
|
||||
expect(updated.document.assets.find((asset) => asset.id === "mask-asset")?.source).toBe("updated-mask");
|
||||
expect(updated.document.assets.find((asset) => asset.id === "mask-asset")?.mimeType).toBe("image/png");
|
||||
expect(ignored.document.assets.find((asset) => asset.id === "loose-asset")?.source).toBe("loose-source");
|
||||
});
|
||||
|
||||
test("cleans mask references when deleting targets or mask layers", () => {
|
||||
const state = documentAddLayerMaskCommand.execute(
|
||||
{ state: documentWithLayers([raster("target", "Target")]) },
|
||||
|
||||
@@ -113,6 +113,25 @@ export type DocumentAddLayerMaskPayload = {
|
||||
maskLayer: RasterLayer;
|
||||
};
|
||||
|
||||
export type LayerMaskOperation =
|
||||
| { type: "paint" }
|
||||
| { type: "magicWand" }
|
||||
| { type: "chromaKey" }
|
||||
| { type: "invert" }
|
||||
| { type: "fill"; fill: "white" | "black" | "clear" }
|
||||
| { type: "feather"; radius: number }
|
||||
| { type: "expand"; radius: number }
|
||||
| { type: "contract"; radius: number }
|
||||
| { type: "blur"; radius: number }
|
||||
| { type: "despeckle"; strength: number };
|
||||
|
||||
export type DocumentApplyLayerMaskOperationPayload = {
|
||||
maskLayerId: LayerId;
|
||||
source: string;
|
||||
mimeType?: string;
|
||||
operation: LayerMaskOperation;
|
||||
};
|
||||
|
||||
export type DocumentRemoveLayerMaskPayload = {
|
||||
layerId: LayerId;
|
||||
};
|
||||
@@ -490,6 +509,38 @@ export const documentAddLayerMaskCommand: Command<DocumentAddLayerMaskPayload> =
|
||||
},
|
||||
};
|
||||
|
||||
export const documentApplyLayerMaskOperationCommand: Command<DocumentApplyLayerMaskOperationPayload> = {
|
||||
id: commandIds.documentApplyLayerMaskOperation,
|
||||
name: "Apply layer mask operation",
|
||||
execute({ state }, payload) {
|
||||
if (!payload.source.trim()) return state;
|
||||
|
||||
const maskLocation = findLayerLocation(state.document, payload.maskLayerId);
|
||||
if (!maskLocation || maskLocation.layer.type === "group") return state;
|
||||
if (!isReferencedMaskLayer(state.document, payload.maskLayerId)) return state;
|
||||
|
||||
return {
|
||||
...state,
|
||||
document: {
|
||||
...state.document,
|
||||
assets: state.document.assets.map((asset) =>
|
||||
asset.id === maskLocation.layer.assetId
|
||||
? {
|
||||
...asset,
|
||||
source: payload.source,
|
||||
mimeType: payload.mimeType ?? asset.mimeType,
|
||||
}
|
||||
: asset,
|
||||
),
|
||||
},
|
||||
editor: {
|
||||
...state.editor,
|
||||
brushStrokePreview: state.editor.brushStrokePreview?.assetId === maskLocation.layer.assetId ? undefined : state.editor.brushStrokePreview,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const documentRemoveLayerMaskCommand: Command<DocumentRemoveLayerMaskPayload> = {
|
||||
id: commandIds.documentRemoveLayerMask,
|
||||
name: "Remove layer mask",
|
||||
@@ -564,6 +615,7 @@ export const documentCommands = [
|
||||
documentRenameLayerCommand,
|
||||
documentSetLayerClippingMaskCommand,
|
||||
documentAddLayerMaskCommand,
|
||||
documentApplyLayerMaskOperationCommand,
|
||||
documentRemoveLayerMaskCommand,
|
||||
] satisfies Command<unknown>[];
|
||||
|
||||
@@ -582,6 +634,18 @@ function findLayerLocation(document: ImageDocument, layerId: LayerId): LayerLoca
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isReferencedMaskLayer(document: ImageDocument, maskLayerId: LayerId): boolean {
|
||||
return document.artboards.some((artboard) => isReferencedMaskLayerInTree(artboard.layers, maskLayerId));
|
||||
}
|
||||
|
||||
function isReferencedMaskLayerInTree(layers: readonly Layer[], maskLayerId: LayerId): boolean {
|
||||
for (const layer of layers) {
|
||||
if (layer.clippingMask?.maskLayerId === maskLayerId) return true;
|
||||
if (layer.type === "group" && isReferencedMaskLayerInTree(layer.children, maskLayerId)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function findLayerLocationInTree(layers: Layer[], layerId: LayerId, artboardId: ArtboardId, parentGroupId?: LayerId): LayerLocation | undefined {
|
||||
for (let index = 0; index < layers.length; index++) {
|
||||
const layer = layers[index];
|
||||
|
||||
143
commands/generation.test.ts
Normal file
143
commands/generation.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { GenerationCandidate } from "@editor/state";
|
||||
import { createInitialAppState } from "@editor/initial-state";
|
||||
import {
|
||||
generationAddCandidateCommand,
|
||||
generationApplyCandidateAsLayerCommand,
|
||||
generationRemoveCandidateCommand,
|
||||
generationReplaceCandidatePixelsCommand,
|
||||
} from "./generation";
|
||||
|
||||
describe("generation commands", () => {
|
||||
test("adds, selects, and removes candidates", () => {
|
||||
const state = createInitialAppState("Test");
|
||||
const candidate = generationCandidate("candidate-1");
|
||||
|
||||
const added = generationAddCandidateCommand.execute({ state }, { candidate });
|
||||
const removed = generationRemoveCandidateCommand.execute({ state: added }, { candidateId: candidate.id });
|
||||
|
||||
expect(added.editor.generation.candidates).toEqual([candidate]);
|
||||
expect(added.editor.generation.selectedCandidateId).toBe(candidate.id);
|
||||
expect(removed.editor.generation.candidates).toEqual([]);
|
||||
expect(removed.editor.generation.selectedCandidateId).toBeUndefined();
|
||||
});
|
||||
|
||||
test("applies candidates as top-level layers", () => {
|
||||
const state = generationAddCandidateCommand.execute({ state: documentWithSourceLayer() }, { candidate: generationCandidate("candidate-1") });
|
||||
|
||||
const next = generationApplyCandidateAsLayerCommand.execute(
|
||||
{ state },
|
||||
{ candidateId: "candidate-1", assetId: "generated-asset", layerId: "generated-layer" },
|
||||
);
|
||||
|
||||
expect(next.document.assets.find((asset) => asset.id === "generated-asset")?.source).toBe("generated-source");
|
||||
expect(next.document.artboards[0]?.layers[0]?.id).toBe("generated-layer");
|
||||
expect(next.editor.selection).toEqual({ artboardId: "a1", layerIds: ["generated-layer"] });
|
||||
});
|
||||
|
||||
test("replaces source asset pixels for inpaint candidates", () => {
|
||||
const state = generationAddCandidateCommand.execute({ state: documentWithSourceLayer() }, { candidate: generationCandidate("candidate-1", true) });
|
||||
|
||||
const next = generationReplaceCandidatePixelsCommand.execute(
|
||||
{ state },
|
||||
{ candidateId: "candidate-1", source: "composited-source", mimeType: "image/png" },
|
||||
);
|
||||
|
||||
expect(next.document.assets.find((asset) => asset.id === "source-asset")?.source).toBe("composited-source");
|
||||
expect(next.document.assets.find((asset) => asset.id === "source-asset")?.mimeType).toBe("image/png");
|
||||
expect(next.editor.selection).toEqual({ artboardId: "a1", layerIds: ["source-layer"] });
|
||||
});
|
||||
});
|
||||
|
||||
function documentWithSourceLayer() {
|
||||
return {
|
||||
...createInitialAppState("Test"),
|
||||
document: {
|
||||
...createInitialAppState("Test").document,
|
||||
assets: [
|
||||
{ id: "source-asset", name: "Source", mimeType: "image/png", source: "source", intrinsicSize: { w: 100, h: 100 } },
|
||||
{ id: "mask-asset", name: "Mask", mimeType: "image/png", source: "mask", intrinsicSize: { w: 100, h: 100 } },
|
||||
],
|
||||
artboards: [
|
||||
{
|
||||
id: "a1",
|
||||
name: "Artboard 1",
|
||||
bounds: { x: 0, y: 0, w: 100, h: 100 },
|
||||
backgroundColor: "transparent",
|
||||
visible: true,
|
||||
locked: false,
|
||||
layers: [
|
||||
raster("mask-layer", "Mask", "mask-asset"),
|
||||
{ ...raster("source-layer", "Source", "source-asset"), clippingMask: { maskLayerId: "mask-layer" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function generationCandidate(id: string, inpaint = false): GenerationCandidate {
|
||||
const candidate: GenerationCandidate = {
|
||||
id,
|
||||
source: "generated-source",
|
||||
mimeType: "image/png",
|
||||
intrinsicSize: { w: 64, h: 64 },
|
||||
mode: inpaint ? "inpaint" : "text-to-image",
|
||||
settings: createInitialAppState("Test").editor.tools.generate,
|
||||
seed: 123,
|
||||
width: 64,
|
||||
height: 64,
|
||||
placement: {
|
||||
artboardId: "a1",
|
||||
layerName: "Generated",
|
||||
transform: { position: { x: 5, y: 6 }, scale: { x: 1, y: 1 }, rotation: 0 },
|
||||
},
|
||||
};
|
||||
|
||||
return inpaint
|
||||
? {
|
||||
...candidate,
|
||||
inputImage: "input",
|
||||
maskImage: "mask",
|
||||
inpaint: {
|
||||
targetLayerId: "source-layer",
|
||||
maskLayerId: "mask-layer",
|
||||
sourceAssetId: "source-asset",
|
||||
maskAssetId: "mask-asset",
|
||||
inputImage: "input",
|
||||
maskImage: "mask",
|
||||
crop: {
|
||||
assetBounds: { x: 0, y: 0, w: 64, h: 64 },
|
||||
documentBounds: { x: 0, y: 0, w: 64, h: 64 },
|
||||
padding: 12,
|
||||
maskedAreaOnly: true,
|
||||
},
|
||||
mask: {
|
||||
polarity: "hidden",
|
||||
activeBounds: { x: 10, y: 10, w: 20, h: 20 },
|
||||
},
|
||||
backend: {
|
||||
growMaskBy: 6,
|
||||
maskedContent: "neutral",
|
||||
maskBlur: 0,
|
||||
maskFeather: 0,
|
||||
maskExpand: 0,
|
||||
cropPadding: 12,
|
||||
},
|
||||
},
|
||||
}
|
||||
: candidate;
|
||||
}
|
||||
|
||||
function raster(id: string, name: string, assetId: string) {
|
||||
return {
|
||||
id,
|
||||
type: "raster" as const,
|
||||
name,
|
||||
visible: true,
|
||||
locked: false,
|
||||
opacity: 1,
|
||||
assetId,
|
||||
transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 },
|
||||
};
|
||||
}
|
||||
217
commands/generation.ts
Normal file
217
commands/generation.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import type { Asset } from "@core/asset";
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { ArtboardId, AssetId, LayerId } from "@core/id";
|
||||
import type { ImageLayer } from "@core/image-layer";
|
||||
import type { Layer } from "@core/layer";
|
||||
import type { GenerationCandidate } from "@editor/state";
|
||||
import type { Command } from "./command";
|
||||
import { commandIds } from "./ids";
|
||||
|
||||
export type GenerationAddCandidatePayload = {
|
||||
candidate: GenerationCandidate;
|
||||
};
|
||||
|
||||
export type GenerationSelectCandidatePayload = {
|
||||
candidateId?: string;
|
||||
};
|
||||
|
||||
export type GenerationRemoveCandidatePayload = {
|
||||
candidateId: string;
|
||||
};
|
||||
|
||||
export type GenerationApplyCandidateAsLayerPayload = {
|
||||
candidateId: string;
|
||||
assetId: AssetId;
|
||||
layerId: LayerId;
|
||||
variant?: boolean;
|
||||
};
|
||||
|
||||
export type GenerationReplaceCandidatePixelsPayload = {
|
||||
candidateId: string;
|
||||
source: string;
|
||||
mimeType?: string;
|
||||
};
|
||||
|
||||
const maxCandidates = 12;
|
||||
|
||||
export const generationAddCandidateCommand: Command<GenerationAddCandidatePayload> = {
|
||||
id: commandIds.generationAddCandidate,
|
||||
name: "Add generation candidate",
|
||||
history: { mode: "ignore" },
|
||||
execute({ state }, payload) {
|
||||
const candidates = [payload.candidate, ...state.editor.generation.candidates.filter((candidate) => candidate.id !== payload.candidate.id)].slice(0, maxCandidates);
|
||||
return {
|
||||
...state,
|
||||
editor: {
|
||||
...state.editor,
|
||||
generation: {
|
||||
candidates,
|
||||
selectedCandidateId: payload.candidate.id,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const generationSelectCandidateCommand: Command<GenerationSelectCandidatePayload> = {
|
||||
id: commandIds.generationSelectCandidate,
|
||||
name: "Select generation candidate",
|
||||
history: { mode: "ignore" },
|
||||
execute({ state }, payload) {
|
||||
const selectedCandidateId = payload.candidateId && state.editor.generation.candidates.some((candidate) => candidate.id === payload.candidateId) ? payload.candidateId : undefined;
|
||||
if (state.editor.generation.selectedCandidateId === selectedCandidateId) return state;
|
||||
return {
|
||||
...state,
|
||||
editor: {
|
||||
...state.editor,
|
||||
generation: {
|
||||
...state.editor.generation,
|
||||
selectedCandidateId,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const generationRemoveCandidateCommand: Command<GenerationRemoveCandidatePayload> = {
|
||||
id: commandIds.generationRemoveCandidate,
|
||||
name: "Remove generation candidate",
|
||||
history: { mode: "ignore" },
|
||||
execute({ state }, payload) {
|
||||
const candidates = state.editor.generation.candidates.filter((candidate) => candidate.id !== payload.candidateId);
|
||||
if (candidates.length === state.editor.generation.candidates.length) return state;
|
||||
const selectedCandidateId = state.editor.generation.selectedCandidateId === payload.candidateId ? candidates[0]?.id : state.editor.generation.selectedCandidateId;
|
||||
return {
|
||||
...state,
|
||||
editor: {
|
||||
...state.editor,
|
||||
generation: {
|
||||
candidates,
|
||||
selectedCandidateId,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const generationClearCandidatesCommand: Command = {
|
||||
id: commandIds.generationClearCandidates,
|
||||
name: "Clear generation candidates",
|
||||
history: { mode: "ignore" },
|
||||
execute({ state }) {
|
||||
if (state.editor.generation.candidates.length === 0 && !state.editor.generation.selectedCandidateId) return state;
|
||||
return {
|
||||
...state,
|
||||
editor: {
|
||||
...state.editor,
|
||||
generation: { candidates: [], selectedCandidateId: undefined },
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const generationApplyCandidateAsLayerCommand: Command<GenerationApplyCandidateAsLayerPayload> = {
|
||||
id: commandIds.generationApplyCandidateAsLayer,
|
||||
name: "Apply generation candidate as layer",
|
||||
execute({ state }, payload) {
|
||||
const candidate = state.editor.generation.candidates.find((item) => item.id === payload.candidateId);
|
||||
if (!candidate) return state;
|
||||
if (state.document.assets.some((asset) => asset.id === payload.assetId) || findLayerLocation(state.document, payload.layerId)) return state;
|
||||
if (!state.document.artboards.some((artboard) => artboard.id === candidate.placement.artboardId)) return state;
|
||||
|
||||
const asset: Asset = {
|
||||
id: payload.assetId,
|
||||
name: payload.variant ? `${candidate.placement.layerName} variant` : candidate.placement.layerName,
|
||||
mimeType: candidate.mimeType,
|
||||
source: candidate.source,
|
||||
intrinsicSize: { ...candidate.intrinsicSize },
|
||||
};
|
||||
const layer: ImageLayer = {
|
||||
id: payload.layerId,
|
||||
type: "image",
|
||||
name: payload.variant ? `${candidate.placement.layerName} variant` : candidate.placement.layerName,
|
||||
visible: true,
|
||||
locked: false,
|
||||
opacity: 1,
|
||||
assetId: asset.id,
|
||||
transform: {
|
||||
position: { ...candidate.placement.transform.position },
|
||||
scale: { ...candidate.placement.transform.scale },
|
||||
rotation: candidate.placement.transform.rotation,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...state,
|
||||
document: insertLayerAtTop({ ...state.document, assets: [...state.document.assets, asset] }, candidate.placement.artboardId, layer),
|
||||
editor: {
|
||||
...state.editor,
|
||||
selection: { artboardId: candidate.placement.artboardId, layerIds: [layer.id] },
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const generationReplaceCandidatePixelsCommand: Command<GenerationReplaceCandidatePixelsPayload> = {
|
||||
id: commandIds.generationReplaceCandidatePixels,
|
||||
name: "Replace masked pixels with generation candidate",
|
||||
execute({ state }, payload) {
|
||||
const candidate = state.editor.generation.candidates.find((item) => item.id === payload.candidateId);
|
||||
if (!candidate?.inpaint || !payload.source.trim()) return state;
|
||||
const targetAsset = state.document.assets.find((asset) => asset.id === candidate.inpaint?.sourceAssetId);
|
||||
const targetLayerLocation = findLayerLocation(state.document, candidate.inpaint.targetLayerId);
|
||||
if (!targetAsset || !targetLayerLocation) return state;
|
||||
|
||||
return {
|
||||
...state,
|
||||
document: {
|
||||
...state.document,
|
||||
assets: state.document.assets.map((asset) => asset.id === targetAsset.id ? { ...asset, source: payload.source, mimeType: payload.mimeType ?? asset.mimeType } : asset),
|
||||
},
|
||||
editor: {
|
||||
...state.editor,
|
||||
selection: { artboardId: targetLayerLocation.artboardId, layerIds: [candidate.inpaint.targetLayerId] },
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const generationCommands = [
|
||||
generationAddCandidateCommand,
|
||||
generationSelectCandidateCommand,
|
||||
generationRemoveCandidateCommand,
|
||||
generationClearCandidatesCommand,
|
||||
generationApplyCandidateAsLayerCommand,
|
||||
generationReplaceCandidatePixelsCommand,
|
||||
] satisfies Command<unknown>[];
|
||||
|
||||
type LayerLocation = {
|
||||
artboardId: ArtboardId;
|
||||
layer: Layer;
|
||||
};
|
||||
|
||||
function insertLayerAtTop(document: ImageDocument, artboardId: ArtboardId, layer: Layer): ImageDocument {
|
||||
return {
|
||||
...document,
|
||||
artboards: document.artboards.map((artboard) => artboard.id === artboardId ? { ...artboard, layers: [layer, ...artboard.layers] } : artboard),
|
||||
};
|
||||
}
|
||||
|
||||
function findLayerLocation(document: ImageDocument, layerId: LayerId): LayerLocation | undefined {
|
||||
for (const artboard of document.artboards) {
|
||||
const layer = findLayerInTree(artboard.layers, layerId);
|
||||
if (layer) return { artboardId: artboard.id, layer };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findLayerInTree(layers: readonly Layer[], layerId: LayerId): Layer | undefined {
|
||||
for (const layer of layers) {
|
||||
if (layer.id === layerId) return layer;
|
||||
if (layer.type === "group") {
|
||||
const child = findLayerInTree(layer.children, layerId);
|
||||
if (child) return child;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export const commandIds = {
|
||||
documentRenameLayer: "document.renameLayer",
|
||||
documentSetLayerClippingMask: "document.setLayerClippingMask",
|
||||
documentAddLayerMask: "document.addLayerMask",
|
||||
documentApplyLayerMaskOperation: "document.applyLayerMaskOperation",
|
||||
documentRemoveLayerMask: "document.removeLayerMask",
|
||||
selectionSet: "selection.set",
|
||||
selectionClear: "selection.clear",
|
||||
@@ -35,6 +36,12 @@ export const commandIds = {
|
||||
toolExitMaskEdit: "tool.exitMaskEdit",
|
||||
toolEnterTemporaryPan: "tool.enterTemporaryPan",
|
||||
toolExitTemporaryPan: "tool.exitTemporaryPan",
|
||||
generationAddCandidate: "generation.addCandidate",
|
||||
generationSelectCandidate: "generation.selectCandidate",
|
||||
generationRemoveCandidate: "generation.removeCandidate",
|
||||
generationClearCandidates: "generation.clearCandidates",
|
||||
generationApplyCandidateAsLayer: "generation.applyCandidateAsLayer",
|
||||
generationReplaceCandidatePixels: "generation.replaceCandidatePixels",
|
||||
transformBegin: "transform.begin",
|
||||
transformUpdate: "transform.update",
|
||||
transformSetBounds: "transform.setBounds",
|
||||
|
||||
@@ -6,6 +6,7 @@ export {
|
||||
documentAddImageLayerCommand,
|
||||
documentAddLayerMaskCommand,
|
||||
documentAddRasterLayerCommand,
|
||||
documentApplyLayerMaskOperationCommand,
|
||||
documentCommands,
|
||||
documentGroupLayersCommand,
|
||||
documentMoveLayerCommand,
|
||||
@@ -30,6 +31,7 @@ export type {
|
||||
DocumentAddImageLayerPayload,
|
||||
DocumentAddLayerMaskPayload,
|
||||
DocumentAddRasterLayerPayload,
|
||||
DocumentApplyLayerMaskOperationPayload,
|
||||
DocumentGroupLayersPayload,
|
||||
DocumentMoveLayerPayload,
|
||||
DocumentRemoveArtboardPayload,
|
||||
@@ -45,8 +47,25 @@ export type {
|
||||
DocumentSetLayerVisiblePayload,
|
||||
DocumentUpdateAssetSourcePayload,
|
||||
DocumentUngroupLayerPayload,
|
||||
LayerMaskOperation,
|
||||
} from "./document";
|
||||
export { historyCommands, historyRedoCommand, historyUndoCommand } from "./history";
|
||||
export {
|
||||
generationAddCandidateCommand,
|
||||
generationApplyCandidateAsLayerCommand,
|
||||
generationClearCandidatesCommand,
|
||||
generationCommands,
|
||||
generationRemoveCandidateCommand,
|
||||
generationReplaceCandidatePixelsCommand,
|
||||
generationSelectCandidateCommand,
|
||||
} from "./generation";
|
||||
export type {
|
||||
GenerationAddCandidatePayload,
|
||||
GenerationApplyCandidateAsLayerPayload,
|
||||
GenerationRemoveCandidatePayload,
|
||||
GenerationReplaceCandidatePixelsPayload,
|
||||
GenerationSelectCandidatePayload,
|
||||
} from "./generation";
|
||||
export type { CommandDispatcher, Dispatch } from "./dispatcher";
|
||||
export type { CommandId, CommandPayloads } from "./payloads";
|
||||
export { createCommandDispatcher } from "./dispatcher";
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
DocumentAddImageLayerPayload,
|
||||
DocumentAddLayerMaskPayload,
|
||||
DocumentAddRasterLayerPayload,
|
||||
DocumentApplyLayerMaskOperationPayload,
|
||||
DocumentGroupLayersPayload,
|
||||
DocumentMoveLayerPayload,
|
||||
DocumentRemoveArtboardPayload,
|
||||
@@ -22,6 +23,13 @@ import type {
|
||||
DocumentUpdateAssetSourcePayload,
|
||||
DocumentUngroupLayerPayload,
|
||||
} from "./document";
|
||||
import type {
|
||||
GenerationAddCandidatePayload,
|
||||
GenerationApplyCandidateAsLayerPayload,
|
||||
GenerationRemoveCandidatePayload,
|
||||
GenerationReplaceCandidatePixelsPayload,
|
||||
GenerationSelectCandidatePayload,
|
||||
} from "./generation";
|
||||
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
||||
import type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool";
|
||||
import type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform";
|
||||
@@ -46,6 +54,7 @@ export type CommandPayloads = {
|
||||
[commandIds.documentAddRasterLayer]: DocumentAddRasterLayerPayload;
|
||||
[commandIds.documentAddGroupLayer]: DocumentAddGroupLayerPayload;
|
||||
[commandIds.documentAddLayerMask]: DocumentAddLayerMaskPayload;
|
||||
[commandIds.documentApplyLayerMaskOperation]: DocumentApplyLayerMaskOperationPayload;
|
||||
[commandIds.documentRemoveLayerMask]: DocumentRemoveLayerMaskPayload;
|
||||
[commandIds.documentMoveLayer]: DocumentMoveLayerPayload;
|
||||
[commandIds.documentGroupLayers]: DocumentGroupLayersPayload;
|
||||
@@ -70,6 +79,12 @@ export type CommandPayloads = {
|
||||
[commandIds.toolExitMaskEdit]: void;
|
||||
[commandIds.toolEnterTemporaryPan]: void;
|
||||
[commandIds.toolExitTemporaryPan]: void;
|
||||
[commandIds.generationAddCandidate]: GenerationAddCandidatePayload;
|
||||
[commandIds.generationSelectCandidate]: GenerationSelectCandidatePayload;
|
||||
[commandIds.generationRemoveCandidate]: GenerationRemoveCandidatePayload;
|
||||
[commandIds.generationClearCandidates]: void;
|
||||
[commandIds.generationApplyCandidateAsLayer]: GenerationApplyCandidateAsLayerPayload;
|
||||
[commandIds.generationReplaceCandidatePixels]: GenerationReplaceCandidatePixelsPayload;
|
||||
[commandIds.transformBegin]: TransformBeginPayload;
|
||||
[commandIds.transformUpdate]: TransformUpdatePayload;
|
||||
[commandIds.transformSetBounds]: TransformSetBoundsPayload;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createInitialAppState } from "@editor/initial-state";
|
||||
import { toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetChromaKeySettingsCommand, toolSetMaskViewModeCommand } from "./tool";
|
||||
import { toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetChromaKeySettingsCommand, toolSetGenerateSettingsCommand, toolSetMaskViewModeCommand } from "./tool";
|
||||
|
||||
const defaultBrush = { color: "#111827", size: 8, hardness: 100 };
|
||||
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 = { mode: "text-to-image" as const, model: "auto" as const, 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 } };
|
||||
const defaultGenerate = { mode: "text-to-image" as const, model: "auto" as const, 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 } };
|
||||
|
||||
describe("tool commands", () => {
|
||||
test("sets active tool", () => {
|
||||
@@ -25,6 +25,16 @@ describe("tool commands", () => {
|
||||
expect(next.editor.tools.chromaKey).toEqual({ color: "#123456", tolerance: 255, softness: 24, feather: 0, choke: 0, despeckle: 0, spill: 50 });
|
||||
});
|
||||
|
||||
test("sets inpaint generation settings", () => {
|
||||
const next = toolSetGenerateSettingsCommand.execute(
|
||||
{ state: createInitialAppState("Test") },
|
||||
{ mode: "inpaint", inpaint: { ...defaultGenerate.inpaint, cropPadding: 5000, growMaskBy: -4, maskExpand: -500, maskBlur: 300, maskPolarity: "revealed", maskedContent: "original" } },
|
||||
);
|
||||
|
||||
expect(next.editor.tools.generate.mode).toBe("inpaint");
|
||||
expect(next.editor.tools.generate.inpaint).toEqual({ ...defaultGenerate.inpaint, cropPadding: 2048, growMaskBy: 0, maskExpand: -256, maskBlur: 256, maskPolarity: "revealed", maskedContent: "original" });
|
||||
});
|
||||
|
||||
test("sets and clears brush preview", () => {
|
||||
const showing = toolSetBrushPreviewCommand.execute({ state: createInitialAppState("Test") }, { position: { x: 10, y: 20 } });
|
||||
const cleared = toolSetBrushPreviewCommand.execute({ state: showing }, undefined);
|
||||
|
||||
@@ -89,6 +89,17 @@ export const toolSetGenerateSettingsCommand: Command<ToolSetGenerateSettingsPayl
|
||||
bottom: Math.round(clampNumber(payload.outpaint?.bottom ?? state.editor.tools.generate.outpaint.bottom, 0, 2048)),
|
||||
feathering: Math.round(clampNumber(payload.outpaint?.feathering ?? state.editor.tools.generate.outpaint.feathering, 0, 512)),
|
||||
},
|
||||
inpaint: {
|
||||
maskedAreaOnly: payload.inpaint?.maskedAreaOnly ?? state.editor.tools.generate.inpaint.maskedAreaOnly,
|
||||
cropPadding: Math.round(clampNumber(payload.inpaint?.cropPadding ?? state.editor.tools.generate.inpaint.cropPadding, 0, 2048)),
|
||||
maskPolarity: payload.inpaint?.maskPolarity ?? state.editor.tools.generate.inpaint.maskPolarity,
|
||||
maskedContent: payload.inpaint?.maskedContent ?? state.editor.tools.generate.inpaint.maskedContent,
|
||||
growMaskBy: Math.round(clampNumber(payload.inpaint?.growMaskBy ?? state.editor.tools.generate.inpaint.growMaskBy, 0, 256)),
|
||||
maskExpand: Math.round(clampNumber(payload.inpaint?.maskExpand ?? state.editor.tools.generate.inpaint.maskExpand, -256, 256)),
|
||||
maskFeather: Math.round(clampNumber(payload.inpaint?.maskFeather ?? state.editor.tools.generate.inpaint.maskFeather, 0, 256)),
|
||||
maskBlur: Math.round(clampNumber(payload.inpaint?.maskBlur ?? state.editor.tools.generate.inpaint.maskBlur, 0, 256)),
|
||||
maskDespeckle: Math.round(clampNumber(payload.inpaint?.maskDespeckle ?? state.editor.tools.generate.inpaint.maskDespeckle, 0, 64)),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user