feat: enhance layer mask handling and opacity management
- Introduced `getLayerMask` utility to streamline layer mask retrieval. - Updated layer rendering logic to incorporate layer masks and opacity adjustments. - Added functionality to prevent dropping a group into its descendants. - Enhanced image texture rendering to support opacity parameters across various rendering functions. - Implemented group layer bounds application for better scaling and positioning. - Added tests to ensure correct behavior when handling layer masks and group layers. - Created new types for asset generation provenance to track generated assets more effectively.
This commit is contained in:
@@ -154,6 +154,35 @@ describe("document commands", () => {
|
||||
expect(ungrouped.editor.selection.layerIds).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
test("groups nested sibling layers in place", () => {
|
||||
const state = documentWithLayers([{ ...group("parent", "Parent"), children: [group("a", "A"), group("b", "B"), group("c", "C")] }]);
|
||||
|
||||
const grouped = documentGroupLayersCommand.execute({ state }, { artboardId: "a1", layerIds: ["a", "b"], group: group("g", "Group") });
|
||||
const parent = grouped.document.artboards[0]?.layers[0];
|
||||
const nestedGroup = parent?.type === "group" ? parent.children[0] : undefined;
|
||||
|
||||
expect(parent?.type).toBe("group");
|
||||
expect(parent?.type === "group" ? parent.children.map((layer) => layer.id) : []).toEqual(["g", "c"]);
|
||||
expect(nestedGroup?.type === "group" ? nestedGroup.children.map((layer) => layer.id) : []).toEqual(["a", "b"]);
|
||||
expect(grouped.editor.selection).toEqual({ artboardId: "a1", layerIds: ["g"] });
|
||||
});
|
||||
|
||||
test("does not group layers from different parents", () => {
|
||||
const state = documentWithLayers([group("a", "A"), { ...group("parent", "Parent"), children: [group("b", "B")] }]);
|
||||
|
||||
const grouped = documentGroupLayersCommand.execute({ state }, { artboardId: "a1", layerIds: ["a", "b"], group: group("g", "Group") });
|
||||
|
||||
expect(grouped).toBe(state);
|
||||
});
|
||||
|
||||
test("does not move groups into their own descendants", () => {
|
||||
const state = documentWithLayers([{ ...group("parent", "Parent"), children: [group("child", "Child")] }]);
|
||||
|
||||
const moved = documentMoveLayerCommand.execute({ state }, { layerId: "parent", toArtboardId: "a1", toParentGroupId: "child", toIndex: 0 });
|
||||
|
||||
expect(moved).toBe(state);
|
||||
});
|
||||
|
||||
test("removes layers", () => {
|
||||
const state = documentWithLayers([group("a", "A"), group("b", "B")]);
|
||||
const selectedState = { ...state, editor: { ...state.editor, selection: { artboardId: "a1", layerIds: ["a"] } } };
|
||||
@@ -219,6 +248,12 @@ describe("document commands", () => {
|
||||
|
||||
expect(masked.document.assets).toContainEqual(maskAsset());
|
||||
expect(masked.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["mask", "target"]);
|
||||
expect(masked.document.artboards[0]?.layers[1]?.layerMask).toEqual({
|
||||
kind: "raster",
|
||||
maskLayerId: "mask",
|
||||
enabled: true,
|
||||
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.tools.activeTool).toBe("brush");
|
||||
@@ -233,6 +268,7 @@ describe("document commands", () => {
|
||||
const unmasked = documentRemoveLayerMaskCommand.execute({ state }, { layerId: "target" });
|
||||
|
||||
expect(unmasked.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["target"]);
|
||||
expect(unmasked.document.artboards[0]?.layers[0]?.layerMask).toBeUndefined();
|
||||
expect(unmasked.document.artboards[0]?.layers[0]?.clippingMask).toBeUndefined();
|
||||
expect(unmasked.editor.maskEdit).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Rect } from "@core/geometry";
|
||||
import type { ArtboardId, AssetId, LayerId } from "@core/id";
|
||||
import type { ImageLayer } from "@core/image-layer";
|
||||
import type { Layer } from "@core/layer";
|
||||
import { getLayerMask } from "@core/layer-mask-utils";
|
||||
import type { RasterLayer } from "@core/raster-layer";
|
||||
import type { LayerGroup } from "@core/layer-group";
|
||||
import type { Command } from "./command";
|
||||
@@ -316,7 +317,7 @@ export const documentMoveLayerCommand: Command<DocumentMoveLayerPayload> = {
|
||||
const removed = removeLayerFromDocument(state.document, payload.layerId);
|
||||
if (!removed.layer) return state;
|
||||
|
||||
const maskLayerId = removed.layer.clippingMask?.maskLayerId;
|
||||
const maskLayerId = getLayerMask(removed.layer)?.maskLayerId;
|
||||
const removedMask = maskLayerId ? removeLayerFromDocument(removed.document, maskLayerId) : undefined;
|
||||
const documentAfterRemoval = removedMask?.document ?? removed.document;
|
||||
if (payload.toParentGroupId && !findGroup(documentAfterRemoval, payload.toParentGroupId)) return state;
|
||||
@@ -338,25 +339,29 @@ export const documentGroupLayersCommand: Command<DocumentGroupLayersPayload> = {
|
||||
execute({ state }, payload) {
|
||||
const requestedIds = [...new Set(payload.layerIds)];
|
||||
if (requestedIds.length === 0) return state;
|
||||
if (findLayerLocation(state.document, payload.group.id)) return state;
|
||||
|
||||
const artboard = state.document.artboards.find((candidate) => candidate.id === payload.artboardId);
|
||||
if (!artboard) return state;
|
||||
const requestedLocations = requestedIds.flatMap((layerId) => {
|
||||
const location = findLayerLocation(state.document, layerId);
|
||||
return location ? [location] : [];
|
||||
});
|
||||
if (requestedLocations.length !== requestedIds.length) return state;
|
||||
|
||||
const uniqueIds = [...new Set([...requestedIds, ...collectAttachedMaskIds(artboard.layers, requestedIds)])];
|
||||
const firstLocation = requestedLocations[0];
|
||||
if (!firstLocation || firstLocation.artboardId !== payload.artboardId) return state;
|
||||
if (requestedLocations.some((location) => location.artboardId !== firstLocation.artboardId || location.parentGroupId !== firstLocation.parentGroupId)) return state;
|
||||
|
||||
const selected = artboard.layers.filter((layer) => uniqueIds.includes(layer.id));
|
||||
const uniqueIds = new Set([...requestedIds, ...collectAttachedMaskIds(firstLocation.siblings, requestedIds)]);
|
||||
const selected = firstLocation.siblings.filter((layer) => uniqueIds.has(layer.id));
|
||||
if (selected.length === 0) return state;
|
||||
|
||||
const firstIndex = artboard.layers.findIndex((layer) => layer.id === selected[0]?.id);
|
||||
const group: LayerGroup = { ...payload.group, children: selected };
|
||||
const document = {
|
||||
...state.document,
|
||||
artboards: state.document.artboards.map((candidate) =>
|
||||
candidate.id === payload.artboardId
|
||||
? { ...candidate, layers: [...candidate.layers.filter((layer) => !uniqueIds.includes(layer.id)).slice(0, firstIndex), group, ...candidate.layers.filter((layer) => !uniqueIds.includes(layer.id)).slice(firstIndex)] }
|
||||
: candidate,
|
||||
),
|
||||
};
|
||||
const document = replaceLayerListInDocument(
|
||||
state.document,
|
||||
firstLocation.artboardId,
|
||||
firstLocation.parentGroupId,
|
||||
replaceSelectedLayersWithGroup(firstLocation.siblings, uniqueIds, group),
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
@@ -414,7 +419,7 @@ export const documentSetLayerClippingMaskCommand: Command<DocumentSetLayerClippi
|
||||
if (payload.maskLayerId === payload.layerId) return state;
|
||||
|
||||
if (!payload.maskLayerId) {
|
||||
const previousMaskId = findLayerLocation(state.document, payload.layerId)?.layer.clippingMask?.maskLayerId;
|
||||
const previousMaskId = getLayerMask(findLayerLocation(state.document, payload.layerId)?.layer)?.maskLayerId;
|
||||
return {
|
||||
...state,
|
||||
document: mapLayerInDocument(state.document, payload.layerId, (layer) => removeLayerMaskReference(layer)),
|
||||
@@ -442,7 +447,7 @@ export const documentSetLayerClippingMaskCommand: Command<DocumentSetLayerClippi
|
||||
removed.document,
|
||||
maskLocationAfterRemoval.artboardId,
|
||||
maskLocationAfterRemoval.parentGroupId,
|
||||
{ ...removed.layer, clippingMask: { maskLayerId: payload.maskLayerId } },
|
||||
withLayerMask(removed.layer, payload.maskLayerId),
|
||||
maskLocationAfterRemoval.index + 1,
|
||||
),
|
||||
};
|
||||
@@ -456,7 +461,7 @@ export const documentAddLayerMaskCommand: Command<DocumentAddLayerMaskPayload> =
|
||||
const targetLocation = findLayerLocation(state.document, payload.layerId);
|
||||
if (!targetLocation || targetLocation.layer.type === "group") return state;
|
||||
|
||||
const existingMaskId = targetLocation.layer.clippingMask?.maskLayerId;
|
||||
const existingMaskId = getLayerMask(targetLocation.layer)?.maskLayerId;
|
||||
if (existingMaskId) {
|
||||
const existingMaskLocation = findLayerLocation(state.document, existingMaskId);
|
||||
if (existingMaskLocation?.layer.type === "group") return state;
|
||||
@@ -486,11 +491,12 @@ export const documentAddLayerMaskCommand: Command<DocumentAddLayerMaskPayload> =
|
||||
visible: true,
|
||||
locked: false,
|
||||
opacity: 1,
|
||||
layerMask: undefined,
|
||||
clippingMask: undefined,
|
||||
};
|
||||
const withAsset: ImageDocument = { ...state.document, assets: [...state.document.assets, payload.asset] };
|
||||
const withMaskLayer = insertLayer(withAsset, targetLocation.artboardId, targetLocation.parentGroupId, maskLayer, targetLocation.index);
|
||||
const document = mapLayerInDocument(withMaskLayer, payload.layerId, (layer) => ({ ...layer, clippingMask: { maskLayerId: maskLayer.id } }));
|
||||
const document = mapLayerInDocument(withMaskLayer, payload.layerId, (layer) => withLayerMask(layer, maskLayer.id));
|
||||
|
||||
return {
|
||||
...state,
|
||||
@@ -546,7 +552,7 @@ export const documentRemoveLayerMaskCommand: Command<DocumentRemoveLayerMaskPayl
|
||||
name: "Remove layer mask",
|
||||
execute({ state }, payload) {
|
||||
const targetLocation = findLayerLocation(state.document, payload.layerId);
|
||||
const maskLayerId = targetLocation?.layer.clippingMask?.maskLayerId;
|
||||
const maskLayerId = targetLocation?.layer ? getLayerMask(targetLocation.layer)?.maskLayerId : undefined;
|
||||
if (!targetLocation || !maskLayerId) {
|
||||
return state.editor.maskEdit?.targetLayerId === payload.layerId ? { ...state, editor: { ...state.editor, maskEdit: undefined } } : state;
|
||||
}
|
||||
@@ -624,6 +630,7 @@ type LayerLocation = {
|
||||
parentGroupId?: LayerId;
|
||||
index: number;
|
||||
layer: Layer;
|
||||
siblings: readonly Layer[];
|
||||
};
|
||||
|
||||
function findLayerLocation(document: ImageDocument, layerId: LayerId): LayerLocation | undefined {
|
||||
@@ -640,7 +647,7 @@ function isReferencedMaskLayer(document: ImageDocument, maskLayerId: LayerId): b
|
||||
|
||||
function isReferencedMaskLayerInTree(layers: readonly Layer[], maskLayerId: LayerId): boolean {
|
||||
for (const layer of layers) {
|
||||
if (layer.clippingMask?.maskLayerId === maskLayerId) return true;
|
||||
if (getLayerMask(layer)?.maskLayerId === maskLayerId) return true;
|
||||
if (layer.type === "group" && isReferencedMaskLayerInTree(layer.children, maskLayerId)) return true;
|
||||
}
|
||||
return false;
|
||||
@@ -650,7 +657,7 @@ function findLayerLocationInTree(layers: Layer[], layerId: LayerId, artboardId:
|
||||
for (let index = 0; index < layers.length; index++) {
|
||||
const layer = layers[index];
|
||||
if (!layer) continue;
|
||||
if (layer.id === layerId) return { artboardId, parentGroupId, index, layer };
|
||||
if (layer.id === layerId) return { artboardId, parentGroupId, index, layer, siblings: layers };
|
||||
if (layer.type === "group") {
|
||||
const child = findLayerLocationInTree(layer.children, layerId, artboardId, layer.id);
|
||||
if (child) return child;
|
||||
@@ -693,6 +700,41 @@ function insertLayerInGroup(layers: Layer[], groupId: LayerId, layer: Layer, ind
|
||||
});
|
||||
}
|
||||
|
||||
function replaceLayerListInDocument(document: ImageDocument, artboardId: ArtboardId, parentGroupId: LayerId | undefined, layers: Layer[]): ImageDocument {
|
||||
return {
|
||||
...document,
|
||||
artboards: document.artboards.map((artboard) => {
|
||||
if (artboard.id !== artboardId) return artboard;
|
||||
if (!parentGroupId) return { ...artboard, layers };
|
||||
return { ...artboard, layers: replaceLayerListInGroup(artboard.layers, parentGroupId, layers) };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function replaceLayerListInGroup(layers: Layer[], groupId: LayerId, children: Layer[]): Layer[] {
|
||||
return layers.map((layer) => {
|
||||
if (layer.type === "group" && layer.id === groupId) return { ...layer, children };
|
||||
if (layer.type === "group") return { ...layer, children: replaceLayerListInGroup(layer.children, groupId, children) };
|
||||
return layer;
|
||||
});
|
||||
}
|
||||
|
||||
function replaceSelectedLayersWithGroup(layers: readonly Layer[], selectedLayerIds: ReadonlySet<LayerId>, group: LayerGroup): Layer[] {
|
||||
const next: Layer[] = [];
|
||||
let inserted = false;
|
||||
for (const layer of layers) {
|
||||
if (!selectedLayerIds.has(layer.id)) {
|
||||
next.push(layer);
|
||||
continue;
|
||||
}
|
||||
if (!inserted) {
|
||||
next.push(group);
|
||||
inserted = true;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function removeLayerFromDocument(document: ImageDocument, layerId: LayerId): { document: ImageDocument; layer?: Layer } {
|
||||
let removed: Layer | undefined;
|
||||
return {
|
||||
@@ -785,10 +827,24 @@ function findGroupInTree(layers: Layer[], groupId: LayerId): LayerGroup | undefi
|
||||
|
||||
function removeLayerMaskReference(layer: Layer): Layer {
|
||||
const next = { ...layer };
|
||||
delete next.layerMask;
|
||||
delete next.clippingMask;
|
||||
return next;
|
||||
}
|
||||
|
||||
function withLayerMask(layer: Layer, maskLayerId: LayerId): Layer {
|
||||
return {
|
||||
...layer,
|
||||
layerMask: {
|
||||
kind: "raster",
|
||||
maskLayerId,
|
||||
enabled: true,
|
||||
inverted: false,
|
||||
},
|
||||
clippingMask: { maskLayerId },
|
||||
};
|
||||
}
|
||||
|
||||
function removeUnreferencedMaskLayer(document: ImageDocument, maskLayerId: LayerId): ImageDocument {
|
||||
if (isMaskLayerReferenced(document, maskLayerId)) return document;
|
||||
return removeLayerFromDocument(document, maskLayerId).document;
|
||||
@@ -801,7 +857,8 @@ function isMaskLayerReferenced(document: ImageDocument, maskLayerId: LayerId): b
|
||||
function removeMissingMaskReferences(document: ImageDocument): ImageDocument {
|
||||
const existingLayerIds = collectDocumentLayerIds(document);
|
||||
return mapAllLayersInDocument(document, (layer) => {
|
||||
if (!layer.clippingMask || existingLayerIds.has(layer.clippingMask.maskLayerId)) return layer;
|
||||
const mask = getLayerMask(layer);
|
||||
if (!mask || existingLayerIds.has(mask.maskLayerId)) return layer;
|
||||
return removeLayerMaskReference(layer);
|
||||
});
|
||||
}
|
||||
@@ -814,7 +871,7 @@ function isMaskEditValid(maskEdit: { targetLayerId: LayerId; maskLayerId: LayerI
|
||||
if (!maskEdit) return false;
|
||||
const target = findLayerLocation(document, maskEdit.targetLayerId)?.layer;
|
||||
const mask = findLayerLocation(document, maskEdit.maskLayerId)?.layer;
|
||||
return Boolean(target?.clippingMask?.maskLayerId === maskEdit.maskLayerId && mask && mask.type !== "group");
|
||||
return Boolean(target && getLayerMask(target)?.maskLayerId === maskEdit.maskLayerId && mask && mask.type !== "group");
|
||||
}
|
||||
|
||||
function collectDocumentLayerIds(document: ImageDocument): Set<LayerId> {
|
||||
@@ -836,7 +893,8 @@ function collectLayerIdsFromTree(layers: readonly Layer[], ids = new Set<LayerId
|
||||
|
||||
function collectClippingMaskIds(layers: readonly Layer[], ids = new Set<LayerId>()): Set<LayerId> {
|
||||
for (const layer of layers) {
|
||||
if (layer.clippingMask) ids.add(layer.clippingMask.maskLayerId);
|
||||
const mask = getLayerMask(layer);
|
||||
if (mask) ids.add(mask.maskLayerId);
|
||||
if (layer.type === "group") collectClippingMaskIds(layer.children, ids);
|
||||
}
|
||||
return ids;
|
||||
@@ -844,7 +902,10 @@ function collectClippingMaskIds(layers: readonly Layer[], ids = new Set<LayerId>
|
||||
|
||||
function collectAttachedMaskIds(layers: readonly Layer[], layerIds: readonly LayerId[]): LayerId[] {
|
||||
const layerIdSet = new Set(layerIds);
|
||||
return layers.flatMap((layer) => (layerIdSet.has(layer.id) && layer.clippingMask ? [layer.clippingMask.maskLayerId] : []));
|
||||
return layers.flatMap((layer) => {
|
||||
const mask = getLayerMask(layer);
|
||||
return layerIdSet.has(layer.id) && mask ? [mask.maskLayerId] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function mapAllLayersInDocument(document: ImageDocument, mapLayer: (layer: Layer) => Layer): ImageDocument {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
generationApplyCandidateAsLayerCommand,
|
||||
generationRemoveCandidateCommand,
|
||||
generationReplaceCandidatePixelsCommand,
|
||||
generationSetCompareModeCommand,
|
||||
} from "./generation";
|
||||
|
||||
describe("generation commands", () => {
|
||||
@@ -18,8 +19,20 @@ describe("generation commands", () => {
|
||||
|
||||
expect(added.editor.generation.candidates).toEqual([candidate]);
|
||||
expect(added.editor.generation.selectedCandidateId).toBe(candidate.id);
|
||||
expect(added.editor.generation.compareMode).toBe("result");
|
||||
expect(removed.editor.generation.candidates).toEqual([]);
|
||||
expect(removed.editor.generation.selectedCandidateId).toBeUndefined();
|
||||
expect(removed.editor.generation.compareMode).toBe("result");
|
||||
});
|
||||
|
||||
test("sets generation compare mode without touching candidates", () => {
|
||||
const state = generationAddCandidateCommand.execute({ state: createInitialAppState("Test") }, { candidate: generationCandidate("candidate-1") });
|
||||
|
||||
const next = generationSetCompareModeCommand.execute({ state }, { mode: "split" });
|
||||
|
||||
expect(next.editor.generation.candidates).toEqual([generationCandidate("candidate-1")]);
|
||||
expect(next.editor.generation.selectedCandidateId).toBe("candidate-1");
|
||||
expect(next.editor.generation.compareMode).toBe("split");
|
||||
});
|
||||
|
||||
test("applies candidates as top-level layers", () => {
|
||||
@@ -31,9 +44,15 @@ describe("generation commands", () => {
|
||||
);
|
||||
|
||||
expect(next.document.assets.find((asset) => asset.id === "generated-asset")?.source).toBe("generated-source");
|
||||
expect(next.document.assets.find((asset) => asset.id === "generated-asset")?.provenance).toMatchObject({
|
||||
kind: "generated",
|
||||
candidateId: "candidate-1",
|
||||
acceptance: "layer",
|
||||
seed: 123,
|
||||
});
|
||||
expect(next.document.artboards[0]?.layers[0]?.id).toBe("generated-layer");
|
||||
expect(next.editor.selection).toEqual({ artboardId: "a1", layerIds: ["generated-layer"] });
|
||||
expect(next.editor.generation).toEqual({ candidates: [], selectedCandidateId: undefined });
|
||||
expect(next.editor.generation).toEqual({ candidates: [], selectedCandidateId: undefined, compareMode: "result" });
|
||||
});
|
||||
|
||||
test("replaces source asset pixels for inpaint candidates", () => {
|
||||
@@ -46,8 +65,17 @@ describe("generation commands", () => {
|
||||
|
||||
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.document.assets.find((asset) => asset.id === "source-asset")?.provenance).toMatchObject({
|
||||
kind: "generated",
|
||||
candidateId: "candidate-1",
|
||||
acceptance: "replacement",
|
||||
inpaint: {
|
||||
sourceAssetId: "source-asset",
|
||||
maskAssetId: "mask-asset",
|
||||
},
|
||||
});
|
||||
expect(next.editor.selection).toEqual({ artboardId: "a1", layerIds: ["source-layer"] });
|
||||
expect(next.editor.generation).toEqual({ candidates: [], selectedCandidateId: undefined });
|
||||
expect(next.editor.generation).toEqual({ candidates: [], selectedCandidateId: undefined, compareMode: "result" });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { Asset } from "@core/asset";
|
||||
import type { AssetGenerationProvenance, GeneratedAssetAcceptance } from "@core/asset-provenance";
|
||||
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, GenerationState } from "@editor/state";
|
||||
import type { GenerationCandidate, GenerationCompareMode, GenerationState } from "@editor/state";
|
||||
import type { Command } from "./command";
|
||||
import { commandIds } from "./ids";
|
||||
|
||||
@@ -15,6 +16,10 @@ export type GenerationSelectCandidatePayload = {
|
||||
candidateId?: string;
|
||||
};
|
||||
|
||||
export type GenerationSetCompareModePayload = {
|
||||
mode: GenerationCompareMode;
|
||||
};
|
||||
|
||||
export type GenerationRemoveCandidatePayload = {
|
||||
candidateId: string;
|
||||
};
|
||||
@@ -33,6 +38,7 @@ export type GenerationReplaceCandidatePixelsPayload = {
|
||||
};
|
||||
|
||||
const maxCandidates = 12;
|
||||
const generationCompareModes = new Set<GenerationCompareMode>(["result", "before", "split"]);
|
||||
|
||||
export const generationAddCandidateCommand: Command<GenerationAddCandidatePayload> = {
|
||||
id: commandIds.generationAddCandidate,
|
||||
@@ -47,6 +53,7 @@ export const generationAddCandidateCommand: Command<GenerationAddCandidatePayloa
|
||||
generation: {
|
||||
candidates,
|
||||
selectedCandidateId: payload.candidate.id,
|
||||
compareMode: "result",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -73,6 +80,26 @@ export const generationSelectCandidateCommand: Command<GenerationSelectCandidate
|
||||
},
|
||||
};
|
||||
|
||||
export const generationSetCompareModeCommand: Command<GenerationSetCompareModePayload> = {
|
||||
id: commandIds.generationSetCompareMode,
|
||||
name: "Set generation compare mode",
|
||||
history: { mode: "ignore" },
|
||||
execute({ state }, payload) {
|
||||
if (!generationCompareModes.has(payload.mode)) return state;
|
||||
if (state.editor.generation.compareMode === payload.mode) return state;
|
||||
return {
|
||||
...state,
|
||||
editor: {
|
||||
...state.editor,
|
||||
generation: {
|
||||
...state.editor.generation,
|
||||
compareMode: payload.mode,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const generationRemoveCandidateCommand: Command<GenerationRemoveCandidatePayload> = {
|
||||
id: commandIds.generationRemoveCandidate,
|
||||
name: "Remove generation candidate",
|
||||
@@ -88,6 +115,7 @@ export const generationRemoveCandidateCommand: Command<GenerationRemoveCandidate
|
||||
generation: {
|
||||
candidates,
|
||||
selectedCandidateId,
|
||||
compareMode: candidates.length > 0 ? state.editor.generation.compareMode : "result",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -99,12 +127,12 @@ export const generationClearCandidatesCommand: Command = {
|
||||
name: "Clear generation candidates",
|
||||
history: { mode: "ignore" },
|
||||
execute({ state }) {
|
||||
if (state.editor.generation.candidates.length === 0 && !state.editor.generation.selectedCandidateId) return state;
|
||||
if (state.editor.generation.candidates.length === 0 && !state.editor.generation.selectedCandidateId && state.editor.generation.compareMode === "result") return state;
|
||||
return {
|
||||
...state,
|
||||
editor: {
|
||||
...state.editor,
|
||||
generation: { candidates: [], selectedCandidateId: undefined },
|
||||
generation: { candidates: [], selectedCandidateId: undefined, compareMode: "result" },
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -125,6 +153,7 @@ export const generationApplyCandidateAsLayerCommand: Command<GenerationApplyCand
|
||||
mimeType: candidate.mimeType,
|
||||
source: candidate.source,
|
||||
intrinsicSize: { ...candidate.intrinsicSize },
|
||||
provenance: generationProvenance(candidate, payload.variant ? "variant-layer" : "layer"),
|
||||
};
|
||||
const layer: ImageLayer = {
|
||||
id: payload.layerId,
|
||||
@@ -167,7 +196,16 @@ export const generationReplaceCandidatePixelsCommand: Command<GenerationReplaceC
|
||||
...state,
|
||||
document: {
|
||||
...state.document,
|
||||
assets: state.document.assets.map((asset) => asset.id === targetAsset.id ? { ...asset, source: payload.source, mimeType: payload.mimeType ?? asset.mimeType } : asset),
|
||||
assets: state.document.assets.map((asset) =>
|
||||
asset.id === targetAsset.id
|
||||
? {
|
||||
...asset,
|
||||
source: payload.source,
|
||||
mimeType: payload.mimeType ?? asset.mimeType,
|
||||
provenance: generationProvenance(candidate, "replacement"),
|
||||
}
|
||||
: asset,
|
||||
),
|
||||
},
|
||||
editor: {
|
||||
...state.editor,
|
||||
@@ -181,6 +219,7 @@ export const generationReplaceCandidatePixelsCommand: Command<GenerationReplaceC
|
||||
export const generationCommands = [
|
||||
generationAddCandidateCommand,
|
||||
generationSelectCandidateCommand,
|
||||
generationSetCompareModeCommand,
|
||||
generationRemoveCandidateCommand,
|
||||
generationClearCandidatesCommand,
|
||||
generationApplyCandidateAsLayerCommand,
|
||||
@@ -219,6 +258,58 @@ function findLayerInTree(layers: readonly Layer[], layerId: LayerId): Layer | un
|
||||
}
|
||||
|
||||
function clearCommittedGenerationPreview(generation: GenerationState): GenerationState {
|
||||
if (generation.candidates.length === 0 && !generation.selectedCandidateId) return generation;
|
||||
return { candidates: [], selectedCandidateId: undefined };
|
||||
if (generation.candidates.length === 0 && !generation.selectedCandidateId && generation.compareMode === "result") return generation;
|
||||
return { candidates: [], selectedCandidateId: undefined, compareMode: "result" };
|
||||
}
|
||||
|
||||
function generationProvenance(candidate: GenerationCandidate, acceptance: GeneratedAssetAcceptance): AssetGenerationProvenance {
|
||||
return {
|
||||
kind: "generated",
|
||||
candidateId: candidate.id,
|
||||
mode: candidate.mode,
|
||||
acceptance,
|
||||
prompt: candidate.settings.prompt,
|
||||
negativePrompt: candidate.settings.negativePrompt,
|
||||
seed: candidate.seed,
|
||||
outputSize: { ...candidate.intrinsicSize },
|
||||
settings: {
|
||||
architecture: candidate.settings.architecture,
|
||||
model: candidate.settings.model,
|
||||
textEncoder: candidate.settings.textEncoder,
|
||||
vae: candidate.settings.vae,
|
||||
strength: candidate.settings.strength,
|
||||
steps: candidate.settings.steps,
|
||||
cfg: candidate.settings.cfg,
|
||||
sampler: candidate.settings.sampler,
|
||||
scheduler: candidate.settings.scheduler,
|
||||
width: candidate.settings.width,
|
||||
height: candidate.settings.height,
|
||||
},
|
||||
inpaint: candidate.inpaint
|
||||
? {
|
||||
targetLayerId: candidate.inpaint.targetLayerId,
|
||||
maskLayerId: candidate.inpaint.maskLayerId,
|
||||
sourceAssetId: candidate.inpaint.sourceAssetId,
|
||||
maskAssetId: candidate.inpaint.maskAssetId,
|
||||
crop: {
|
||||
assetBounds: { ...candidate.inpaint.crop.assetBounds },
|
||||
documentBounds: { ...candidate.inpaint.crop.documentBounds },
|
||||
padding: candidate.inpaint.crop.padding,
|
||||
maskedAreaOnly: candidate.inpaint.crop.maskedAreaOnly,
|
||||
},
|
||||
mask: {
|
||||
polarity: candidate.inpaint.mask.polarity,
|
||||
activeBounds: { ...candidate.inpaint.mask.activeBounds },
|
||||
},
|
||||
backend: {
|
||||
growMaskBy: candidate.inpaint.backend.growMaskBy,
|
||||
maskedContent: candidate.inpaint.backend.maskedContent,
|
||||
maskBlur: candidate.inpaint.backend.maskBlur,
|
||||
maskFeather: candidate.inpaint.backend.maskFeather,
|
||||
maskExpand: candidate.inpaint.backend.maskExpand,
|
||||
cropPadding: candidate.inpaint.backend.cropPadding,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ export const commandIds = {
|
||||
toolExitTemporaryPan: "tool.exitTemporaryPan",
|
||||
generationAddCandidate: "generation.addCandidate",
|
||||
generationSelectCandidate: "generation.selectCandidate",
|
||||
generationSetCompareMode: "generation.setCompareMode",
|
||||
generationRemoveCandidate: "generation.removeCandidate",
|
||||
generationClearCandidates: "generation.clearCandidates",
|
||||
generationApplyCandidateAsLayer: "generation.applyCandidateAsLayer",
|
||||
|
||||
@@ -58,6 +58,7 @@ export {
|
||||
generationRemoveCandidateCommand,
|
||||
generationReplaceCandidatePixelsCommand,
|
||||
generationSelectCandidateCommand,
|
||||
generationSetCompareModeCommand,
|
||||
} from "./generation";
|
||||
export type {
|
||||
GenerationAddCandidatePayload,
|
||||
@@ -65,6 +66,7 @@ export type {
|
||||
GenerationRemoveCandidatePayload,
|
||||
GenerationReplaceCandidatePixelsPayload,
|
||||
GenerationSelectCandidatePayload,
|
||||
GenerationSetCompareModePayload,
|
||||
} from "./generation";
|
||||
export type { CommandDispatcher, Dispatch } from "./dispatcher";
|
||||
export type { CommandId, CommandPayloads } from "./payloads";
|
||||
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
GenerationRemoveCandidatePayload,
|
||||
GenerationReplaceCandidatePixelsPayload,
|
||||
GenerationSelectCandidatePayload,
|
||||
GenerationSetCompareModePayload,
|
||||
} from "./generation";
|
||||
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
||||
import type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool";
|
||||
@@ -81,6 +82,7 @@ export type CommandPayloads = {
|
||||
[commandIds.toolExitTemporaryPan]: void;
|
||||
[commandIds.generationAddCandidate]: GenerationAddCandidatePayload;
|
||||
[commandIds.generationSelectCandidate]: GenerationSelectCandidatePayload;
|
||||
[commandIds.generationSetCompareMode]: GenerationSetCompareModePayload;
|
||||
[commandIds.generationRemoveCandidate]: GenerationRemoveCandidatePayload;
|
||||
[commandIds.generationClearCandidates]: void;
|
||||
[commandIds.generationApplyCandidateAsLayer]: GenerationApplyCandidateAsLayerPayload;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ImageDocument } from "@core/document";
|
||||
import type { Vec2D } from "@core/geometry";
|
||||
import type { LayerId, ArtboardId, AssetId } 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, GenerateSettings, MagicWandSettings, ToolId } from "@editor/tools";
|
||||
@@ -253,7 +254,7 @@ export const toolEnterMaskEditCommand: Command<ToolEnterMaskEditPayload> = {
|
||||
const targetLocation = findLayerLocation(state.document, payload.targetLayerId);
|
||||
const maskLocation = findLayerLocation(state.document, payload.maskLayerId);
|
||||
if (!targetLocation || !maskLocation) return state;
|
||||
if (targetLocation.layer.clippingMask?.maskLayerId !== payload.maskLayerId) return state;
|
||||
if (getLayerMask(targetLocation.layer)?.maskLayerId !== payload.maskLayerId) return state;
|
||||
if (maskLocation.layer.type === "group") return state;
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user