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.
This commit is contained in:
@@ -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)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -235,10 +235,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,14 +634,15 @@ 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",
|
||||
@@ -670,7 +675,7 @@ 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",
|
||||
@@ -749,7 +754,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)),
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
@@ -41,8 +44,12 @@ export const commandIds = {
|
||||
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 +57,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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
32
commands/inpaint-region.test.ts
Normal file
32
commands/inpaint-region.test.ts
Normal 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
100
commands/inpaint-region.ts
Normal 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>[];
|
||||
@@ -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, 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;
|
||||
@@ -108,8 +114,12 @@ export type CommandPayloads = {
|
||||
[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 +127,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;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
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, 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 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", () => {
|
||||
@@ -24,7 +25,7 @@ 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 chroma key settings", () => {
|
||||
@@ -43,6 +44,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 +75,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 +96,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 +125,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();
|
||||
});
|
||||
|
||||
|
||||
119
commands/tool.ts
119
commands/tool.ts
@@ -1,10 +1,10 @@
|
||||
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 { generateArchitectureDefaults, inpaintProfileDefaults } from "@editor/tools";
|
||||
import type { BrushSettings, ChromaKeySettings, GenerateIntent, GenerateSettings, MagicWandSettings, ToolId } from "@editor/tools";
|
||||
import type { Command } from "./command";
|
||||
import { commandIds } from "./ids";
|
||||
@@ -15,7 +15,10 @@ export type ToolSetActivePayload = {
|
||||
|
||||
export type ToolSetBrushSettingsPayload = Partial<BrushSettings>;
|
||||
|
||||
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>;
|
||||
@@ -35,12 +38,19 @@ export type ToolSetBrushStrokePreviewPayload =
|
||||
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 +66,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 +85,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 +101,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 +109,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 +120,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 +145,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 +158,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 +176,10 @@ 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -270,6 +296,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 +341,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 +393,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 +402,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,
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -366,7 +459,11 @@ export const toolCommands = [
|
||||
toolSetBrushPreviewCommand,
|
||||
toolSetBrushStrokePreviewCommand,
|
||||
toolSetMaskViewModeCommand,
|
||||
toolBeginMaskShapeCommand,
|
||||
toolAppendMaskShapeCommand,
|
||||
toolClearMaskShapeCommand,
|
||||
toolEnterMaskEditCommand,
|
||||
toolEnterInpaintRegionEditCommand,
|
||||
toolExitMaskEditCommand,
|
||||
toolEnterTemporaryPanCommand,
|
||||
toolExitTemporaryPanCommand,
|
||||
|
||||
Reference in New Issue
Block a user