feat: enhance layer mask handling and opacity management
- Introduced `getLayerMask` utility to streamline layer mask retrieval. - Updated layer rendering logic to incorporate layer masks and opacity adjustments. - Added functionality to prevent dropping a group into its descendants. - Enhanced image texture rendering to support opacity parameters across various rendering functions. - Implemented group layer bounds application for better scaling and positioning. - Added tests to ensure correct behavior when handling layer masks and group layers. - Created new types for asset generation provenance to track generated assets more effectively.
This commit is contained in:
@@ -154,6 +154,35 @@ describe("document commands", () => {
|
|||||||
expect(ungrouped.editor.selection.layerIds).toEqual(["a", "b"]);
|
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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
56
core/asset-provenance.ts
Normal 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;
|
||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
18
core/layer-mask-utils.ts
Normal 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
8
core/layer-mask.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import type { LayerId } from "./id";
|
||||||
|
|
||||||
|
export type LayerMask = {
|
||||||
|
kind: "raster";
|
||||||
|
maskLayerId: LayerId;
|
||||||
|
enabled: boolean;
|
||||||
|
inverted: boolean;
|
||||||
|
};
|
||||||
@@ -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" } },
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")) {
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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];
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 }];
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" />;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ const visualEditorChanges: Array<[string, (state: AppState) => AppState]> = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
selectedCandidateId: "candidate",
|
selectedCandidateId: "candidate",
|
||||||
|
compareMode: "result",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user