From 5eaf37ba28522d09746bc4b4ff2dad27cbaefbaf Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Sun, 5 Jul 2026 16:06:23 +0200 Subject: [PATCH] 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. --- commands/document.test.ts | 36 ++++++ commands/document.ts | 111 ++++++++++++++---- commands/generation.test.ts | 32 ++++- commands/generation.ts | 103 +++++++++++++++- commands/ids.ts | 1 + commands/index.ts | 2 + commands/payloads.ts | 2 + commands/tool.ts | 3 +- core/asset-provenance.ts | 56 +++++++++ core/asset.ts | 2 + core/base-layer.ts | 3 + core/index.ts | 3 + core/layer-mask-utils.ts | 18 +++ core/layer-mask.ts | 8 ++ editor/document-indexes.test.ts | 5 +- editor/document-indexes.ts | 8 +- editor/initial-state.ts | 1 + editor/state.ts | 3 + editor/transform-targets.test.ts | 45 +++++++ editor/transform-targets.ts | 68 ++++++++++- input/layers-panel.test.ts | 4 + input/layers-panel.ts | 9 ++ input/selection.ts | 5 +- renderer/image-textures.ts | 42 +++++-- renderer/layers.ts | 58 +++++++-- view/LayersSheet.tsx | 21 ++-- view/bottom-controls/ChromaKeyControls.tsx | 4 +- .../GenerateActionControls.tsx | 46 +++++++- view/canvas/magic-wand.ts | 4 +- view/canvas/renderFrame.test.ts | 1 + view/exportArtboardPng.ts | 9 +- view/generate/inpaintPrep.ts | 6 +- view/generate/runGenerate.ts | 4 +- 33 files changed, 635 insertions(+), 88 deletions(-) create mode 100644 core/asset-provenance.ts create mode 100644 core/layer-mask-utils.ts create mode 100644 core/layer-mask.ts diff --git a/commands/document.test.ts b/commands/document.test.ts index 969b239..794e276 100644 --- a/commands/document.test.ts +++ b/commands/document.test.ts @@ -154,6 +154,35 @@ describe("document commands", () => { expect(ungrouped.editor.selection.layerIds).toEqual(["a", "b"]); }); + test("groups nested sibling layers in place", () => { + const state = documentWithLayers([{ ...group("parent", "Parent"), children: [group("a", "A"), group("b", "B"), group("c", "C")] }]); + + const grouped = documentGroupLayersCommand.execute({ state }, { artboardId: "a1", layerIds: ["a", "b"], group: group("g", "Group") }); + const parent = grouped.document.artboards[0]?.layers[0]; + const nestedGroup = parent?.type === "group" ? parent.children[0] : undefined; + + expect(parent?.type).toBe("group"); + expect(parent?.type === "group" ? parent.children.map((layer) => layer.id) : []).toEqual(["g", "c"]); + expect(nestedGroup?.type === "group" ? nestedGroup.children.map((layer) => layer.id) : []).toEqual(["a", "b"]); + expect(grouped.editor.selection).toEqual({ artboardId: "a1", layerIds: ["g"] }); + }); + + test("does not group layers from different parents", () => { + const state = documentWithLayers([group("a", "A"), { ...group("parent", "Parent"), children: [group("b", "B")] }]); + + const grouped = documentGroupLayersCommand.execute({ state }, { artboardId: "a1", layerIds: ["a", "b"], group: group("g", "Group") }); + + expect(grouped).toBe(state); + }); + + test("does not move groups into their own descendants", () => { + const state = documentWithLayers([{ ...group("parent", "Parent"), children: [group("child", "Child")] }]); + + const moved = documentMoveLayerCommand.execute({ state }, { layerId: "parent", toArtboardId: "a1", toParentGroupId: "child", toIndex: 0 }); + + expect(moved).toBe(state); + }); + test("removes layers", () => { const state = documentWithLayers([group("a", "A"), group("b", "B")]); const selectedState = { ...state, editor: { ...state.editor, selection: { artboardId: "a1", layerIds: ["a"] } } }; @@ -219,6 +248,12 @@ describe("document commands", () => { expect(masked.document.assets).toContainEqual(maskAsset()); expect(masked.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["mask", "target"]); + expect(masked.document.artboards[0]?.layers[1]?.layerMask).toEqual({ + kind: "raster", + maskLayerId: "mask", + enabled: true, + inverted: false, + }); expect(masked.document.artboards[0]?.layers[1]?.clippingMask).toEqual({ maskLayerId: "mask" }); expect(masked.editor.maskEdit).toEqual({ targetLayerId: "target", maskLayerId: "mask" }); expect(masked.editor.tools.activeTool).toBe("brush"); @@ -233,6 +268,7 @@ describe("document commands", () => { const unmasked = documentRemoveLayerMaskCommand.execute({ state }, { layerId: "target" }); expect(unmasked.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["target"]); + expect(unmasked.document.artboards[0]?.layers[0]?.layerMask).toBeUndefined(); expect(unmasked.document.artboards[0]?.layers[0]?.clippingMask).toBeUndefined(); expect(unmasked.editor.maskEdit).toBeUndefined(); }); diff --git a/commands/document.ts b/commands/document.ts index 84e10f2..3cfe462 100644 --- a/commands/document.ts +++ b/commands/document.ts @@ -4,6 +4,7 @@ import type { Rect } from "@core/geometry"; import type { ArtboardId, AssetId, LayerId } from "@core/id"; import type { ImageLayer } from "@core/image-layer"; import type { Layer } from "@core/layer"; +import { getLayerMask } from "@core/layer-mask-utils"; import type { RasterLayer } from "@core/raster-layer"; import type { LayerGroup } from "@core/layer-group"; import type { Command } from "./command"; @@ -316,7 +317,7 @@ export const documentMoveLayerCommand: Command = { const removed = removeLayerFromDocument(state.document, payload.layerId); if (!removed.layer) return state; - const maskLayerId = removed.layer.clippingMask?.maskLayerId; + const maskLayerId = getLayerMask(removed.layer)?.maskLayerId; const removedMask = maskLayerId ? removeLayerFromDocument(removed.document, maskLayerId) : undefined; const documentAfterRemoval = removedMask?.document ?? removed.document; if (payload.toParentGroupId && !findGroup(documentAfterRemoval, payload.toParentGroupId)) return state; @@ -338,25 +339,29 @@ export const documentGroupLayersCommand: Command = { execute({ state }, payload) { const requestedIds = [...new Set(payload.layerIds)]; if (requestedIds.length === 0) return state; + if (findLayerLocation(state.document, payload.group.id)) return state; - const artboard = state.document.artboards.find((candidate) => candidate.id === payload.artboardId); - if (!artboard) return state; + const requestedLocations = requestedIds.flatMap((layerId) => { + const location = findLayerLocation(state.document, layerId); + return location ? [location] : []; + }); + if (requestedLocations.length !== requestedIds.length) return state; - const uniqueIds = [...new Set([...requestedIds, ...collectAttachedMaskIds(artboard.layers, requestedIds)])]; + const firstLocation = requestedLocations[0]; + if (!firstLocation || firstLocation.artboardId !== payload.artboardId) return state; + if (requestedLocations.some((location) => location.artboardId !== firstLocation.artboardId || location.parentGroupId !== firstLocation.parentGroupId)) return state; - const selected = artboard.layers.filter((layer) => uniqueIds.includes(layer.id)); + const uniqueIds = new Set([...requestedIds, ...collectAttachedMaskIds(firstLocation.siblings, requestedIds)]); + const selected = firstLocation.siblings.filter((layer) => uniqueIds.has(layer.id)); if (selected.length === 0) return state; - const firstIndex = artboard.layers.findIndex((layer) => layer.id === selected[0]?.id); const group: LayerGroup = { ...payload.group, children: selected }; - const document = { - ...state.document, - artboards: state.document.artboards.map((candidate) => - candidate.id === payload.artboardId - ? { ...candidate, layers: [...candidate.layers.filter((layer) => !uniqueIds.includes(layer.id)).slice(0, firstIndex), group, ...candidate.layers.filter((layer) => !uniqueIds.includes(layer.id)).slice(firstIndex)] } - : candidate, - ), - }; + const document = replaceLayerListInDocument( + state.document, + firstLocation.artboardId, + firstLocation.parentGroupId, + replaceSelectedLayersWithGroup(firstLocation.siblings, uniqueIds, group), + ); return { ...state, @@ -414,7 +419,7 @@ export const documentSetLayerClippingMaskCommand: Command removeLayerMaskReference(layer)), @@ -442,7 +447,7 @@ export const documentSetLayerClippingMaskCommand: Command = const targetLocation = findLayerLocation(state.document, payload.layerId); if (!targetLocation || targetLocation.layer.type === "group") return state; - const existingMaskId = targetLocation.layer.clippingMask?.maskLayerId; + const existingMaskId = getLayerMask(targetLocation.layer)?.maskLayerId; if (existingMaskId) { const existingMaskLocation = findLayerLocation(state.document, existingMaskId); if (existingMaskLocation?.layer.type === "group") return state; @@ -486,11 +491,12 @@ export const documentAddLayerMaskCommand: Command = visible: true, locked: false, opacity: 1, + layerMask: undefined, clippingMask: undefined, }; const withAsset: ImageDocument = { ...state.document, assets: [...state.document.assets, payload.asset] }; const withMaskLayer = insertLayer(withAsset, targetLocation.artboardId, targetLocation.parentGroupId, maskLayer, targetLocation.index); - const document = mapLayerInDocument(withMaskLayer, payload.layerId, (layer) => ({ ...layer, clippingMask: { maskLayerId: maskLayer.id } })); + const document = mapLayerInDocument(withMaskLayer, payload.layerId, (layer) => withLayerMask(layer, maskLayer.id)); return { ...state, @@ -546,7 +552,7 @@ export const documentRemoveLayerMaskCommand: Command { + 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, group: LayerGroup): Layer[] { + const next: Layer[] = []; + let inserted = false; + for (const layer of layers) { + if (!selectedLayerIds.has(layer.id)) { + next.push(layer); + continue; + } + if (!inserted) { + next.push(group); + inserted = true; + } + } + return next; +} + function removeLayerFromDocument(document: ImageDocument, layerId: LayerId): { document: ImageDocument; layer?: Layer } { let removed: Layer | undefined; return { @@ -785,10 +827,24 @@ function findGroupInTree(layers: Layer[], groupId: LayerId): LayerGroup | undefi function removeLayerMaskReference(layer: Layer): Layer { const next = { ...layer }; + delete next.layerMask; delete next.clippingMask; return next; } +function withLayerMask(layer: Layer, maskLayerId: LayerId): Layer { + return { + ...layer, + layerMask: { + kind: "raster", + maskLayerId, + enabled: true, + inverted: false, + }, + clippingMask: { maskLayerId }, + }; +} + function removeUnreferencedMaskLayer(document: ImageDocument, maskLayerId: LayerId): ImageDocument { if (isMaskLayerReferenced(document, maskLayerId)) return document; return removeLayerFromDocument(document, maskLayerId).document; @@ -801,7 +857,8 @@ function isMaskLayerReferenced(document: ImageDocument, maskLayerId: LayerId): b function removeMissingMaskReferences(document: ImageDocument): ImageDocument { const existingLayerIds = collectDocumentLayerIds(document); return mapAllLayersInDocument(document, (layer) => { - if (!layer.clippingMask || existingLayerIds.has(layer.clippingMask.maskLayerId)) return layer; + const mask = getLayerMask(layer); + if (!mask || existingLayerIds.has(mask.maskLayerId)) return layer; return removeLayerMaskReference(layer); }); } @@ -814,7 +871,7 @@ function isMaskEditValid(maskEdit: { targetLayerId: LayerId; maskLayerId: LayerI if (!maskEdit) return false; const target = findLayerLocation(document, maskEdit.targetLayerId)?.layer; const mask = findLayerLocation(document, maskEdit.maskLayerId)?.layer; - return Boolean(target?.clippingMask?.maskLayerId === maskEdit.maskLayerId && mask && mask.type !== "group"); + return Boolean(target && getLayerMask(target)?.maskLayerId === maskEdit.maskLayerId && mask && mask.type !== "group"); } function collectDocumentLayerIds(document: ImageDocument): Set { @@ -836,7 +893,8 @@ function collectLayerIdsFromTree(layers: readonly Layer[], ids = new Set()): Set { for (const layer of layers) { - if (layer.clippingMask) ids.add(layer.clippingMask.maskLayerId); + const mask = getLayerMask(layer); + if (mask) ids.add(mask.maskLayerId); if (layer.type === "group") collectClippingMaskIds(layer.children, ids); } return ids; @@ -844,7 +902,10 @@ function collectClippingMaskIds(layers: readonly Layer[], ids = new Set function collectAttachedMaskIds(layers: readonly Layer[], layerIds: readonly LayerId[]): LayerId[] { const layerIdSet = new Set(layerIds); - return layers.flatMap((layer) => (layerIdSet.has(layer.id) && layer.clippingMask ? [layer.clippingMask.maskLayerId] : [])); + return layers.flatMap((layer) => { + const mask = getLayerMask(layer); + return layerIdSet.has(layer.id) && mask ? [mask.maskLayerId] : []; + }); } function mapAllLayersInDocument(document: ImageDocument, mapLayer: (layer: Layer) => Layer): ImageDocument { diff --git a/commands/generation.test.ts b/commands/generation.test.ts index a9074f2..7c57c98 100644 --- a/commands/generation.test.ts +++ b/commands/generation.test.ts @@ -6,6 +6,7 @@ import { generationApplyCandidateAsLayerCommand, generationRemoveCandidateCommand, generationReplaceCandidatePixelsCommand, + generationSetCompareModeCommand, } from "./generation"; describe("generation commands", () => { @@ -18,8 +19,20 @@ describe("generation commands", () => { expect(added.editor.generation.candidates).toEqual([candidate]); expect(added.editor.generation.selectedCandidateId).toBe(candidate.id); + expect(added.editor.generation.compareMode).toBe("result"); expect(removed.editor.generation.candidates).toEqual([]); expect(removed.editor.generation.selectedCandidateId).toBeUndefined(); + expect(removed.editor.generation.compareMode).toBe("result"); + }); + + test("sets generation compare mode without touching candidates", () => { + const state = generationAddCandidateCommand.execute({ state: createInitialAppState("Test") }, { candidate: generationCandidate("candidate-1") }); + + const next = generationSetCompareModeCommand.execute({ state }, { mode: "split" }); + + expect(next.editor.generation.candidates).toEqual([generationCandidate("candidate-1")]); + expect(next.editor.generation.selectedCandidateId).toBe("candidate-1"); + expect(next.editor.generation.compareMode).toBe("split"); }); test("applies candidates as top-level layers", () => { @@ -31,9 +44,15 @@ describe("generation commands", () => { ); expect(next.document.assets.find((asset) => asset.id === "generated-asset")?.source).toBe("generated-source"); + expect(next.document.assets.find((asset) => asset.id === "generated-asset")?.provenance).toMatchObject({ + kind: "generated", + candidateId: "candidate-1", + acceptance: "layer", + seed: 123, + }); expect(next.document.artboards[0]?.layers[0]?.id).toBe("generated-layer"); expect(next.editor.selection).toEqual({ artboardId: "a1", layerIds: ["generated-layer"] }); - expect(next.editor.generation).toEqual({ candidates: [], selectedCandidateId: undefined }); + expect(next.editor.generation).toEqual({ candidates: [], selectedCandidateId: undefined, compareMode: "result" }); }); test("replaces source asset pixels for inpaint candidates", () => { @@ -46,8 +65,17 @@ describe("generation commands", () => { expect(next.document.assets.find((asset) => asset.id === "source-asset")?.source).toBe("composited-source"); expect(next.document.assets.find((asset) => asset.id === "source-asset")?.mimeType).toBe("image/png"); + expect(next.document.assets.find((asset) => asset.id === "source-asset")?.provenance).toMatchObject({ + kind: "generated", + candidateId: "candidate-1", + acceptance: "replacement", + inpaint: { + sourceAssetId: "source-asset", + maskAssetId: "mask-asset", + }, + }); expect(next.editor.selection).toEqual({ artboardId: "a1", layerIds: ["source-layer"] }); - expect(next.editor.generation).toEqual({ candidates: [], selectedCandidateId: undefined }); + expect(next.editor.generation).toEqual({ candidates: [], selectedCandidateId: undefined, compareMode: "result" }); }); }); diff --git a/commands/generation.ts b/commands/generation.ts index b010412..ba63559 100644 --- a/commands/generation.ts +++ b/commands/generation.ts @@ -1,9 +1,10 @@ import type { Asset } from "@core/asset"; +import type { AssetGenerationProvenance, GeneratedAssetAcceptance } from "@core/asset-provenance"; import type { ImageDocument } from "@core/document"; import type { ArtboardId, AssetId, LayerId } from "@core/id"; import type { ImageLayer } from "@core/image-layer"; import type { Layer } from "@core/layer"; -import type { GenerationCandidate, GenerationState } from "@editor/state"; +import type { GenerationCandidate, GenerationCompareMode, GenerationState } from "@editor/state"; import type { Command } from "./command"; import { commandIds } from "./ids"; @@ -15,6 +16,10 @@ export type GenerationSelectCandidatePayload = { candidateId?: string; }; +export type GenerationSetCompareModePayload = { + mode: GenerationCompareMode; +}; + export type GenerationRemoveCandidatePayload = { candidateId: string; }; @@ -33,6 +38,7 @@ export type GenerationReplaceCandidatePixelsPayload = { }; const maxCandidates = 12; +const generationCompareModes = new Set(["result", "before", "split"]); export const generationAddCandidateCommand: Command = { id: commandIds.generationAddCandidate, @@ -47,6 +53,7 @@ export const generationAddCandidateCommand: Command = { + 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 = { id: commandIds.generationRemoveCandidate, name: "Remove generation candidate", @@ -88,6 +115,7 @@ export const generationRemoveCandidateCommand: Command 0 ? state.editor.generation.compareMode : "result", }, }, }; @@ -99,12 +127,12 @@ export const generationClearCandidatesCommand: Command = { name: "Clear generation candidates", history: { mode: "ignore" }, execute({ state }) { - if (state.editor.generation.candidates.length === 0 && !state.editor.generation.selectedCandidateId) return state; + if (state.editor.generation.candidates.length === 0 && !state.editor.generation.selectedCandidateId && state.editor.generation.compareMode === "result") return state; return { ...state, editor: { ...state.editor, - generation: { candidates: [], selectedCandidateId: undefined }, + generation: { candidates: [], selectedCandidateId: undefined, compareMode: "result" }, }, }; }, @@ -125,6 +153,7 @@ export const generationApplyCandidateAsLayerCommand: Command asset.id === targetAsset.id ? { ...asset, source: payload.source, mimeType: payload.mimeType ?? asset.mimeType } : asset), + assets: state.document.assets.map((asset) => + asset.id === targetAsset.id + ? { + ...asset, + source: payload.source, + mimeType: payload.mimeType ?? asset.mimeType, + provenance: generationProvenance(candidate, "replacement"), + } + : asset, + ), }, editor: { ...state.editor, @@ -181,6 +219,7 @@ export const generationReplaceCandidatePixelsCommand: Command = { const targetLocation = findLayerLocation(state.document, payload.targetLayerId); const maskLocation = findLayerLocation(state.document, payload.maskLayerId); if (!targetLocation || !maskLocation) return state; - if (targetLocation.layer.clippingMask?.maskLayerId !== payload.maskLayerId) return state; + if (getLayerMask(targetLocation.layer)?.maskLayerId !== payload.maskLayerId) return state; if (maskLocation.layer.type === "group") return state; return { diff --git a/core/asset-provenance.ts b/core/asset-provenance.ts new file mode 100644 index 0000000..4b476b0 --- /dev/null +++ b/core/asset-provenance.ts @@ -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; diff --git a/core/asset.ts b/core/asset.ts index 45f92c4..ea470e1 100644 --- a/core/asset.ts +++ b/core/asset.ts @@ -1,5 +1,6 @@ import type { Size } from "./geometry"; import type { AssetId } from "./id"; +import type { AssetProvenance } from "./asset-provenance"; export type Asset = { id: AssetId; @@ -7,4 +8,5 @@ export type Asset = { mimeType: string; source: string; intrinsicSize: Size; + provenance?: AssetProvenance; }; diff --git a/core/base-layer.ts b/core/base-layer.ts index a1f6fda..57dbae4 100644 --- a/core/base-layer.ts +++ b/core/base-layer.ts @@ -1,5 +1,6 @@ import type { Transform } from "./geometry"; import type { LayerId } from "./id"; +import type { LayerMask } from "./layer-mask"; export type LayerClippingMask = { maskLayerId: LayerId; @@ -12,5 +13,7 @@ export type BaseLayer = { locked: boolean; opacity: number; transform: Transform; + layerMask?: LayerMask; + /** @deprecated Use layerMask. Kept readable for older documents. */ clippingMask?: LayerClippingMask; }; diff --git a/core/index.ts b/core/index.ts index 9cd1d6c..97639c5 100644 --- a/core/index.ts +++ b/core/index.ts @@ -1,5 +1,6 @@ export type { Artboard } from "./artboard"; export type { Asset } from "./asset"; +export type { AssetGenerationProvenance, AssetProvenance, GeneratedAssetAcceptance, GeneratedAssetMode } from "./asset-provenance"; export type { BaseLayer, LayerClippingMask } from "./base-layer"; export type { ImageDocument } from "./document"; export type { @@ -15,5 +16,7 @@ export type { export type { ArtboardId, AssetId, DocumentId, LayerId } from "./id"; export type { ImageLayer } from "./image-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 { RasterLayer } from "./raster-layer"; diff --git a/core/layer-mask-utils.ts b/core/layer-mask-utils.ts new file mode 100644 index 0000000..0bf3f68 --- /dev/null +++ b/core/layer-mask-utils.ts @@ -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)); +} diff --git a/core/layer-mask.ts b/core/layer-mask.ts new file mode 100644 index 0000000..76b63e6 --- /dev/null +++ b/core/layer-mask.ts @@ -0,0 +1,8 @@ +import type { LayerId } from "./id"; + +export type LayerMask = { + kind: "raster"; + maskLayerId: LayerId; + enabled: boolean; + inverted: boolean; +}; diff --git a/editor/document-indexes.test.ts b/editor/document-indexes.test.ts index 1ed8208..2c69c57 100644 --- a/editor/document-indexes.test.ts +++ b/editor/document-indexes.test.ts @@ -22,7 +22,10 @@ const document: ImageDocument = { locked: false, layers: [ 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", [ 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" } }, diff --git a/editor/document-indexes.ts b/editor/document-indexes.ts index da7e966..f9674df 100644 --- a/editor/document-indexes.ts +++ b/editor/document-indexes.ts @@ -3,6 +3,7 @@ import type { ImageDocument } from "@core/document"; import type { Rect } from "@core/geometry"; import type { ArtboardId, AssetId, LayerId } from "@core/id"; import type { Layer } from "@core/layer"; +import { getLayerMask } from "@core/layer-mask-utils"; export type IndexedLayerInfo = { artboardId: ArtboardId; @@ -113,9 +114,10 @@ function indexLayerTree(options: { index, }); - if (layer.clippingMask) { - options.documentMaskLayerIds.add(layer.clippingMask.maskLayerId); - layerListMaskLayerIds.add(layer.clippingMask.maskLayerId); + const layerMask = getLayerMask(layer); + if (layerMask) { + options.documentMaskLayerIds.add(layerMask.maskLayerId); + layerListMaskLayerIds.add(layerMask.maskLayerId); } if (layer.type === "group") { diff --git a/editor/initial-state.ts b/editor/initial-state.ts index 63a166a..aa6fac4 100644 --- a/editor/initial-state.ts +++ b/editor/initial-state.ts @@ -15,6 +15,7 @@ export const initialEditorState: EditorState = { generation: { candidates: [], selectedCandidateId: undefined, + compareMode: "result", }, transformSession: undefined, maskEdit: undefined, diff --git a/editor/state.ts b/editor/state.ts index 973c38c..3a3dca6 100644 --- a/editor/state.ts +++ b/editor/state.ts @@ -79,9 +79,12 @@ export type GenerationCandidate = { }; }; +export type GenerationCompareMode = "result" | "before" | "split"; + export type GenerationState = { candidates: GenerationCandidate[]; selectedCandidateId?: string; + compareMode: GenerationCompareMode; }; export type EditorState = { diff --git a/editor/transform-targets.test.ts b/editor/transform-targets.test.ts index c090604..3b6f3fe 100644 --- a/editor/transform-targets.test.ts +++ b/editor/transform-targets.test.ts @@ -44,6 +44,10 @@ describe("transform targets", () => { 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", () => { const next = applyTransformTargetBounds(document, { type: "layer", id: "l1" }, { x: 30, y: 40, w: 400, h: 50 }); 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 }); }); + 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", () => { 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 }); }); }); + +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 }, + }, + ], + }, + ], + }, + ], + }; +} diff --git a/editor/transform-targets.ts b/editor/transform-targets.ts index bee6be1..6fb598a 100644 --- a/editor/transform-targets.ts +++ b/editor/transform-targets.ts @@ -1,6 +1,7 @@ import type { ImageDocument } from "@core/document"; import type { Rect } from "@core/geometry"; import type { Layer } from "@core/layer"; +import { getLayerMask } from "@core/layer-mask-utils"; import type { ArtboardId, LayerId } from "@core/id"; 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 { 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( (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[] { return layers.map((layer) => { if (layer.id === layerId && (layer.type === "image" || layer.type === "raster")) { diff --git a/input/layers-panel.test.ts b/input/layers-panel.test.ts index 5866d5e..0f96671 100644 --- a/input/layers-panel.test.ts +++ b/input/layers-panel.test.ts @@ -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", () => { const dispatched: unknown[] = []; const consumed = handleDeleteSelectionKey({ diff --git a/input/layers-panel.ts b/input/layers-panel.ts index b443e71..ced6380 100644 --- a/input/layers-panel.ts +++ b/input/layers-panel.ts @@ -41,6 +41,7 @@ export function resolveLayerDrop(options: { const targetInfo = findLayerInfoInDocument(options.document, options.target.layer.id); const sourceInfo = findLayerInfoInDocument(options.document, options.sourceLayerId); 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 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; } + +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; +} diff --git a/input/selection.ts b/input/selection.ts index 875ca4e..518acd0 100644 --- a/input/selection.ts +++ b/input/selection.ts @@ -2,6 +2,7 @@ import { commandIds } from "@commands/ids"; import type { Dispatch } from "@commands/dispatcher"; import type { ImageDocument } from "@core/document"; import type { Layer } from "@core/layer"; +import { getLayerMask } from "@core/layer-mask-utils"; import { resolveTransformTargetBounds, viewportPointToDocumentPoint, type InputViewportState } from "./document-geometry"; import type { PointerInputEvent } from "./pointer"; @@ -65,9 +66,9 @@ function findTopmostLayerInTreeAtPoint(document: ImageDocument, layers: Layer[], function collectMaskLayerIds(layers: readonly Layer[], ids = new Set()): Set { 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); } return ids; } - diff --git a/renderer/image-textures.ts b/renderer/image-textures.ts index b0ef00d..56d5272 100644 --- a/renderer/image-textures.ts +++ b/renderer/image-textures.ts @@ -5,11 +5,11 @@ export type MaskVisualizationMode = "blackWhite" | "alpha" | "hiddenOverlay"; export type ImageTextureRenderer = { syncAssets(assets: readonly Asset[]): void; - render(asset: Asset, rect: ScreenRect, clipRect?: ScreenRect): boolean; - renderMasked(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, clipRect?: ScreenRect): boolean; - renderMaskRevealPreview(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, opacity: number, clipRect?: ScreenRect): boolean; + render(asset: Asset, rect: ScreenRect, clipRect?: ScreenRect, opacity?: number): 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, layerOpacity?: number): 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; }; @@ -39,12 +39,14 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali const positionLocation = gl.getAttribLocation(program, "a_position"); const texCoordLocation = gl.getAttribLocation(program, "a_texCoord"); const samplerLocation = gl.getUniformLocation(program, "u_image"); + const opacityLocation = gl.getUniformLocation(program, "u_opacity"); const maskedProgram = createMaskedProgram(gl); const maskedPositionLocation = gl.getAttribLocation(maskedProgram, "a_position"); const maskedTexCoordLocation = gl.getAttribLocation(maskedProgram, "a_texCoord"); const maskedMaskTexCoordLocation = gl.getAttribLocation(maskedProgram, "a_maskTexCoord"); const maskedSamplerLocation = gl.getUniformLocation(maskedProgram, "u_image"); const maskedMaskSamplerLocation = gl.getUniformLocation(maskedProgram, "u_mask"); + const maskedOpacityLocation = gl.getUniformLocation(maskedProgram, "u_opacity"); const maskRevealPreviewProgram = createMaskRevealPreviewProgram(gl); const maskRevealPreviewPositionLocation = gl.getAttribLocation(maskRevealPreviewProgram, "a_position"); const maskRevealPreviewTexCoordLocation = gl.getAttribLocation(maskRevealPreviewProgram, "a_texCoord"); @@ -69,8 +71,10 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali !texCoordBuffer || !maskTexCoordBuffer || !samplerLocation || + !opacityLocation || !maskedSamplerLocation || !maskedMaskSamplerLocation || + !maskedOpacityLocation || !maskRevealPreviewSamplerLocation || !maskRevealPreviewMaskSamplerLocation || !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; 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.bindTexture(gl.TEXTURE_2D, texture); gl.uniform1i(samplerLocation, 0); + gl.uniform1f(opacityLocation, clampedOpacity); gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); 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); 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 drawRect = clippedRect ? intersectScreenRects(clippedRect, maskRect) : undefined; 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.bindTexture(gl.TEXTURE_2D, maskTexture); gl.uniform1i(maskedMaskSamplerLocation, 1); + gl.uniform1f(maskedOpacityLocation, clampedOpacity); gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); 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); return true; }, - renderMaskRevealPreview(asset, rect, maskAsset, maskRect, opacity, clipRect) { - const clampedOpacity = Math.max(0, Math.min(1, opacity)); + renderMaskRevealPreview(asset, rect, maskAsset, maskRect, opacity, clipRect, layerOpacity = 1) { + const clampedOpacity = clampOpacity(opacity) * clampOpacity(layerOpacity); if (clampedOpacity <= 0) return true; const clippedRect = clipRect ? intersectScreenRects(rect, clipRect) : rect; @@ -248,7 +258,9 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali gl.disable(gl.BLEND); 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; 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.bindTexture(gl.TEXTURE_2D, texture); 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.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); } +function clampOpacity(opacity: number) { + return Math.max(0, Math.min(1, opacity)); +} + function fullTexCoords() { 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 precision mediump float; uniform sampler2D u_image; + uniform float u_opacity; in vec2 v_texCoord; out vec4 outColor; void main() { - outColor = texture(u_image, v_texCoord); + outColor = texture(u_image, v_texCoord) * u_opacity; }`, ); const program = gl.createProgram(); @@ -691,6 +708,7 @@ function createMaskedProgram(gl: WebGL2RenderingContext) { precision mediump float; uniform sampler2D u_image; uniform sampler2D u_mask; + uniform float u_opacity; in vec2 v_texCoord; in vec2 v_maskTexCoord; out vec4 outColor; @@ -699,7 +717,7 @@ function createMaskedProgram(gl: WebGL2RenderingContext) { vec4 maskColor = texture(u_mask, v_maskTexCoord); float maskAlpha = maskColor.a * dot(maskColor.rgb, vec3(0.2126, 0.7152, 0.0722)); float alpha = color.a * maskAlpha; - outColor = vec4(color.rgb * maskAlpha, alpha); + outColor = vec4(color.rgb * maskAlpha, alpha) * u_opacity; }`, ); const program = gl.createProgram(); diff --git a/renderer/layers.ts b/renderer/layers.ts index 4a83f1e..a8f53cc 100644 --- a/renderer/layers.ts +++ b/renderer/layers.ts @@ -2,6 +2,7 @@ import type { Asset } from "@core/asset"; import type { ImageDocument } from "@core/document"; import type { Rect } from "@core/geometry"; import type { Layer } from "@core/layer"; +import { getLayerMask } from "@core/layer-mask-utils"; import { createDocumentReadIndex, forEachLayerBackToFront, resolveIndexedLayerBounds, type DocumentReadIndex } from "@editor/document-indexes"; import type { EditorState, GenerationCandidate, MaskViewMode, ViewportState } from "@editor/state"; 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 imageLayerInsetColor: RgbaColor = [0.48, 0.54, 0.64, 1]; const hiddenMaskOverlayColor: RgbaColor = [1, 0.08, 0.08, 0.45]; +const comparisonDividerColor: RgbaColor = [1, 1, 1, 0.9]; const maskRevealPreviewOpacity = 0.28; 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; const clipRect = documentRectToScreenRect(context.canvas, artboard.bounds, editor.viewport); 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); } } @@ -39,17 +41,20 @@ function renderLayer( imageTextureRenderer: ImageTextureRenderer, clipRect: ScreenRect, maskLayerIds: ReadonlySet, + inheritedOpacity: number, ) { const editingMaskLayer = editor.maskEdit?.maskLayerId === layer.id; const maskViewMode = editor.maskEdit?.viewMode ?? "composite"; const isolatedMaskView = isIsolatedMaskView(maskViewMode); 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); if (!effectiveClipRect) return; 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; } @@ -60,33 +65,34 @@ function renderLayer( const rect = documentRectToScreenRect(context.canvas, bounds, editor.viewport); 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 maskBounds = maskLayer ? resolveIndexedLayerBounds(documentIndex, maskLayer) : 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"; if (asset && maskAsset && maskRect && activeMaskTarget) { if (maskViewMode === "blackWhite" && imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "blackWhite", 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); return; } } - if (asset && maskAsset && maskRect && imageTextureRenderer.renderMasked(asset, rect, maskAsset, maskRect, effectiveClipRect)) { - if (showMaskRevealPreview) imageTextureRenderer.renderMaskRevealPreview(asset, rect, maskAsset, maskRect, maskRevealPreviewOpacity, effectiveClipRect); + if (asset && maskAsset && maskRect && imageTextureRenderer.renderMasked(asset, rect, maskAsset, maskRect, effectiveClipRect, effectiveOpacity)) { + if (showMaskRevealPreview) imageTextureRenderer.renderMaskRevealPreview(asset, rect, maskAsset, maskRect, maskRevealPreviewOpacity, effectiveClipRect, effectiveOpacity); return; } - if (asset && imageTextureRenderer.render(asset, rect, effectiveClipRect)) return; + if (asset && imageTextureRenderer.render(asset, rect, effectiveClipRect, effectiveOpacity)) return; const fallbackRect = intersectScreenRects(rect, effectiveClipRect); 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); - if (insetRect) clearScreenRect(context, insetRect, imageLayerInsetColor); + if (insetRect) clearScreenRect(context, insetRect, withOpacity(imageLayerInsetColor, effectiveOpacity)); } const emptyLayerIds = new Set(); @@ -98,9 +104,10 @@ function resolveLayerClipRect( layer: Layer, clipRect: ScreenRect, ): 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; return intersectScreenRects(clipRect, documentRectToScreenRect(context.canvas, maskBounds, viewport)); @@ -118,6 +125,29 @@ function renderGenerationCandidatePreview( clipRect: ScreenRect, ) { 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); } @@ -162,3 +192,7 @@ function intersectScreenRects(a: ScreenRect, b: ScreenRect): ScreenRect | undefi 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]; +} diff --git a/view/LayersSheet.tsx b/view/LayersSheet.tsx index 892ea83..df97b4c 100644 --- a/view/LayersSheet.tsx +++ b/view/LayersSheet.tsx @@ -4,6 +4,7 @@ import { commandIds } from "@commands/ids"; import type { Asset } from "@core/asset"; import type { ImageDocument } from "@core/document"; import type { Layer } from "@core/layer"; +import { getLayerMask } from "@core/layer-mask-utils"; import type { ArtboardId } from "@core/id"; import { createDocumentReadIndex, resolveIndexedLayerBounds, type DocumentReadIndex, type IndexedLayerInfo } from "@editor/document-indexes"; import type { MaskEditState, SelectionState } from "@editor/state"; @@ -220,10 +221,11 @@ function LayerRow({ const selected = selectedLayerIds.includes(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 canAddMask = Boolean(layerInfo && layer.type !== "group" && !layer.clippingMask); - const editingMask = Boolean(maskEdit && layer.clippingMask && maskEdit.targetLayerId === layer.id && maskEdit.maskLayerId === layer.clippingMask.maskLayerId); + const canAddMask = Boolean(layerInfo && layer.type !== "group" && !layerMask); + const editingMask = Boolean(maskEdit && layerMask && maskEdit.targetLayerId === layer.id && maskEdit.maskLayerId === layerMask.maskLayerId); const rowPadding = 12 + depth * 16; return ( @@ -273,7 +275,7 @@ function LayerRow({ {layer.name} )} - {layer.clippingMask ? ( + {layerMask ? ( Mask @@ -290,7 +292,7 @@ function LayerRow({ {layer.locked ? : } - {layer.clippingMask ? ( + {layerMask ? (
{maskLayer ? "Layer mask" : "Layer mask missing"} @@ -303,7 +305,7 @@ function LayerRow({ onClick={() => editingMask ? 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"} @@ -313,7 +315,7 @@ function LayerRow({ className={maskActionButtonClass()} title="Paint reveal" 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" }); }} > @@ -324,7 +326,7 @@ function LayerRow({ className={maskActionButtonClass()} title="Paint hide" 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" }); }} > @@ -592,7 +594,8 @@ function moveLayer(documentIndex: DocumentReadIndex, info: IndexedLayerInfo, dir const blocks = siblings.flatMap((layer, index) => { 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 end = maskIndex >= 0 ? Math.max(maskIndex, index) : index; return [{ layerId: layer.id, start, end, size: end - start + 1 }]; diff --git a/view/bottom-controls/ChromaKeyControls.tsx b/view/bottom-controls/ChromaKeyControls.tsx index 7a36b75..2c83445 100644 --- a/view/bottom-controls/ChromaKeyControls.tsx +++ b/view/bottom-controls/ChromaKeyControls.tsx @@ -3,6 +3,7 @@ import { DropHalf } from "@phosphor-icons/react"; import { commandIds } from "@commands/ids"; import type { ImageDocument } from "@core/document"; import type { Layer } from "@core/layer"; +import { getLayerMask } from "@core/layer-mask-utils"; import { resolveTransformTargetBounds } from "@editor/transform-targets"; import type { AppStore } from "@editor/store"; import type { ChromaKeySettings } from "@editor/tools"; @@ -160,7 +161,8 @@ function resolveChromaKeyTarget(document: ImageDocument, selection: SelectionSta if (!layer || layer.type === "group") return undefined; const asset = document.assets.find((candidate) => candidate.id === layer.assetId); 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; return asset && bounds ? { layer, asset, bounds, maskLayer, maskAsset } : undefined; } diff --git a/view/bottom-controls/GenerateActionControls.tsx b/view/bottom-controls/GenerateActionControls.tsx index f14f3a5..2432c06 100644 --- a/view/bottom-controls/GenerateActionControls.tsx +++ b/view/bottom-controls/GenerateActionControls.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { commandIds } from "@commands/ids"; 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 { AppStore } from "@editor/store"; import { createMaskedPixelReplacementSource } from "../generate/candidateActions"; @@ -62,6 +62,7 @@ export function GenerateActionControls({ document, selection, viewport, settings void; @@ -128,6 +131,7 @@ function CandidateControls({
Seed {candidate.seed} + rerun("Regenerate", candidate.settings)} /> rerun("Reuse seed", { ...candidate.settings, seed: candidate.seed })} /> rerun("New seed", { ...candidate.settings, seed: -1 })} /> - applyCandidateAsLayer(candidate, false, dispatch)} /> + applyCandidateAsLayer(candidate, false, dispatch)} /> { setBusy("Refine"); @@ -152,10 +156,10 @@ function CandidateControls({ .finally(() => setBusy(undefined)); }} /> - applyCandidateAsLayer(candidate, true, dispatch)} /> + applyCandidateAsLayer(candidate, true, dispatch)} /> { @@ -183,6 +187,36 @@ function CandidateControls({ ); } +function CandidateCompareControls({ compareMode, disabled, dispatch }: { compareMode: GenerationCompareMode; disabled: boolean; dispatch: AppStore["dispatch"] }) { + return ( + + {generationCompareOptions.map((option) => { + const active = compareMode === option.mode; + return ( + + ); + })} + + ); +} + +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 }) { if (!candidate.inputImage) { return ; diff --git a/view/canvas/magic-wand.ts b/view/canvas/magic-wand.ts index b3d035b..7a854a4 100644 --- a/view/canvas/magic-wand.ts +++ b/view/canvas/magic-wand.ts @@ -2,6 +2,7 @@ import { commandIds } from "@commands/ids"; import type { ImageDocument } from "@core/document"; import type { Vec2D } from "@core/geometry"; import type { Layer } from "@core/layer"; +import { getLayerMask } from "@core/layer-mask-utils"; import { resolveTransformTargetBounds } from "@editor/transform-targets"; import type { AppStore } from "@editor/store"; import type { EditorState } from "@editor/state"; @@ -41,7 +42,8 @@ function resolveTarget(document: ImageDocument, editor: EditorState) { if (!layer || layer.type === "group") return undefined; const asset = document.assets.find((candidate) => candidate.id === layer.assetId); 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; return asset && bounds ? { layer, asset, bounds, maskLayer, maskAsset } : undefined; } diff --git a/view/canvas/renderFrame.test.ts b/view/canvas/renderFrame.test.ts index 175f25b..194be69 100644 --- a/view/canvas/renderFrame.test.ts +++ b/view/canvas/renderFrame.test.ts @@ -177,6 +177,7 @@ const visualEditorChanges: Array<[string, (state: AppState) => AppState]> = [ }, ], selectedCandidateId: "candidate", + compareMode: "result", }, }, }), diff --git a/view/exportArtboardPng.ts b/view/exportArtboardPng.ts index 2afe850..ccf8f7c 100644 --- a/view/exportArtboardPng.ts +++ b/view/exportArtboardPng.ts @@ -2,6 +2,7 @@ import type { Artboard } from "@core/artboard"; import type { Asset } from "@core/asset"; import type { Rect } from "@core/geometry"; import type { Layer } from "@core/layer"; +import { getLayerMask } from "@core/layer-mask-utils"; export async function downloadArtboardPng(artboard: Artboard, assets: readonly Asset[]) { 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 (!options.ignoreOwnMask && layer.clippingMask) { - const maskLayer = findLayer(layerTree, layer.clippingMask.maskLayerId); + const layerMask = getLayerMask(layer); + if (!options.ignoreOwnMask && layerMask?.enabled) { + const maskLayer = findLayer(layerTree, layerMask.maskLayerId); if (!maskLayer) return; await drawMaskedLayer(context, layer, maskLayer, layerTree, assets, artboardBounds); return; @@ -122,7 +124,8 @@ function translatedContext(canvas: HTMLCanvasElement, bounds: Rect) { function collectMaskLayerIds(layers: readonly Layer[], ids = new Set()) { 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); } return ids; diff --git a/view/generate/inpaintPrep.ts b/view/generate/inpaintPrep.ts index 99b0dc8..8032b0d 100644 --- a/view/generate/inpaintPrep.ts +++ b/view/generate/inpaintPrep.ts @@ -2,6 +2,7 @@ import type { Asset } from "@core/asset"; import type { ImageDocument } from "@core/document"; import type { Rect } from "@core/geometry"; import type { Layer } from "@core/layer"; +import { getLayerMask } from "@core/layer-mask-utils"; import type { SelectionState } from "@editor/state"; import type { GenerateSettings } from "@editor/tools"; 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); 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."); const maskAsset = documentIndex.assetById.get(maskLayer.assetId); diff --git a/view/generate/runGenerate.ts b/view/generate/runGenerate.ts index 20a2f41..3e4dfcf 100644 --- a/view/generate/runGenerate.ts +++ b/view/generate/runGenerate.ts @@ -2,6 +2,7 @@ import { commandIds } from "@commands/ids"; import type { ImageDocument } from "@core/document"; import type { Transform } from "@core/geometry"; import type { Layer } from "@core/layer"; +import { getLayerMask } from "@core/layer-mask-utils"; import type { AppStore } from "@editor/store"; import type { GenerationCandidate, SelectionState, ViewportState } from "@editor/state"; 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); if (!layer || layer.type === "group") return undefined; 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; return asset ? { layer, asset, maskAsset } : undefined; }