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:
syntaxbullet
2026-07-05 16:06:23 +02:00
parent a83024c35c
commit 5eaf37ba28
33 changed files with 635 additions and 88 deletions

View File

@@ -154,6 +154,35 @@ describe("document commands", () => {
expect(ungrouped.editor.selection.layerIds).toEqual(["a", "b"]); 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", () => { test("removes layers", () => {
const state = documentWithLayers([group("a", "A"), group("b", "B")]); const state = documentWithLayers([group("a", "A"), group("b", "B")]);
const selectedState = { ...state, editor: { ...state.editor, selection: { artboardId: "a1", layerIds: ["a"] } } }; 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.assets).toContainEqual(maskAsset());
expect(masked.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["mask", "target"]); 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.document.artboards[0]?.layers[1]?.clippingMask).toEqual({ maskLayerId: "mask" });
expect(masked.editor.maskEdit).toEqual({ targetLayerId: "target", maskLayerId: "mask" }); expect(masked.editor.maskEdit).toEqual({ targetLayerId: "target", maskLayerId: "mask" });
expect(masked.editor.tools.activeTool).toBe("brush"); expect(masked.editor.tools.activeTool).toBe("brush");
@@ -233,6 +268,7 @@ describe("document commands", () => {
const unmasked = documentRemoveLayerMaskCommand.execute({ state }, { layerId: "target" }); 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.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.document.artboards[0]?.layers[0]?.clippingMask).toBeUndefined();
expect(unmasked.editor.maskEdit).toBeUndefined(); expect(unmasked.editor.maskEdit).toBeUndefined();
}); });

View File

@@ -4,6 +4,7 @@ import type { Rect } from "@core/geometry";
import type { ArtboardId, AssetId, LayerId } from "@core/id"; import type { ArtboardId, AssetId, LayerId } from "@core/id";
import type { ImageLayer } from "@core/image-layer"; import type { ImageLayer } from "@core/image-layer";
import type { Layer } from "@core/layer"; import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
import type { RasterLayer } from "@core/raster-layer"; import type { RasterLayer } from "@core/raster-layer";
import type { LayerGroup } from "@core/layer-group"; import type { LayerGroup } from "@core/layer-group";
import type { Command } from "./command"; import type { Command } from "./command";
@@ -316,7 +317,7 @@ export const documentMoveLayerCommand: Command<DocumentMoveLayerPayload> = {
const removed = removeLayerFromDocument(state.document, payload.layerId); const removed = removeLayerFromDocument(state.document, payload.layerId);
if (!removed.layer) return state; 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 removedMask = maskLayerId ? removeLayerFromDocument(removed.document, maskLayerId) : undefined;
const documentAfterRemoval = removedMask?.document ?? removed.document; const documentAfterRemoval = removedMask?.document ?? removed.document;
if (payload.toParentGroupId && !findGroup(documentAfterRemoval, payload.toParentGroupId)) return state; if (payload.toParentGroupId && !findGroup(documentAfterRemoval, payload.toParentGroupId)) return state;
@@ -338,25 +339,29 @@ export const documentGroupLayersCommand: Command<DocumentGroupLayersPayload> = {
execute({ state }, payload) { execute({ state }, payload) {
const requestedIds = [...new Set(payload.layerIds)]; const requestedIds = [...new Set(payload.layerIds)];
if (requestedIds.length === 0) return state; 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); const requestedLocations = requestedIds.flatMap((layerId) => {
if (!artboard) return state; 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; 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 group: LayerGroup = { ...payload.group, children: selected };
const document = { const document = replaceLayerListInDocument(
...state.document, state.document,
artboards: state.document.artboards.map((candidate) => firstLocation.artboardId,
candidate.id === payload.artboardId firstLocation.parentGroupId,
? { ...candidate, layers: [...candidate.layers.filter((layer) => !uniqueIds.includes(layer.id)).slice(0, firstIndex), group, ...candidate.layers.filter((layer) => !uniqueIds.includes(layer.id)).slice(firstIndex)] } replaceSelectedLayersWithGroup(firstLocation.siblings, uniqueIds, group),
: candidate, );
),
};
return { return {
...state, ...state,
@@ -414,7 +419,7 @@ export const documentSetLayerClippingMaskCommand: Command<DocumentSetLayerClippi
if (payload.maskLayerId === payload.layerId) return state; if (payload.maskLayerId === payload.layerId) return state;
if (!payload.maskLayerId) { if (!payload.maskLayerId) {
const previousMaskId = findLayerLocation(state.document, payload.layerId)?.layer.clippingMask?.maskLayerId; const previousMaskId = getLayerMask(findLayerLocation(state.document, payload.layerId)?.layer)?.maskLayerId;
return { return {
...state, ...state,
document: mapLayerInDocument(state.document, payload.layerId, (layer) => removeLayerMaskReference(layer)), document: mapLayerInDocument(state.document, payload.layerId, (layer) => removeLayerMaskReference(layer)),
@@ -442,7 +447,7 @@ export const documentSetLayerClippingMaskCommand: Command<DocumentSetLayerClippi
removed.document, removed.document,
maskLocationAfterRemoval.artboardId, maskLocationAfterRemoval.artboardId,
maskLocationAfterRemoval.parentGroupId, maskLocationAfterRemoval.parentGroupId,
{ ...removed.layer, clippingMask: { maskLayerId: payload.maskLayerId } }, withLayerMask(removed.layer, payload.maskLayerId),
maskLocationAfterRemoval.index + 1, maskLocationAfterRemoval.index + 1,
), ),
}; };
@@ -456,7 +461,7 @@ export const documentAddLayerMaskCommand: Command<DocumentAddLayerMaskPayload> =
const targetLocation = findLayerLocation(state.document, payload.layerId); const targetLocation = findLayerLocation(state.document, payload.layerId);
if (!targetLocation || targetLocation.layer.type === "group") return state; if (!targetLocation || targetLocation.layer.type === "group") return state;
const existingMaskId = targetLocation.layer.clippingMask?.maskLayerId; const existingMaskId = getLayerMask(targetLocation.layer)?.maskLayerId;
if (existingMaskId) { if (existingMaskId) {
const existingMaskLocation = findLayerLocation(state.document, existingMaskId); const existingMaskLocation = findLayerLocation(state.document, existingMaskId);
if (existingMaskLocation?.layer.type === "group") return state; if (existingMaskLocation?.layer.type === "group") return state;
@@ -486,11 +491,12 @@ export const documentAddLayerMaskCommand: Command<DocumentAddLayerMaskPayload> =
visible: true, visible: true,
locked: false, locked: false,
opacity: 1, opacity: 1,
layerMask: undefined,
clippingMask: undefined, clippingMask: undefined,
}; };
const withAsset: ImageDocument = { ...state.document, assets: [...state.document.assets, payload.asset] }; const withAsset: ImageDocument = { ...state.document, assets: [...state.document.assets, payload.asset] };
const withMaskLayer = insertLayer(withAsset, targetLocation.artboardId, targetLocation.parentGroupId, maskLayer, targetLocation.index); 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 { return {
...state, ...state,
@@ -546,7 +552,7 @@ export const documentRemoveLayerMaskCommand: Command<DocumentRemoveLayerMaskPayl
name: "Remove layer mask", name: "Remove layer mask",
execute({ state }, payload) { execute({ state }, payload) {
const targetLocation = findLayerLocation(state.document, payload.layerId); 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) { if (!targetLocation || !maskLayerId) {
return state.editor.maskEdit?.targetLayerId === payload.layerId ? { ...state, editor: { ...state.editor, maskEdit: undefined } } : state; return state.editor.maskEdit?.targetLayerId === payload.layerId ? { ...state, editor: { ...state.editor, maskEdit: undefined } } : state;
} }
@@ -624,6 +630,7 @@ type LayerLocation = {
parentGroupId?: LayerId; parentGroupId?: LayerId;
index: number; index: number;
layer: Layer; layer: Layer;
siblings: readonly Layer[];
}; };
function findLayerLocation(document: ImageDocument, layerId: LayerId): LayerLocation | undefined { 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 { function isReferencedMaskLayerInTree(layers: readonly Layer[], maskLayerId: LayerId): boolean {
for (const layer of layers) { 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; if (layer.type === "group" && isReferencedMaskLayerInTree(layer.children, maskLayerId)) return true;
} }
return false; return false;
@@ -650,7 +657,7 @@ function findLayerLocationInTree(layers: Layer[], layerId: LayerId, artboardId:
for (let index = 0; index < layers.length; index++) { for (let index = 0; index < layers.length; index++) {
const layer = layers[index]; const layer = layers[index];
if (!layer) continue; 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") { if (layer.type === "group") {
const child = findLayerLocationInTree(layer.children, layerId, artboardId, layer.id); const child = findLayerLocationInTree(layer.children, layerId, artboardId, layer.id);
if (child) return child; 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 } { function removeLayerFromDocument(document: ImageDocument, layerId: LayerId): { document: ImageDocument; layer?: Layer } {
let removed: Layer | undefined; let removed: Layer | undefined;
return { return {
@@ -785,10 +827,24 @@ function findGroupInTree(layers: Layer[], groupId: LayerId): LayerGroup | undefi
function removeLayerMaskReference(layer: Layer): Layer { function removeLayerMaskReference(layer: Layer): Layer {
const next = { ...layer }; const next = { ...layer };
delete next.layerMask;
delete next.clippingMask; delete next.clippingMask;
return next; 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 { function removeUnreferencedMaskLayer(document: ImageDocument, maskLayerId: LayerId): ImageDocument {
if (isMaskLayerReferenced(document, maskLayerId)) return document; if (isMaskLayerReferenced(document, maskLayerId)) return document;
return removeLayerFromDocument(document, maskLayerId).document; return removeLayerFromDocument(document, maskLayerId).document;
@@ -801,7 +857,8 @@ function isMaskLayerReferenced(document: ImageDocument, maskLayerId: LayerId): b
function removeMissingMaskReferences(document: ImageDocument): ImageDocument { function removeMissingMaskReferences(document: ImageDocument): ImageDocument {
const existingLayerIds = collectDocumentLayerIds(document); const existingLayerIds = collectDocumentLayerIds(document);
return mapAllLayersInDocument(document, (layer) => { 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); return removeLayerMaskReference(layer);
}); });
} }
@@ -814,7 +871,7 @@ function isMaskEditValid(maskEdit: { targetLayerId: LayerId; maskLayerId: LayerI
if (!maskEdit) return false; if (!maskEdit) return false;
const target = findLayerLocation(document, maskEdit.targetLayerId)?.layer; const target = findLayerLocation(document, maskEdit.targetLayerId)?.layer;
const mask = findLayerLocation(document, maskEdit.maskLayerId)?.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> { 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> { function collectClippingMaskIds(layers: readonly Layer[], ids = new Set<LayerId>()): Set<LayerId> {
for (const layer of layers) { 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); if (layer.type === "group") collectClippingMaskIds(layer.children, ids);
} }
return 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[] { function collectAttachedMaskIds(layers: readonly Layer[], layerIds: readonly LayerId[]): LayerId[] {
const layerIdSet = new Set(layerIds); 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 { function mapAllLayersInDocument(document: ImageDocument, mapLayer: (layer: Layer) => Layer): ImageDocument {

View File

@@ -6,6 +6,7 @@ import {
generationApplyCandidateAsLayerCommand, generationApplyCandidateAsLayerCommand,
generationRemoveCandidateCommand, generationRemoveCandidateCommand,
generationReplaceCandidatePixelsCommand, generationReplaceCandidatePixelsCommand,
generationSetCompareModeCommand,
} from "./generation"; } from "./generation";
describe("generation commands", () => { describe("generation commands", () => {
@@ -18,8 +19,20 @@ describe("generation commands", () => {
expect(added.editor.generation.candidates).toEqual([candidate]); expect(added.editor.generation.candidates).toEqual([candidate]);
expect(added.editor.generation.selectedCandidateId).toBe(candidate.id); 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.candidates).toEqual([]);
expect(removed.editor.generation.selectedCandidateId).toBeUndefined(); 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", () => { 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")?.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.document.artboards[0]?.layers[0]?.id).toBe("generated-layer");
expect(next.editor.selection).toEqual({ artboardId: "a1", layerIds: ["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", () => { 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")?.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")?.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.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" });
}); });
}); });

View File

@@ -1,9 +1,10 @@
import type { Asset } from "@core/asset"; import type { Asset } from "@core/asset";
import type { AssetGenerationProvenance, GeneratedAssetAcceptance } from "@core/asset-provenance";
import type { ImageDocument } from "@core/document"; import type { ImageDocument } from "@core/document";
import type { ArtboardId, AssetId, LayerId } from "@core/id"; import type { ArtboardId, AssetId, LayerId } from "@core/id";
import type { ImageLayer } from "@core/image-layer"; import type { ImageLayer } from "@core/image-layer";
import type { Layer } from "@core/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 type { Command } from "./command";
import { commandIds } from "./ids"; import { commandIds } from "./ids";
@@ -15,6 +16,10 @@ export type GenerationSelectCandidatePayload = {
candidateId?: string; candidateId?: string;
}; };
export type GenerationSetCompareModePayload = {
mode: GenerationCompareMode;
};
export type GenerationRemoveCandidatePayload = { export type GenerationRemoveCandidatePayload = {
candidateId: string; candidateId: string;
}; };
@@ -33,6 +38,7 @@ export type GenerationReplaceCandidatePixelsPayload = {
}; };
const maxCandidates = 12; const maxCandidates = 12;
const generationCompareModes = new Set<GenerationCompareMode>(["result", "before", "split"]);
export const generationAddCandidateCommand: Command<GenerationAddCandidatePayload> = { export const generationAddCandidateCommand: Command<GenerationAddCandidatePayload> = {
id: commandIds.generationAddCandidate, id: commandIds.generationAddCandidate,
@@ -47,6 +53,7 @@ export const generationAddCandidateCommand: Command<GenerationAddCandidatePayloa
generation: { generation: {
candidates, candidates,
selectedCandidateId: payload.candidate.id, 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> = { export const generationRemoveCandidateCommand: Command<GenerationRemoveCandidatePayload> = {
id: commandIds.generationRemoveCandidate, id: commandIds.generationRemoveCandidate,
name: "Remove generation candidate", name: "Remove generation candidate",
@@ -88,6 +115,7 @@ export const generationRemoveCandidateCommand: Command<GenerationRemoveCandidate
generation: { generation: {
candidates, candidates,
selectedCandidateId, selectedCandidateId,
compareMode: candidates.length > 0 ? state.editor.generation.compareMode : "result",
}, },
}, },
}; };
@@ -99,12 +127,12 @@ export const generationClearCandidatesCommand: Command = {
name: "Clear generation candidates", name: "Clear generation candidates",
history: { mode: "ignore" }, history: { mode: "ignore" },
execute({ state }) { 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 { return {
...state, ...state,
editor: { editor: {
...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, mimeType: candidate.mimeType,
source: candidate.source, source: candidate.source,
intrinsicSize: { ...candidate.intrinsicSize }, intrinsicSize: { ...candidate.intrinsicSize },
provenance: generationProvenance(candidate, payload.variant ? "variant-layer" : "layer"),
}; };
const layer: ImageLayer = { const layer: ImageLayer = {
id: payload.layerId, id: payload.layerId,
@@ -167,7 +196,16 @@ export const generationReplaceCandidatePixelsCommand: Command<GenerationReplaceC
...state, ...state,
document: { document: {
...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: { editor: {
...state.editor, ...state.editor,
@@ -181,6 +219,7 @@ export const generationReplaceCandidatePixelsCommand: Command<GenerationReplaceC
export const generationCommands = [ export const generationCommands = [
generationAddCandidateCommand, generationAddCandidateCommand,
generationSelectCandidateCommand, generationSelectCandidateCommand,
generationSetCompareModeCommand,
generationRemoveCandidateCommand, generationRemoveCandidateCommand,
generationClearCandidatesCommand, generationClearCandidatesCommand,
generationApplyCandidateAsLayerCommand, generationApplyCandidateAsLayerCommand,
@@ -219,6 +258,58 @@ function findLayerInTree(layers: readonly Layer[], layerId: LayerId): Layer | un
} }
function clearCommittedGenerationPreview(generation: GenerationState): GenerationState { function clearCommittedGenerationPreview(generation: GenerationState): GenerationState {
if (generation.candidates.length === 0 && !generation.selectedCandidateId) return generation; if (generation.candidates.length === 0 && !generation.selectedCandidateId && generation.compareMode === "result") return generation;
return { candidates: [], selectedCandidateId: undefined }; 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,
};
} }

View File

@@ -38,6 +38,7 @@ export const commandIds = {
toolExitTemporaryPan: "tool.exitTemporaryPan", toolExitTemporaryPan: "tool.exitTemporaryPan",
generationAddCandidate: "generation.addCandidate", generationAddCandidate: "generation.addCandidate",
generationSelectCandidate: "generation.selectCandidate", generationSelectCandidate: "generation.selectCandidate",
generationSetCompareMode: "generation.setCompareMode",
generationRemoveCandidate: "generation.removeCandidate", generationRemoveCandidate: "generation.removeCandidate",
generationClearCandidates: "generation.clearCandidates", generationClearCandidates: "generation.clearCandidates",
generationApplyCandidateAsLayer: "generation.applyCandidateAsLayer", generationApplyCandidateAsLayer: "generation.applyCandidateAsLayer",

View File

@@ -58,6 +58,7 @@ export {
generationRemoveCandidateCommand, generationRemoveCandidateCommand,
generationReplaceCandidatePixelsCommand, generationReplaceCandidatePixelsCommand,
generationSelectCandidateCommand, generationSelectCandidateCommand,
generationSetCompareModeCommand,
} from "./generation"; } from "./generation";
export type { export type {
GenerationAddCandidatePayload, GenerationAddCandidatePayload,
@@ -65,6 +66,7 @@ export type {
GenerationRemoveCandidatePayload, GenerationRemoveCandidatePayload,
GenerationReplaceCandidatePixelsPayload, GenerationReplaceCandidatePixelsPayload,
GenerationSelectCandidatePayload, GenerationSelectCandidatePayload,
GenerationSetCompareModePayload,
} from "./generation"; } from "./generation";
export type { CommandDispatcher, Dispatch } from "./dispatcher"; export type { CommandDispatcher, Dispatch } from "./dispatcher";
export type { CommandId, CommandPayloads } from "./payloads"; export type { CommandId, CommandPayloads } from "./payloads";

View File

@@ -29,6 +29,7 @@ import type {
GenerationRemoveCandidatePayload, GenerationRemoveCandidatePayload,
GenerationReplaceCandidatePixelsPayload, GenerationReplaceCandidatePixelsPayload,
GenerationSelectCandidatePayload, GenerationSelectCandidatePayload,
GenerationSetCompareModePayload,
} from "./generation"; } from "./generation";
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection"; import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
import type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool"; import type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool";
@@ -81,6 +82,7 @@ export type CommandPayloads = {
[commandIds.toolExitTemporaryPan]: void; [commandIds.toolExitTemporaryPan]: void;
[commandIds.generationAddCandidate]: GenerationAddCandidatePayload; [commandIds.generationAddCandidate]: GenerationAddCandidatePayload;
[commandIds.generationSelectCandidate]: GenerationSelectCandidatePayload; [commandIds.generationSelectCandidate]: GenerationSelectCandidatePayload;
[commandIds.generationSetCompareMode]: GenerationSetCompareModePayload;
[commandIds.generationRemoveCandidate]: GenerationRemoveCandidatePayload; [commandIds.generationRemoveCandidate]: GenerationRemoveCandidatePayload;
[commandIds.generationClearCandidates]: void; [commandIds.generationClearCandidates]: void;
[commandIds.generationApplyCandidateAsLayer]: GenerationApplyCandidateAsLayerPayload; [commandIds.generationApplyCandidateAsLayer]: GenerationApplyCandidateAsLayerPayload;

View File

@@ -2,6 +2,7 @@ import type { ImageDocument } from "@core/document";
import type { Vec2D } from "@core/geometry"; import type { Vec2D } from "@core/geometry";
import type { LayerId, ArtboardId, AssetId } from "@core/id"; import type { LayerId, ArtboardId, AssetId } from "@core/id";
import type { Layer } from "@core/layer"; import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
import type { MaskViewMode } from "@editor/state"; import type { MaskViewMode } from "@editor/state";
import { generateArchitectureDefaults } from "@editor/tools"; import { generateArchitectureDefaults } from "@editor/tools";
import type { BrushSettings, ChromaKeySettings, GenerateSettings, MagicWandSettings, ToolId } 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 targetLocation = findLayerLocation(state.document, payload.targetLayerId);
const maskLocation = findLayerLocation(state.document, payload.maskLayerId); const maskLocation = findLayerLocation(state.document, payload.maskLayerId);
if (!targetLocation || !maskLocation) return state; 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; if (maskLocation.layer.type === "group") return state;
return { return {

56
core/asset-provenance.ts Normal file
View File

@@ -0,0 +1,56 @@
import type { Rect, Size } from "./geometry";
import type { AssetId, LayerId } from "./id";
export type GeneratedAssetMode = "text-to-image" | "image-to-image" | "inpaint" | "outpaint";
export type GeneratedAssetAcceptance = "layer" | "variant-layer" | "replacement";
export type AssetGenerationProvenance = {
kind: "generated";
candidateId: string;
mode: GeneratedAssetMode;
acceptance: GeneratedAssetAcceptance;
prompt: string;
negativePrompt: string;
seed: number;
outputSize: Size;
settings: {
architecture: string;
model: string;
textEncoder: string;
vae: string;
strength: number;
steps: number;
cfg: number;
sampler: string;
scheduler: string;
width: number;
height: number;
};
inpaint?: {
targetLayerId: LayerId;
maskLayerId: LayerId;
sourceAssetId: AssetId;
maskAssetId: AssetId;
crop: {
assetBounds: Rect;
documentBounds: Rect;
padding: number;
maskedAreaOnly: boolean;
};
mask: {
polarity: "hidden" | "revealed";
activeBounds: Rect;
};
backend: {
growMaskBy: number;
maskedContent: string;
maskBlur: number;
maskFeather: number;
maskExpand: number;
cropPadding: number;
};
};
};
export type AssetProvenance = AssetGenerationProvenance;

View File

@@ -1,5 +1,6 @@
import type { Size } from "./geometry"; import type { Size } from "./geometry";
import type { AssetId } from "./id"; import type { AssetId } from "./id";
import type { AssetProvenance } from "./asset-provenance";
export type Asset = { export type Asset = {
id: AssetId; id: AssetId;
@@ -7,4 +8,5 @@ export type Asset = {
mimeType: string; mimeType: string;
source: string; source: string;
intrinsicSize: Size; intrinsicSize: Size;
provenance?: AssetProvenance;
}; };

View File

@@ -1,5 +1,6 @@
import type { Transform } from "./geometry"; import type { Transform } from "./geometry";
import type { LayerId } from "./id"; import type { LayerId } from "./id";
import type { LayerMask } from "./layer-mask";
export type LayerClippingMask = { export type LayerClippingMask = {
maskLayerId: LayerId; maskLayerId: LayerId;
@@ -12,5 +13,7 @@ export type BaseLayer = {
locked: boolean; locked: boolean;
opacity: number; opacity: number;
transform: Transform; transform: Transform;
layerMask?: LayerMask;
/** @deprecated Use layerMask. Kept readable for older documents. */
clippingMask?: LayerClippingMask; clippingMask?: LayerClippingMask;
}; };

View File

@@ -1,5 +1,6 @@
export type { Artboard } from "./artboard"; export type { Artboard } from "./artboard";
export type { Asset } from "./asset"; export type { Asset } from "./asset";
export type { AssetGenerationProvenance, AssetProvenance, GeneratedAssetAcceptance, GeneratedAssetMode } from "./asset-provenance";
export type { BaseLayer, LayerClippingMask } from "./base-layer"; export type { BaseLayer, LayerClippingMask } from "./base-layer";
export type { ImageDocument } from "./document"; export type { ImageDocument } from "./document";
export type { export type {
@@ -15,5 +16,7 @@ export type {
export type { ArtboardId, AssetId, DocumentId, LayerId } from "./id"; export type { ArtboardId, AssetId, DocumentId, LayerId } from "./id";
export type { ImageLayer } from "./image-layer"; export type { ImageLayer } from "./image-layer";
export type { Layer } from "./layer"; export type { Layer } from "./layer";
export type { LayerMask } from "./layer-mask";
export { getLayerMask, hasLayerMask } from "./layer-mask-utils";
export type { LayerGroup } from "./layer-group"; export type { LayerGroup } from "./layer-group";
export type { RasterLayer } from "./raster-layer"; export type { RasterLayer } from "./raster-layer";

18
core/layer-mask-utils.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { Layer } from "./layer";
import type { LayerMask } from "./layer-mask";
export function getLayerMask(layer: Layer | undefined): LayerMask | undefined {
if (layer.layerMask) return layer.layerMask;
if (!layer.clippingMask) return undefined;
return {
kind: "raster",
maskLayerId: layer.clippingMask.maskLayerId,
enabled: true,
inverted: false,
};
}
export function hasLayerMask(layer: Layer): boolean {
return Boolean(getLayerMask(layer));
}

8
core/layer-mask.ts Normal file
View File

@@ -0,0 +1,8 @@
import type { LayerId } from "./id";
export type LayerMask = {
kind: "raster";
maskLayerId: LayerId;
enabled: boolean;
inverted: boolean;
};

View File

@@ -22,7 +22,10 @@ const document: ImageDocument = {
locked: false, locked: false,
layers: [ layers: [
raster("mask", "Mask", "asset-mask"), raster("mask", "Mask", "asset-mask"),
{ ...raster("target", "Target", "asset-target", { x: 10, y: 20 }, { x: 0.5, y: 0.5 }), clippingMask: { maskLayerId: "mask" } }, {
...raster("target", "Target", "asset-target", { x: 10, y: 20 }, { x: 0.5, y: 0.5 }),
layerMask: { kind: "raster", maskLayerId: "mask", enabled: true, inverted: false },
},
group("group", "Group", [ group("group", "Group", [
raster("nested-mask", "Nested Mask", "asset-mask"), raster("nested-mask", "Nested Mask", "asset-mask"),
{ ...raster("nested-target", "Nested Target", "asset-nested", { x: 80, y: 10 }, { x: 2, y: 3 }), clippingMask: { maskLayerId: "nested-mask" } }, { ...raster("nested-target", "Nested Target", "asset-nested", { x: 80, y: 10 }, { x: 2, y: 3 }), clippingMask: { maskLayerId: "nested-mask" } },

View File

@@ -3,6 +3,7 @@ import type { ImageDocument } from "@core/document";
import type { Rect } from "@core/geometry"; import type { Rect } from "@core/geometry";
import type { ArtboardId, AssetId, LayerId } from "@core/id"; import type { ArtboardId, AssetId, LayerId } from "@core/id";
import type { Layer } from "@core/layer"; import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
export type IndexedLayerInfo = { export type IndexedLayerInfo = {
artboardId: ArtboardId; artboardId: ArtboardId;
@@ -113,9 +114,10 @@ function indexLayerTree(options: {
index, index,
}); });
if (layer.clippingMask) { const layerMask = getLayerMask(layer);
options.documentMaskLayerIds.add(layer.clippingMask.maskLayerId); if (layerMask) {
layerListMaskLayerIds.add(layer.clippingMask.maskLayerId); options.documentMaskLayerIds.add(layerMask.maskLayerId);
layerListMaskLayerIds.add(layerMask.maskLayerId);
} }
if (layer.type === "group") { if (layer.type === "group") {

View File

@@ -15,6 +15,7 @@ export const initialEditorState: EditorState = {
generation: { generation: {
candidates: [], candidates: [],
selectedCandidateId: undefined, selectedCandidateId: undefined,
compareMode: "result",
}, },
transformSession: undefined, transformSession: undefined,
maskEdit: undefined, maskEdit: undefined,

View File

@@ -79,9 +79,12 @@ export type GenerationCandidate = {
}; };
}; };
export type GenerationCompareMode = "result" | "before" | "split";
export type GenerationState = { export type GenerationState = {
candidates: GenerationCandidate[]; candidates: GenerationCandidate[];
selectedCandidateId?: string; selectedCandidateId?: string;
compareMode: GenerationCompareMode;
}; };
export type EditorState = { export type EditorState = {

View File

@@ -44,6 +44,10 @@ describe("transform targets", () => {
expect(resolveTransformTargetBounds(document, { type: "layer", id: "l1" })).toEqual({ x: 10, y: 20, w: 100, h: 200 }); expect(resolveTransformTargetBounds(document, { type: "layer", id: "l1" })).toEqual({ x: 10, y: 20, w: 100, h: 200 });
}); });
test("resolves group bounds from descendant layers", () => {
expect(resolveTransformTargetBounds(groupDocument(), { type: "layer", id: "g1" })).toEqual({ x: 10, y: 20, w: 300, h: 200 });
});
test("applies image layer bounds to transform", () => { test("applies image layer bounds to transform", () => {
const next = applyTransformTargetBounds(document, { type: "layer", id: "l1" }, { x: 30, y: 40, w: 400, h: 50 }); const next = applyTransformTargetBounds(document, { type: "layer", id: "l1" }, { x: 30, y: 40, w: 400, h: 50 });
const layer = next.artboards[0]?.layers[0]; const layer = next.artboards[0]?.layers[0];
@@ -81,8 +85,49 @@ describe("transform targets", () => {
expect(next.artboards[0]?.layers[1]?.transform).toEqual({ position: { x: 30, y: 40 }, scale: { x: 2, y: 0.5 }, rotation: 0 }); expect(next.artboards[0]?.layers[1]?.transform).toEqual({ position: { x: 30, y: 40 }, scale: { x: 2, y: 0.5 }, rotation: 0 });
}); });
test("applies group bounds to descendant layer transforms", () => {
const next = applyTransformTargetBounds(groupDocument(), { type: "layer", id: "g1" }, { x: 20, y: 40, w: 600, h: 100 });
const group = next.artboards[0]?.layers[0];
const first = group?.type === "group" ? group.children[0] : undefined;
const second = group?.type === "group" ? group.children[1] : undefined;
expect(first?.transform).toEqual({ position: { x: 20, y: 40 }, scale: { x: 1, y: 1 }, rotation: 0 });
expect(second?.transform).toEqual({ position: { x: 420, y: 90 }, scale: { x: 1, y: 0.5 }, rotation: 0 });
});
test("applies artboard bounds", () => { test("applies artboard bounds", () => {
const next = applyTransformTargetBounds(document, { type: "artboard", id: "a1" }, { x: 10, y: 20, w: 200, h: 160 }); const next = applyTransformTargetBounds(document, { type: "artboard", id: "a1" }, { x: 10, y: 20, w: 200, h: 160 });
expect(next.artboards[0]?.bounds).toEqual({ x: 10, y: 20, w: 200, h: 160 }); expect(next.artboards[0]?.bounds).toEqual({ x: 10, y: 20, w: 200, h: 160 });
}); });
}); });
function groupDocument(): ImageDocument {
return {
...document,
artboards: [
{
...document.artboards[0]!,
layers: [
{
id: "g1",
type: "group",
name: "Group",
visible: true,
locked: false,
opacity: 1,
transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 },
children: [
document.artboards[0]!.layers[0]!,
{
...document.artboards[0]!.layers[0]!,
id: "l2",
name: "Second Layer",
transform: { position: { x: 210, y: 120 }, scale: { x: 0.5, y: 1 }, rotation: 0 },
},
],
},
],
},
],
};
}

View File

@@ -1,6 +1,7 @@
import type { ImageDocument } from "@core/document"; import type { ImageDocument } from "@core/document";
import type { Rect } from "@core/geometry"; import type { Rect } from "@core/geometry";
import type { Layer } from "@core/layer"; import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
import type { ArtboardId, LayerId } from "@core/id"; import type { ArtboardId, LayerId } from "@core/id";
import type { TransformTarget } from "./transform"; import type { TransformTarget } from "./transform";
@@ -35,7 +36,11 @@ export function selectedTransformTarget(document: ImageDocument, selection: { ar
function applyLayerBounds(document: ImageDocument, layerId: LayerId, bounds: Rect): ImageDocument { function applyLayerBounds(document: ImageDocument, layerId: LayerId, bounds: Rect): ImageDocument {
const layer = findLayer(document, layerId); const layer = findLayer(document, layerId);
const targetLayerIds = layer?.clippingMask ? [layerId, layer.clippingMask.maskLayerId] : [layerId]; if (!layer) return document;
if (layer.type === "group") return applyGroupLayerBounds(document, layer.id, bounds);
const layerMask = getLayerMask(layer);
const targetLayerIds = layerMask ? [layerId, layerMask.maskLayerId] : [layerId];
return targetLayerIds.reduce( return targetLayerIds.reduce(
(nextDocument, targetLayerId) => ({ (nextDocument, targetLayerId) => ({
@@ -49,6 +54,67 @@ function applyLayerBounds(document: ImageDocument, layerId: LayerId, bounds: Rec
); );
} }
function applyGroupLayerBounds(document: ImageDocument, groupId: LayerId, bounds: Rect): ImageDocument {
const group = findLayer(document, groupId);
if (!group || group.type !== "group") return document;
const initialBounds = resolveLayerBounds(document, group);
if (!initialBounds || initialBounds.w === 0 || initialBounds.h === 0) return document;
const scale = {
x: bounds.w / initialBounds.w,
y: bounds.h / initialBounds.h,
};
return {
...document,
artboards: document.artboards.map((artboard) => ({
...artboard,
layers: applyGroupLayerBoundsInTree(document, artboard.layers, groupId, initialBounds, bounds, scale),
})),
};
}
function applyGroupLayerBoundsInTree(document: ImageDocument, layers: Layer[], groupId: LayerId, initialBounds: Rect, bounds: Rect, scale: { x: number; y: number }): Layer[] {
return layers.map((layer) => {
if (layer.type === "group" && layer.id === groupId) {
return {
...layer,
children: layer.children.map((child) => scaleLayerSubtree(document, child, initialBounds, bounds, scale)),
};
}
if (layer.type === "group") return { ...layer, children: applyGroupLayerBoundsInTree(document, layer.children, groupId, initialBounds, bounds, scale) };
return layer;
});
}
function scaleLayerSubtree(document: ImageDocument, layer: Layer, initialBounds: Rect, bounds: Rect, scale: { x: number; y: number }): Layer {
if (layer.type === "group") {
return {
...layer,
children: layer.children.map((child) => scaleLayerSubtree(document, child, initialBounds, bounds, scale)),
};
}
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
if (!asset) return layer;
return {
...layer,
transform: {
...layer.transform,
position: {
x: bounds.x + (layer.transform.position.x - initialBounds.x) * scale.x,
y: bounds.y + (layer.transform.position.y - initialBounds.y) * scale.y,
},
scale: {
x: layer.transform.scale.x * scale.x,
y: layer.transform.scale.y * scale.y,
},
},
};
}
function applyLayerBoundsInTree(document: ImageDocument, layers: Layer[], layerId: LayerId, bounds: Rect): Layer[] { function applyLayerBoundsInTree(document: ImageDocument, layers: Layer[], layerId: LayerId, bounds: Rect): Layer[] {
return layers.map((layer) => { return layers.map((layer) => {
if (layer.id === layerId && (layer.type === "image" || layer.type === "raster")) { if (layer.id === layerId && (layer.type === "image" || layer.type === "raster")) {

View File

@@ -41,6 +41,10 @@ describe("layers panel input", () => {
}); });
}); });
test("does not resolve dropping a group into one of its descendants", () => {
expect(resolveLayerDrop({ document, sourceLayerId: "g", target: { artboardId: "a1", layer: group("c") }, verticalRatio: 0.5 })).toBeUndefined();
});
test("dispatches delete commands for selected layers", () => { test("dispatches delete commands for selected layers", () => {
const dispatched: unknown[] = []; const dispatched: unknown[] = [];
const consumed = handleDeleteSelectionKey({ const consumed = handleDeleteSelectionKey({

View File

@@ -41,6 +41,7 @@ export function resolveLayerDrop(options: {
const targetInfo = findLayerInfoInDocument(options.document, options.target.layer.id); const targetInfo = findLayerInfoInDocument(options.document, options.target.layer.id);
const sourceInfo = findLayerInfoInDocument(options.document, options.sourceLayerId); const sourceInfo = findLayerInfoInDocument(options.document, options.sourceLayerId);
if (!targetInfo || !sourceInfo || options.sourceLayerId === options.target.layer.id) return undefined; if (!targetInfo || !sourceInfo || options.sourceLayerId === options.target.layer.id) return undefined;
if (isDescendantLayer(sourceInfo.layer, options.target.layer.id)) return undefined;
const verticalRatio = Math.max(0, Math.min(1, options.verticalRatio)); const verticalRatio = Math.max(0, Math.min(1, options.verticalRatio));
const dropIntoGroup = options.target.layer.type === "group" && verticalRatio >= 0.33 && verticalRatio <= 0.66; const dropIntoGroup = options.target.layer.type === "group" && verticalRatio >= 0.33 && verticalRatio <= 0.66;
@@ -97,3 +98,11 @@ function findLayerInfo(layers: Layer[], layerId: LayerId, artboardId: ArtboardId
} }
return undefined; return undefined;
} }
function isDescendantLayer(layer: Layer, descendantLayerId: LayerId): boolean {
if (layer.type !== "group") return false;
for (const child of layer.children) {
if (child.id === descendantLayerId || isDescendantLayer(child, descendantLayerId)) return true;
}
return false;
}

View File

@@ -2,6 +2,7 @@ import { commandIds } from "@commands/ids";
import type { Dispatch } from "@commands/dispatcher"; import type { Dispatch } from "@commands/dispatcher";
import type { ImageDocument } from "@core/document"; import type { ImageDocument } from "@core/document";
import type { Layer } from "@core/layer"; import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
import { resolveTransformTargetBounds, viewportPointToDocumentPoint, type InputViewportState } from "./document-geometry"; import { resolveTransformTargetBounds, viewportPointToDocumentPoint, type InputViewportState } from "./document-geometry";
import type { PointerInputEvent } from "./pointer"; import type { PointerInputEvent } from "./pointer";
@@ -65,9 +66,9 @@ function findTopmostLayerInTreeAtPoint(document: ImageDocument, layers: Layer[],
function collectMaskLayerIds(layers: readonly Layer[], ids = new Set<string>()): Set<string> { function collectMaskLayerIds(layers: readonly Layer[], ids = new Set<string>()): Set<string> {
for (const layer of layers) { for (const layer of layers) {
if (layer.clippingMask) ids.add(layer.clippingMask.maskLayerId); const layerMask = getLayerMask(layer);
if (layerMask) ids.add(layerMask.maskLayerId);
if (layer.type === "group") collectMaskLayerIds(layer.children, ids); if (layer.type === "group") collectMaskLayerIds(layer.children, ids);
} }
return ids; return ids;
} }

View File

@@ -5,11 +5,11 @@ export type MaskVisualizationMode = "blackWhite" | "alpha" | "hiddenOverlay";
export type ImageTextureRenderer = { export type ImageTextureRenderer = {
syncAssets(assets: readonly Asset[]): void; syncAssets(assets: readonly Asset[]): void;
render(asset: Asset, rect: ScreenRect, clipRect?: ScreenRect): boolean; render(asset: Asset, rect: ScreenRect, clipRect?: ScreenRect, opacity?: number): boolean;
renderMasked(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, clipRect?: ScreenRect): boolean; renderMasked(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, clipRect?: ScreenRect, opacity?: number): boolean;
renderMaskRevealPreview(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, opacity: number, clipRect?: ScreenRect): boolean; renderMaskRevealPreview(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, opacity: number, clipRect?: ScreenRect, layerOpacity?: number): boolean;
renderMaskVisualization(maskAsset: Asset, maskRect: ScreenRect, mode: MaskVisualizationMode, color?: RgbaColor, clipRect?: ScreenRect): boolean; renderMaskVisualization(maskAsset: Asset, maskRect: ScreenRect, mode: MaskVisualizationMode, color?: RgbaColor, clipRect?: ScreenRect): boolean;
renderTinted(asset: Asset, rect: ScreenRect, color: RgbaColor, clipRect?: ScreenRect): boolean; renderTinted(asset: Asset, rect: ScreenRect, color: RgbaColor, clipRect?: ScreenRect, opacity?: number): boolean;
dispose(): void; dispose(): void;
}; };
@@ -39,12 +39,14 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
const positionLocation = gl.getAttribLocation(program, "a_position"); const positionLocation = gl.getAttribLocation(program, "a_position");
const texCoordLocation = gl.getAttribLocation(program, "a_texCoord"); const texCoordLocation = gl.getAttribLocation(program, "a_texCoord");
const samplerLocation = gl.getUniformLocation(program, "u_image"); const samplerLocation = gl.getUniformLocation(program, "u_image");
const opacityLocation = gl.getUniformLocation(program, "u_opacity");
const maskedProgram = createMaskedProgram(gl); const maskedProgram = createMaskedProgram(gl);
const maskedPositionLocation = gl.getAttribLocation(maskedProgram, "a_position"); const maskedPositionLocation = gl.getAttribLocation(maskedProgram, "a_position");
const maskedTexCoordLocation = gl.getAttribLocation(maskedProgram, "a_texCoord"); const maskedTexCoordLocation = gl.getAttribLocation(maskedProgram, "a_texCoord");
const maskedMaskTexCoordLocation = gl.getAttribLocation(maskedProgram, "a_maskTexCoord"); const maskedMaskTexCoordLocation = gl.getAttribLocation(maskedProgram, "a_maskTexCoord");
const maskedSamplerLocation = gl.getUniformLocation(maskedProgram, "u_image"); const maskedSamplerLocation = gl.getUniformLocation(maskedProgram, "u_image");
const maskedMaskSamplerLocation = gl.getUniformLocation(maskedProgram, "u_mask"); const maskedMaskSamplerLocation = gl.getUniformLocation(maskedProgram, "u_mask");
const maskedOpacityLocation = gl.getUniformLocation(maskedProgram, "u_opacity");
const maskRevealPreviewProgram = createMaskRevealPreviewProgram(gl); const maskRevealPreviewProgram = createMaskRevealPreviewProgram(gl);
const maskRevealPreviewPositionLocation = gl.getAttribLocation(maskRevealPreviewProgram, "a_position"); const maskRevealPreviewPositionLocation = gl.getAttribLocation(maskRevealPreviewProgram, "a_position");
const maskRevealPreviewTexCoordLocation = gl.getAttribLocation(maskRevealPreviewProgram, "a_texCoord"); const maskRevealPreviewTexCoordLocation = gl.getAttribLocation(maskRevealPreviewProgram, "a_texCoord");
@@ -69,8 +71,10 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
!texCoordBuffer || !texCoordBuffer ||
!maskTexCoordBuffer || !maskTexCoordBuffer ||
!samplerLocation || !samplerLocation ||
!opacityLocation ||
!maskedSamplerLocation || !maskedSamplerLocation ||
!maskedMaskSamplerLocation || !maskedMaskSamplerLocation ||
!maskedOpacityLocation ||
!maskRevealPreviewSamplerLocation || !maskRevealPreviewSamplerLocation ||
!maskRevealPreviewMaskSamplerLocation || !maskRevealPreviewMaskSamplerLocation ||
!maskRevealPreviewOpacityLocation || !maskRevealPreviewOpacityLocation ||
@@ -91,7 +95,9 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
} }
} }
}, },
render(asset, rect, clipRect) { render(asset, rect, clipRect, opacity = 1) {
const clampedOpacity = clampOpacity(opacity);
if (clampedOpacity <= 0) return true;
const drawRect = clipRect ? intersectScreenRects(rect, clipRect) : rect; const drawRect = clipRect ? intersectScreenRects(rect, clipRect) : rect;
if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true; if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true;
@@ -107,6 +113,7 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
gl.activeTexture(gl.TEXTURE0); gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture); gl.bindTexture(gl.TEXTURE_2D, texture);
gl.uniform1i(samplerLocation, 0); gl.uniform1i(samplerLocation, 0);
gl.uniform1f(opacityLocation, clampedOpacity);
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, rect), gl.DYNAMIC_DRAW); gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, rect), gl.DYNAMIC_DRAW);
@@ -122,7 +129,9 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
gl.disable(gl.BLEND); gl.disable(gl.BLEND);
return true; return true;
}, },
renderMasked(asset, rect, maskAsset, maskRect, clipRect) { renderMasked(asset, rect, maskAsset, maskRect, clipRect, opacity = 1) {
const clampedOpacity = clampOpacity(opacity);
if (clampedOpacity <= 0) return true;
const clippedRect = clipRect ? intersectScreenRects(rect, clipRect) : rect; const clippedRect = clipRect ? intersectScreenRects(rect, clipRect) : rect;
const drawRect = clippedRect ? intersectScreenRects(clippedRect, maskRect) : undefined; const drawRect = clippedRect ? intersectScreenRects(clippedRect, maskRect) : undefined;
if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true; if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true;
@@ -144,6 +153,7 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
gl.activeTexture(gl.TEXTURE1); gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, maskTexture); gl.bindTexture(gl.TEXTURE_2D, maskTexture);
gl.uniform1i(maskedMaskSamplerLocation, 1); gl.uniform1i(maskedMaskSamplerLocation, 1);
gl.uniform1f(maskedOpacityLocation, clampedOpacity);
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, drawRect), gl.DYNAMIC_DRAW); gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, drawRect), gl.DYNAMIC_DRAW);
@@ -164,8 +174,8 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
gl.disable(gl.BLEND); gl.disable(gl.BLEND);
return true; return true;
}, },
renderMaskRevealPreview(asset, rect, maskAsset, maskRect, opacity, clipRect) { renderMaskRevealPreview(asset, rect, maskAsset, maskRect, opacity, clipRect, layerOpacity = 1) {
const clampedOpacity = Math.max(0, Math.min(1, opacity)); const clampedOpacity = clampOpacity(opacity) * clampOpacity(layerOpacity);
if (clampedOpacity <= 0) return true; if (clampedOpacity <= 0) return true;
const clippedRect = clipRect ? intersectScreenRects(rect, clipRect) : rect; const clippedRect = clipRect ? intersectScreenRects(rect, clipRect) : rect;
@@ -248,7 +258,9 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
gl.disable(gl.BLEND); gl.disable(gl.BLEND);
return true; return true;
}, },
renderTinted(asset, rect, color, clipRect) { renderTinted(asset, rect, color, clipRect, opacity = 1) {
const clampedOpacity = clampOpacity(opacity);
if (clampedOpacity <= 0) return true;
const drawRect = clipRect ? intersectScreenRects(rect, clipRect) : rect; const drawRect = clipRect ? intersectScreenRects(rect, clipRect) : rect;
if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true; if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true;
@@ -264,7 +276,7 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
gl.activeTexture(gl.TEXTURE0); gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture); gl.bindTexture(gl.TEXTURE_2D, texture);
gl.uniform1i(tintedSamplerLocation, 0); gl.uniform1i(tintedSamplerLocation, 0);
gl.uniform4fv(tintedColorLocation, color); gl.uniform4fv(tintedColorLocation, [color[0], color[1], color[2], color[3] * clampedOpacity]);
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, rect), gl.DYNAMIC_DRAW); gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, rect), gl.DYNAMIC_DRAW);
@@ -401,6 +413,10 @@ function enablePremultipliedAlphaBlending(gl: WebGL2RenderingContext) {
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
} }
function clampOpacity(opacity: number) {
return Math.max(0, Math.min(1, opacity));
}
function fullTexCoords() { function fullTexCoords() {
return new Float32Array([0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1]); return new Float32Array([0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1]);
} }
@@ -442,10 +458,11 @@ function createProgram(gl: WebGL2RenderingContext) {
`#version 300 es `#version 300 es
precision mediump float; precision mediump float;
uniform sampler2D u_image; uniform sampler2D u_image;
uniform float u_opacity;
in vec2 v_texCoord; in vec2 v_texCoord;
out vec4 outColor; out vec4 outColor;
void main() { void main() {
outColor = texture(u_image, v_texCoord); outColor = texture(u_image, v_texCoord) * u_opacity;
}`, }`,
); );
const program = gl.createProgram(); const program = gl.createProgram();
@@ -691,6 +708,7 @@ function createMaskedProgram(gl: WebGL2RenderingContext) {
precision mediump float; precision mediump float;
uniform sampler2D u_image; uniform sampler2D u_image;
uniform sampler2D u_mask; uniform sampler2D u_mask;
uniform float u_opacity;
in vec2 v_texCoord; in vec2 v_texCoord;
in vec2 v_maskTexCoord; in vec2 v_maskTexCoord;
out vec4 outColor; out vec4 outColor;
@@ -699,7 +717,7 @@ function createMaskedProgram(gl: WebGL2RenderingContext) {
vec4 maskColor = texture(u_mask, v_maskTexCoord); vec4 maskColor = texture(u_mask, v_maskTexCoord);
float maskAlpha = maskColor.a * dot(maskColor.rgb, vec3(0.2126, 0.7152, 0.0722)); float maskAlpha = maskColor.a * dot(maskColor.rgb, vec3(0.2126, 0.7152, 0.0722));
float alpha = color.a * maskAlpha; float alpha = color.a * maskAlpha;
outColor = vec4(color.rgb * maskAlpha, alpha); outColor = vec4(color.rgb * maskAlpha, alpha) * u_opacity;
}`, }`,
); );
const program = gl.createProgram(); const program = gl.createProgram();

View File

@@ -2,6 +2,7 @@ import type { Asset } from "@core/asset";
import type { ImageDocument } from "@core/document"; import type { ImageDocument } from "@core/document";
import type { Rect } from "@core/geometry"; import type { Rect } from "@core/geometry";
import type { Layer } from "@core/layer"; import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
import { createDocumentReadIndex, forEachLayerBackToFront, resolveIndexedLayerBounds, type DocumentReadIndex } from "@editor/document-indexes"; import { createDocumentReadIndex, forEachLayerBackToFront, resolveIndexedLayerBounds, type DocumentReadIndex } from "@editor/document-indexes";
import type { EditorState, GenerationCandidate, MaskViewMode, ViewportState } from "@editor/state"; import type { EditorState, GenerationCandidate, MaskViewMode, ViewportState } from "@editor/state";
import { clearScreenRect } from "./clear-rect"; import { clearScreenRect } from "./clear-rect";
@@ -12,6 +13,7 @@ import type { RgbaColor, ScreenRect, WebGlRendererContext } from "./types";
const imageLayerColor: RgbaColor = [0.38, 0.42, 0.5, 1]; const imageLayerColor: RgbaColor = [0.38, 0.42, 0.5, 1];
const imageLayerInsetColor: RgbaColor = [0.48, 0.54, 0.64, 1]; const imageLayerInsetColor: RgbaColor = [0.48, 0.54, 0.64, 1];
const hiddenMaskOverlayColor: RgbaColor = [1, 0.08, 0.08, 0.45]; const hiddenMaskOverlayColor: RgbaColor = [1, 0.08, 0.08, 0.45];
const comparisonDividerColor: RgbaColor = [1, 1, 1, 0.9];
const maskRevealPreviewOpacity = 0.28; const maskRevealPreviewOpacity = 0.28;
export function renderLayers(context: WebGlRendererContext, document: ImageDocument, editor: EditorState, imageTextureRenderer: ImageTextureRenderer) { export function renderLayers(context: WebGlRendererContext, document: ImageDocument, editor: EditorState, imageTextureRenderer: ImageTextureRenderer) {
@@ -22,7 +24,7 @@ export function renderLayers(context: WebGlRendererContext, document: ImageDocum
if (!artboard.visible) continue; if (!artboard.visible) continue;
const clipRect = documentRectToScreenRect(context.canvas, artboard.bounds, editor.viewport); const clipRect = documentRectToScreenRect(context.canvas, artboard.bounds, editor.viewport);
const maskLayerIds = documentIndex.maskLayerIdsByArtboardId.get(artboard.id) ?? emptyLayerIds; const maskLayerIds = documentIndex.maskLayerIdsByArtboardId.get(artboard.id) ?? emptyLayerIds;
forEachLayerBackToFront(artboard.layers, (layer) => renderLayer(context, documentIndex, editor, layer, imageTextureRenderer, clipRect, maskLayerIds)); forEachLayerBackToFront(artboard.layers, (layer) => renderLayer(context, documentIndex, editor, layer, imageTextureRenderer, clipRect, maskLayerIds, 1));
if (generationCandidate?.placement.artboardId === artboard.id) renderGenerationCandidatePreview(context, editor, generationCandidate, imageTextureRenderer, clipRect); if (generationCandidate?.placement.artboardId === artboard.id) renderGenerationCandidatePreview(context, editor, generationCandidate, imageTextureRenderer, clipRect);
} }
} }
@@ -39,17 +41,20 @@ function renderLayer(
imageTextureRenderer: ImageTextureRenderer, imageTextureRenderer: ImageTextureRenderer,
clipRect: ScreenRect, clipRect: ScreenRect,
maskLayerIds: ReadonlySet<string>, maskLayerIds: ReadonlySet<string>,
inheritedOpacity: number,
) { ) {
const editingMaskLayer = editor.maskEdit?.maskLayerId === layer.id; const editingMaskLayer = editor.maskEdit?.maskLayerId === layer.id;
const maskViewMode = editor.maskEdit?.viewMode ?? "composite"; const maskViewMode = editor.maskEdit?.viewMode ?? "composite";
const isolatedMaskView = isIsolatedMaskView(maskViewMode); const isolatedMaskView = isIsolatedMaskView(maskViewMode);
if (!layer.visible || maskLayerIds.has(layer.id)) return; if (!layer.visible || maskLayerIds.has(layer.id)) return;
const effectiveOpacity = inheritedOpacity * layer.opacity;
if (effectiveOpacity <= 0) return;
const effectiveClipRect = resolveLayerClipRect(context, documentIndex, editor.viewport, layer, clipRect); const effectiveClipRect = resolveLayerClipRect(context, documentIndex, editor.viewport, layer, clipRect);
if (!effectiveClipRect) return; if (!effectiveClipRect) return;
if (layer.type === "group") { if (layer.type === "group") {
forEachLayerBackToFront(layer.children, (child) => renderLayer(context, documentIndex, editor, child, imageTextureRenderer, effectiveClipRect, maskLayerIds)); forEachLayerBackToFront(layer.children, (child) => renderLayer(context, documentIndex, editor, child, imageTextureRenderer, effectiveClipRect, maskLayerIds, effectiveOpacity));
return; return;
} }
@@ -60,33 +65,34 @@ function renderLayer(
const rect = documentRectToScreenRect(context.canvas, bounds, editor.viewport); const rect = documentRectToScreenRect(context.canvas, bounds, editor.viewport);
const asset = assetWithBrushStrokePreview(documentIndex.assetById.get(layer.assetId), editor); const asset = assetWithBrushStrokePreview(documentIndex.assetById.get(layer.assetId), editor);
const maskLayer = !editingMaskLayer && layer.clippingMask ? documentIndex.layerById.get(layer.clippingMask.maskLayerId) : undefined; const layerMask = getLayerMask(layer);
const maskLayer = !editingMaskLayer && layerMask?.enabled ? documentIndex.layerById.get(layerMask.maskLayerId) : undefined;
const maskAsset = assetWithBrushStrokePreview(maskLayer && maskLayer.type !== "group" ? documentIndex.assetById.get(maskLayer.assetId) : undefined, editor); const maskAsset = assetWithBrushStrokePreview(maskLayer && maskLayer.type !== "group" ? documentIndex.assetById.get(maskLayer.assetId) : undefined, editor);
const maskBounds = maskLayer ? resolveIndexedLayerBounds(documentIndex, maskLayer) : undefined; const maskBounds = maskLayer ? resolveIndexedLayerBounds(documentIndex, maskLayer) : undefined;
const maskRect = maskBounds ? documentRectToScreenRect(context.canvas, maskBounds, editor.viewport) : undefined; const maskRect = maskBounds ? documentRectToScreenRect(context.canvas, maskBounds, editor.viewport) : undefined;
const activeMaskTarget = Boolean(editor.maskEdit?.targetLayerId === layer.id && editor.maskEdit.maskLayerId === layer.clippingMask?.maskLayerId); const activeMaskTarget = Boolean(editor.maskEdit?.targetLayerId === layer.id && editor.maskEdit.maskLayerId === layerMask?.maskLayerId);
const showMaskRevealPreview = editor.tools.activeTool === "brush" && activeMaskTarget && maskViewMode === "composite"; const showMaskRevealPreview = editor.tools.activeTool === "brush" && activeMaskTarget && maskViewMode === "composite";
if (asset && maskAsset && maskRect && activeMaskTarget) { if (asset && maskAsset && maskRect && activeMaskTarget) {
if (maskViewMode === "blackWhite" && imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "blackWhite", undefined, effectiveClipRect)) return; if (maskViewMode === "blackWhite" && imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "blackWhite", undefined, effectiveClipRect)) return;
if (maskViewMode === "alpha" && imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "alpha", undefined, effectiveClipRect)) return; if (maskViewMode === "alpha" && imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "alpha", undefined, effectiveClipRect)) return;
if (maskViewMode === "overlay" && imageTextureRenderer.render(asset, rect, effectiveClipRect)) { if (maskViewMode === "overlay" && imageTextureRenderer.render(asset, rect, effectiveClipRect, effectiveOpacity)) {
imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "hiddenOverlay", hiddenMaskOverlayColor, effectiveClipRect); imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "hiddenOverlay", hiddenMaskOverlayColor, effectiveClipRect);
return; return;
} }
} }
if (asset && maskAsset && maskRect && imageTextureRenderer.renderMasked(asset, rect, maskAsset, maskRect, effectiveClipRect)) { if (asset && maskAsset && maskRect && imageTextureRenderer.renderMasked(asset, rect, maskAsset, maskRect, effectiveClipRect, effectiveOpacity)) {
if (showMaskRevealPreview) imageTextureRenderer.renderMaskRevealPreview(asset, rect, maskAsset, maskRect, maskRevealPreviewOpacity, effectiveClipRect); if (showMaskRevealPreview) imageTextureRenderer.renderMaskRevealPreview(asset, rect, maskAsset, maskRect, maskRevealPreviewOpacity, effectiveClipRect, effectiveOpacity);
return; return;
} }
if (asset && imageTextureRenderer.render(asset, rect, effectiveClipRect)) return; if (asset && imageTextureRenderer.render(asset, rect, effectiveClipRect, effectiveOpacity)) return;
const fallbackRect = intersectScreenRects(rect, effectiveClipRect); const fallbackRect = intersectScreenRects(rect, effectiveClipRect);
if (!fallbackRect) return; if (!fallbackRect) return;
clearScreenRect(context, fallbackRect, imageLayerColor); clearScreenRect(context, fallbackRect, withOpacity(imageLayerColor, effectiveOpacity));
const insetRect = intersectScreenRects({ x: rect.x + 4, y: rect.y + 4, w: Math.max(0, rect.w - 8), h: Math.max(0, rect.h - 8) }, effectiveClipRect); const insetRect = intersectScreenRects({ x: rect.x + 4, y: rect.y + 4, w: Math.max(0, rect.w - 8), h: Math.max(0, rect.h - 8) }, effectiveClipRect);
if (insetRect) clearScreenRect(context, insetRect, imageLayerInsetColor); if (insetRect) clearScreenRect(context, insetRect, withOpacity(imageLayerInsetColor, effectiveOpacity));
} }
const emptyLayerIds = new Set<string>(); const emptyLayerIds = new Set<string>();
@@ -98,9 +104,10 @@ function resolveLayerClipRect(
layer: Layer, layer: Layer,
clipRect: ScreenRect, clipRect: ScreenRect,
): ScreenRect | undefined { ): ScreenRect | undefined {
if (!layer.clippingMask) return clipRect; const layerMask = getLayerMask(layer);
if (!layerMask?.enabled) return clipRect;
const maskBounds = resolveIndexedLayerBounds(documentIndex, layer.clippingMask.maskLayerId); const maskBounds = resolveIndexedLayerBounds(documentIndex, layerMask.maskLayerId);
if (!maskBounds) return clipRect; if (!maskBounds) return clipRect;
return intersectScreenRects(clipRect, documentRectToScreenRect(context.canvas, maskBounds, viewport)); return intersectScreenRects(clipRect, documentRectToScreenRect(context.canvas, maskBounds, viewport));
@@ -118,6 +125,29 @@ function renderGenerationCandidatePreview(
clipRect: ScreenRect, clipRect: ScreenRect,
) { ) {
const rect = documentRectToScreenRect(context.canvas, generationCandidateBounds(candidate), editor.viewport); const rect = documentRectToScreenRect(context.canvas, generationCandidateBounds(candidate), editor.viewport);
const compareMode = editor.generation.compareMode ?? "result";
if (compareMode === "before") return;
if (compareMode === "split") {
const splitClipRect = intersectScreenRects(clipRect, {
x: rect.x + rect.w / 2,
y: rect.y,
w: rect.w / 2,
h: rect.h,
});
if (!splitClipRect) return;
imageTextureRenderer.render(generationCandidateAsset(candidate), rect, splitClipRect);
const divider = intersectScreenRects(clipRect, {
x: rect.x + rect.w / 2 - 1,
y: rect.y,
w: 2,
h: rect.h,
});
if (divider) clearScreenRect(context, divider, comparisonDividerColor);
return;
}
imageTextureRenderer.render(generationCandidateAsset(candidate), rect, clipRect); imageTextureRenderer.render(generationCandidateAsset(candidate), rect, clipRect);
} }
@@ -162,3 +192,7 @@ function intersectScreenRects(a: ScreenRect, b: ScreenRect): ScreenRect | undefi
return { x: x1, y: y1, w: x2 - x1, h: y2 - y1 }; return { x: x1, y: y1, w: x2 - x1, h: y2 - y1 };
} }
function withOpacity(color: RgbaColor, opacity: number): RgbaColor {
return [color[0], color[1], color[2], color[3] * opacity];
}

View File

@@ -4,6 +4,7 @@ import { commandIds } from "@commands/ids";
import type { Asset } from "@core/asset"; import type { Asset } from "@core/asset";
import type { ImageDocument } from "@core/document"; import type { ImageDocument } from "@core/document";
import type { Layer } from "@core/layer"; import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
import type { ArtboardId } from "@core/id"; import type { ArtboardId } from "@core/id";
import { createDocumentReadIndex, resolveIndexedLayerBounds, type DocumentReadIndex, type IndexedLayerInfo } from "@editor/document-indexes"; import { createDocumentReadIndex, resolveIndexedLayerBounds, type DocumentReadIndex, type IndexedLayerInfo } from "@editor/document-indexes";
import type { MaskEditState, SelectionState } from "@editor/state"; import type { MaskEditState, SelectionState } from "@editor/state";
@@ -220,10 +221,11 @@ function LayerRow({
const selected = selectedLayerIds.includes(layer.id); const selected = selectedLayerIds.includes(layer.id);
const layerInfo = documentIndex.layerInfoById.get(layer.id); const layerInfo = documentIndex.layerInfoById.get(layer.id);
const maskLayer = layer.clippingMask ? documentIndex.layerById.get(layer.clippingMask.maskLayerId) : undefined; const layerMask = getLayerMask(layer);
const maskLayer = layerMask ? documentIndex.layerById.get(layerMask.maskLayerId) : undefined;
const maskAsset = maskLayer && maskLayer.type !== "group" ? documentIndex.assetById.get(maskLayer.assetId) : undefined; const maskAsset = maskLayer && maskLayer.type !== "group" ? documentIndex.assetById.get(maskLayer.assetId) : undefined;
const canAddMask = Boolean(layerInfo && layer.type !== "group" && !layer.clippingMask); const canAddMask = Boolean(layerInfo && layer.type !== "group" && !layerMask);
const editingMask = Boolean(maskEdit && layer.clippingMask && maskEdit.targetLayerId === layer.id && maskEdit.maskLayerId === layer.clippingMask.maskLayerId); const editingMask = Boolean(maskEdit && layerMask && maskEdit.targetLayerId === layer.id && maskEdit.maskLayerId === layerMask.maskLayerId);
const rowPadding = 12 + depth * 16; const rowPadding = 12 + depth * 16;
return ( return (
@@ -273,7 +275,7 @@ function LayerRow({
{layer.name} {layer.name}
</button> </button>
)} )}
{layer.clippingMask ? ( {layerMask ? (
<span className={`inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs ${editingMask || selected ? "bg-black/10 text-black/65" : "bg-sky-400/10 text-sky-100/70"}`}> <span className={`inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs ${editingMask || selected ? "bg-black/10 text-black/65" : "bg-sky-400/10 text-sky-100/70"}`}>
<Stack size={13} weight="fill" /> Mask <Stack size={13} weight="fill" /> Mask
</span> </span>
@@ -290,7 +292,7 @@ function LayerRow({
{layer.locked ? <Lock size={24} weight="regular" /> : <LockOpen size={24} weight="regular" />} {layer.locked ? <Lock size={24} weight="regular" /> : <LockOpen size={24} weight="regular" />}
</button> </button>
</div> </div>
{layer.clippingMask ? ( {layerMask ? (
<div className="mt-2 flex min-h-14 flex-wrap items-center gap-2 rounded-[1.5rem] py-2 pl-4 pr-2 text-sm text-sky-100/70 hover:bg-white/[0.04]"> <div className="mt-2 flex min-h-14 flex-wrap items-center gap-2 rounded-[1.5rem] py-2 pl-4 pr-2 text-sm text-sky-100/70 hover:bg-white/[0.04]">
<Stack size={24} weight="fill" className="shrink-0" /> <Stack size={24} weight="fill" className="shrink-0" />
<span className="min-w-28 flex-1 truncate">{maskLayer ? "Layer mask" : "Layer mask missing"}</span> <span className="min-w-28 flex-1 truncate">{maskLayer ? "Layer mask" : "Layer mask missing"}</span>
@@ -303,7 +305,7 @@ function LayerRow({
onClick={() => onClick={() =>
editingMask editingMask
? dispatch(commandIds.toolExitMaskEdit, undefined) ? dispatch(commandIds.toolExitMaskEdit, undefined)
: dispatch(commandIds.toolEnterMaskEdit, { targetLayerId: layer.id, maskLayerId: layer.clippingMask!.maskLayerId }) : dispatch(commandIds.toolEnterMaskEdit, { targetLayerId: layer.id, maskLayerId: layerMask.maskLayerId })
} }
> >
{editingMask ? "Done" : "Edit"} {editingMask ? "Done" : "Edit"}
@@ -313,7 +315,7 @@ function LayerRow({
className={maskActionButtonClass()} className={maskActionButtonClass()}
title="Paint reveal" title="Paint reveal"
onClick={() => { onClick={() => {
dispatch(commandIds.toolEnterMaskEdit, { targetLayerId: layer.id, maskLayerId: layer.clippingMask!.maskLayerId }); dispatch(commandIds.toolEnterMaskEdit, { targetLayerId: layer.id, maskLayerId: layerMask.maskLayerId });
dispatch(commandIds.toolSetActive, { tool: "brush" }); dispatch(commandIds.toolSetActive, { tool: "brush" });
}} }}
> >
@@ -324,7 +326,7 @@ function LayerRow({
className={maskActionButtonClass()} className={maskActionButtonClass()}
title="Paint hide" title="Paint hide"
onClick={() => { onClick={() => {
dispatch(commandIds.toolEnterMaskEdit, { targetLayerId: layer.id, maskLayerId: layer.clippingMask!.maskLayerId }); dispatch(commandIds.toolEnterMaskEdit, { targetLayerId: layer.id, maskLayerId: layerMask.maskLayerId });
dispatch(commandIds.toolSetActive, { tool: "eraser" }); dispatch(commandIds.toolSetActive, { tool: "eraser" });
}} }}
> >
@@ -592,7 +594,8 @@ function moveLayer(documentIndex: DocumentReadIndex, info: IndexedLayerInfo, dir
const blocks = siblings.flatMap((layer, index) => { const blocks = siblings.flatMap((layer, index) => {
if (maskLayerIds.has(layer.id)) return []; if (maskLayerIds.has(layer.id)) return [];
const maskIndex = layer.clippingMask ? siblings.findIndex((candidate) => candidate.id === layer.clippingMask?.maskLayerId) : -1; const layerMask = getLayerMask(layer);
const maskIndex = layerMask ? siblings.findIndex((candidate) => candidate.id === layerMask.maskLayerId) : -1;
const start = maskIndex >= 0 ? Math.min(maskIndex, index) : index; const start = maskIndex >= 0 ? Math.min(maskIndex, index) : index;
const end = maskIndex >= 0 ? Math.max(maskIndex, index) : index; const end = maskIndex >= 0 ? Math.max(maskIndex, index) : index;
return [{ layerId: layer.id, start, end, size: end - start + 1 }]; return [{ layerId: layer.id, start, end, size: end - start + 1 }];

View File

@@ -3,6 +3,7 @@ import { DropHalf } from "@phosphor-icons/react";
import { commandIds } from "@commands/ids"; import { commandIds } from "@commands/ids";
import type { ImageDocument } from "@core/document"; import type { ImageDocument } from "@core/document";
import type { Layer } from "@core/layer"; import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
import { resolveTransformTargetBounds } from "@editor/transform-targets"; import { resolveTransformTargetBounds } from "@editor/transform-targets";
import type { AppStore } from "@editor/store"; import type { AppStore } from "@editor/store";
import type { ChromaKeySettings } from "@editor/tools"; import type { ChromaKeySettings } from "@editor/tools";
@@ -160,7 +161,8 @@ function resolveChromaKeyTarget(document: ImageDocument, selection: SelectionSta
if (!layer || layer.type === "group") return undefined; if (!layer || layer.type === "group") return undefined;
const asset = document.assets.find((candidate) => candidate.id === layer.assetId); const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id }); const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id });
const maskLayer = layer.clippingMask ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layer.clippingMask.maskLayerId) : undefined; const layerMask = getLayerMask(layer);
const maskLayer = layerMask?.enabled ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerMask.maskLayerId) : undefined;
const maskAsset = maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined; const maskAsset = maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
return asset && bounds ? { layer, asset, bounds, maskLayer, maskAsset } : undefined; return asset && bounds ? { layer, asset, bounds, maskLayer, maskAsset } : undefined;
} }

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { commandIds } from "@commands/ids"; import { commandIds } from "@commands/ids";
import type { ImageDocument } from "@core/document"; import type { ImageDocument } from "@core/document";
import type { GenerationCandidate, GenerationState, SelectionState, ViewportState } from "@editor/state"; import type { GenerationCandidate, GenerationCompareMode, GenerationState, SelectionState, ViewportState } from "@editor/state";
import type { GenerateSettings } from "@editor/tools"; import type { GenerateSettings } from "@editor/tools";
import type { AppStore } from "@editor/store"; import type { AppStore } from "@editor/store";
import { createMaskedPixelReplacementSource } from "../generate/candidateActions"; import { createMaskedPixelReplacementSource } from "../generate/candidateActions";
@@ -62,6 +62,7 @@ export function GenerateActionControls({ document, selection, viewport, settings
<CandidateControls <CandidateControls
document={document} document={document}
candidate={candidate} candidate={candidate}
compareMode={generation.compareMode ?? "result"}
settings={settings} settings={settings}
busy={busy} busy={busy}
setBusy={setBusy} setBusy={setBusy}
@@ -100,6 +101,7 @@ function CandidatePicker({ generation, dispatch }: { generation: GenerationState
function CandidateControls({ function CandidateControls({
document, document,
candidate, candidate,
compareMode,
settings, settings,
busy, busy,
setBusy, setBusy,
@@ -108,6 +110,7 @@ function CandidateControls({
}: { }: {
document: ImageDocument; document: ImageDocument;
candidate: GenerationCandidate; candidate: GenerationCandidate;
compareMode: GenerationCompareMode;
settings: GenerateSettings; settings: GenerateSettings;
busy?: string; busy?: string;
setBusy: (busy: string | undefined) => void; setBusy: (busy: string | undefined) => void;
@@ -128,6 +131,7 @@ function CandidateControls({
<div className="flex flex-wrap items-center justify-center gap-1 rounded-full bg-white/[0.04] px-2 py-1 ring-1 ring-white/[0.05]"> <div className="flex flex-wrap items-center justify-center gap-1 rounded-full bg-white/[0.04] px-2 py-1 ring-1 ring-white/[0.05]">
<CandidatePreview candidate={candidate} /> <CandidatePreview candidate={candidate} />
<span className="px-2 text-xs font-medium text-white/55">Seed {candidate.seed}</span> <span className="px-2 text-xs font-medium text-white/55">Seed {candidate.seed}</span>
<CandidateCompareControls compareMode={compareMode} disabled={disabled} dispatch={dispatch} />
<CandidateButton disabled={disabled} label="Regenerate" title="Regenerate same mask and crop" busy={busy === "Regenerate"} onClick={() => rerun("Regenerate", candidate.settings)} /> <CandidateButton disabled={disabled} label="Regenerate" title="Regenerate same mask and crop" busy={busy === "Regenerate"} onClick={() => rerun("Regenerate", candidate.settings)} />
<CandidateButton <CandidateButton
disabled={disabled} disabled={disabled}
@@ -138,11 +142,11 @@ function CandidateControls({
/> />
<CandidateButton disabled={disabled} label="Reuse seed" title="Regenerate with the same seed" busy={busy === "Reuse seed"} onClick={() => rerun("Reuse seed", { ...candidate.settings, seed: candidate.seed })} /> <CandidateButton disabled={disabled} label="Reuse seed" title="Regenerate with the same seed" busy={busy === "Reuse seed"} onClick={() => rerun("Reuse seed", { ...candidate.settings, seed: candidate.seed })} />
<CandidateButton disabled={disabled} label="New seed" title="Regenerate with a new seed" busy={busy === "New seed"} onClick={() => rerun("New seed", { ...candidate.settings, seed: -1 })} /> <CandidateButton disabled={disabled} label="New seed" title="Regenerate with a new seed" busy={busy === "New seed"} onClick={() => rerun("New seed", { ...candidate.settings, seed: -1 })} />
<CandidateButton disabled={disabled} label="Apply layer" title="Apply candidate as a normal layer" onClick={() => applyCandidateAsLayer(candidate, false, dispatch)} /> <CandidateButton disabled={disabled} label="Accept layer" title="Accept candidate as a normal layer" onClick={() => applyCandidateAsLayer(candidate, false, dispatch)} />
<CandidateButton <CandidateButton
disabled={disabled} disabled={disabled}
label="Apply + refine" label="Accept + mask"
title="Apply candidate as a layer with a fresh refinement mask" title="Accept candidate as a layer with a fresh refinement mask"
busy={busy === "Refine"} busy={busy === "Refine"}
onClick={() => { onClick={() => {
setBusy("Refine"); setBusy("Refine");
@@ -152,10 +156,10 @@ function CandidateControls({
.finally(() => setBusy(undefined)); .finally(() => setBusy(undefined));
}} }}
/> />
<CandidateButton disabled={disabled} label="Stack variant" title="Stack candidate as another variant layer" onClick={() => applyCandidateAsLayer(candidate, true, dispatch)} /> <CandidateButton disabled={disabled} label="Accept variant" title="Stack candidate as another variant layer" onClick={() => applyCandidateAsLayer(candidate, true, dispatch)} />
<CandidateButton <CandidateButton
disabled={disabled || !candidate.inpaint} disabled={disabled || !candidate.inpaint}
label="Replace" label="Accept replace"
title={candidate.inpaint ? "Replace masked pixels and preserve unmasked pixels" : "Only inpaint candidates can replace masked pixels"} title={candidate.inpaint ? "Replace masked pixels and preserve unmasked pixels" : "Only inpaint candidates can replace masked pixels"}
busy={busy === "Replace"} busy={busy === "Replace"}
onClick={() => { onClick={() => {
@@ -183,6 +187,36 @@ function CandidateControls({
); );
} }
function CandidateCompareControls({ compareMode, disabled, dispatch }: { compareMode: GenerationCompareMode; disabled: boolean; dispatch: AppStore["dispatch"] }) {
return (
<span className="flex items-center gap-1 rounded-full bg-black/20 p-1" aria-label="Compare candidate">
{generationCompareOptions.map((option) => {
const active = compareMode === option.mode;
return (
<button
key={option.mode}
type="button"
className={`h-7 rounded-full px-2 text-[0.7rem] font-semibold transition ${
active ? "bg-white text-black" : "text-white/55 hover:bg-white/10 hover:text-white"
} disabled:pointer-events-none disabled:opacity-35`}
disabled={disabled}
title={option.title}
onClick={() => dispatch(commandIds.generationSetCompareMode, { mode: option.mode })}
>
{option.label}
</button>
);
})}
</span>
);
}
const generationCompareOptions: Array<{ mode: GenerationCompareMode; label: string; title: string }> = [
{ mode: "result", label: "After", title: "Show the generated result over the document" },
{ mode: "before", label: "Before", title: "Hide the generated result and show the source document" },
{ mode: "split", label: "Split", title: "Compare source on the left with result on the right" },
];
function CandidatePreview({ candidate }: { candidate: GenerationCandidate }) { function CandidatePreview({ candidate }: { candidate: GenerationCandidate }) {
if (!candidate.inputImage) { if (!candidate.inputImage) {
return <img src={candidate.source} alt="" className="h-10 w-10 rounded-full bg-black/25 object-cover ring-1 ring-white/10" />; return <img src={candidate.source} alt="" className="h-10 w-10 rounded-full bg-black/25 object-cover ring-1 ring-white/10" />;

View File

@@ -2,6 +2,7 @@ import { commandIds } from "@commands/ids";
import type { ImageDocument } from "@core/document"; import type { ImageDocument } from "@core/document";
import type { Vec2D } from "@core/geometry"; import type { Vec2D } from "@core/geometry";
import type { Layer } from "@core/layer"; import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
import { resolveTransformTargetBounds } from "@editor/transform-targets"; import { resolveTransformTargetBounds } from "@editor/transform-targets";
import type { AppStore } from "@editor/store"; import type { AppStore } from "@editor/store";
import type { EditorState } from "@editor/state"; import type { EditorState } from "@editor/state";
@@ -41,7 +42,8 @@ function resolveTarget(document: ImageDocument, editor: EditorState) {
if (!layer || layer.type === "group") return undefined; if (!layer || layer.type === "group") return undefined;
const asset = document.assets.find((candidate) => candidate.id === layer.assetId); const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id }); const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id });
const maskLayer = layer.clippingMask ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layer.clippingMask.maskLayerId) : undefined; const layerMask = getLayerMask(layer);
const maskLayer = layerMask?.enabled ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerMask.maskLayerId) : undefined;
const maskAsset = maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined; const maskAsset = maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
return asset && bounds ? { layer, asset, bounds, maskLayer, maskAsset } : undefined; return asset && bounds ? { layer, asset, bounds, maskLayer, maskAsset } : undefined;
} }

View File

@@ -177,6 +177,7 @@ const visualEditorChanges: Array<[string, (state: AppState) => AppState]> = [
}, },
], ],
selectedCandidateId: "candidate", selectedCandidateId: "candidate",
compareMode: "result",
}, },
}, },
}), }),

View File

@@ -2,6 +2,7 @@ import type { Artboard } from "@core/artboard";
import type { Asset } from "@core/asset"; import type { Asset } from "@core/asset";
import type { Rect } from "@core/geometry"; import type { Rect } from "@core/geometry";
import type { Layer } from "@core/layer"; import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
export async function downloadArtboardPng(artboard: Artboard, assets: readonly Asset[]) { export async function downloadArtboardPng(artboard: Artboard, assets: readonly Asset[]) {
const width = Math.max(1, Math.round(artboard.bounds.w)); const width = Math.max(1, Math.round(artboard.bounds.w));
@@ -41,8 +42,9 @@ async function drawLayer(
) { ) {
if (!layer.visible || (!options.ignoreOwnMask && options.maskLayerIds.has(layer.id))) return; if (!layer.visible || (!options.ignoreOwnMask && options.maskLayerIds.has(layer.id))) return;
if (!options.ignoreOwnMask && layer.clippingMask) { const layerMask = getLayerMask(layer);
const maskLayer = findLayer(layerTree, layer.clippingMask.maskLayerId); if (!options.ignoreOwnMask && layerMask?.enabled) {
const maskLayer = findLayer(layerTree, layerMask.maskLayerId);
if (!maskLayer) return; if (!maskLayer) return;
await drawMaskedLayer(context, layer, maskLayer, layerTree, assets, artboardBounds); await drawMaskedLayer(context, layer, maskLayer, layerTree, assets, artboardBounds);
return; return;
@@ -122,7 +124,8 @@ function translatedContext(canvas: HTMLCanvasElement, bounds: Rect) {
function collectMaskLayerIds(layers: readonly Layer[], ids = new Set<string>()) { function collectMaskLayerIds(layers: readonly Layer[], ids = new Set<string>()) {
for (const layer of layers) { for (const layer of layers) {
if (layer.clippingMask) ids.add(layer.clippingMask.maskLayerId); const layerMask = getLayerMask(layer);
if (layerMask) ids.add(layerMask.maskLayerId);
if (layer.type === "group") collectMaskLayerIds(layer.children, ids); if (layer.type === "group") collectMaskLayerIds(layer.children, ids);
} }
return ids; return ids;

View File

@@ -2,6 +2,7 @@ import type { Asset } from "@core/asset";
import type { ImageDocument } from "@core/document"; import type { ImageDocument } from "@core/document";
import type { Rect } from "@core/geometry"; import type { Rect } from "@core/geometry";
import type { Layer } from "@core/layer"; import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
import type { SelectionState } from "@editor/state"; import type { SelectionState } from "@editor/state";
import type { GenerateSettings } from "@editor/tools"; import type { GenerateSettings } from "@editor/tools";
import { createDocumentReadIndex, resolveIndexedLayerBounds } from "@editor/document-indexes"; import { createDocumentReadIndex, resolveIndexedLayerBounds } from "@editor/document-indexes";
@@ -137,9 +138,10 @@ function resolveInpaintTarget(document: ImageDocument, selection: SelectionState
const asset = documentIndex.assetById.get(layerInfo.layer.assetId); const asset = documentIndex.assetById.get(layerInfo.layer.assetId);
if (!asset) throw new Error("The selected layer is missing its source image."); if (!asset) throw new Error("The selected layer is missing its source image.");
if (!layerInfo.layer.clippingMask) throw new Error("Add a layer mask before running inpaint."); const layerMask = getLayerMask(layerInfo.layer);
if (!layerMask?.enabled) throw new Error("Add a layer mask before running inpaint.");
const maskLayer = documentIndex.layerById.get(layerInfo.layer.clippingMask.maskLayerId); const maskLayer = documentIndex.layerById.get(layerMask.maskLayerId);
if (!maskLayer || maskLayer.type === "group") throw new Error("The selected layer mask is missing."); if (!maskLayer || maskLayer.type === "group") throw new Error("The selected layer mask is missing.");
const maskAsset = documentIndex.assetById.get(maskLayer.assetId); const maskAsset = documentIndex.assetById.get(maskLayer.assetId);

View File

@@ -2,6 +2,7 @@ import { commandIds } from "@commands/ids";
import type { ImageDocument } from "@core/document"; import type { ImageDocument } from "@core/document";
import type { Transform } from "@core/geometry"; import type { Transform } from "@core/geometry";
import type { Layer } from "@core/layer"; import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
import type { AppStore } from "@editor/store"; import type { AppStore } from "@editor/store";
import type { GenerationCandidate, SelectionState, ViewportState } from "@editor/state"; import type { GenerationCandidate, SelectionState, ViewportState } from "@editor/state";
import type { GenerateSettings } from "@editor/tools"; import type { GenerateSettings } from "@editor/tools";
@@ -224,7 +225,8 @@ function resolveSelectedImage(document: ImageDocument, selection: SelectionState
const layer = findLayer(document.artboards.find((artboard) => artboard.id === selection.artboardId)?.layers ?? [], layerId); const layer = findLayer(document.artboards.find((artboard) => artboard.id === selection.artboardId)?.layers ?? [], layerId);
if (!layer || layer.type === "group") return undefined; if (!layer || layer.type === "group") return undefined;
const asset = document.assets.find((candidate) => candidate.id === layer.assetId); const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
const maskLayer = layer.clippingMask ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layer.clippingMask.maskLayerId) : undefined; const layerMask = getLayerMask(layer);
const maskLayer = layerMask?.enabled ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerMask.maskLayerId) : undefined;
const maskAsset = maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined; const maskAsset = maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
return asset ? { layer, asset, maskAsset } : undefined; return asset ? { layer, asset, maskAsset } : undefined;
} }