feat: enhance layer mask handling and opacity management

- Introduced `getLayerMask` utility to streamline layer mask retrieval.
- Updated layer rendering logic to incorporate layer masks and opacity adjustments.
- Added functionality to prevent dropping a group into its descendants.
- Enhanced image texture rendering to support opacity parameters across various rendering functions.
- Implemented group layer bounds application for better scaling and positioning.
- Added tests to ensure correct behavior when handling layer masks and group layers.
- Created new types for asset generation provenance to track generated assets more effectively.
This commit is contained in:
syntaxbullet
2026-07-05 16:06:23 +02:00
parent a83024c35c
commit 5eaf37ba28
33 changed files with 635 additions and 88 deletions

View File

@@ -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;
}

View File

@@ -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
<CandidateControls
document={document}
candidate={candidate}
compareMode={generation.compareMode ?? "result"}
settings={settings}
busy={busy}
setBusy={setBusy}
@@ -100,6 +101,7 @@ function CandidatePicker({ generation, dispatch }: { generation: GenerationState
function CandidateControls({
document,
candidate,
compareMode,
settings,
busy,
setBusy,
@@ -108,6 +110,7 @@ function CandidateControls({
}: {
document: ImageDocument;
candidate: GenerationCandidate;
compareMode: GenerationCompareMode;
settings: GenerateSettings;
busy?: string;
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]">
<CandidatePreview candidate={candidate} />
<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}
@@ -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="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
disabled={disabled}
label="Apply + refine"
title="Apply candidate as a layer with a fresh refinement mask"
label="Accept + mask"
title="Accept candidate as a layer with a fresh refinement mask"
busy={busy === "Refine"}
onClick={() => {
setBusy("Refine");
@@ -152,10 +156,10 @@ function CandidateControls({
.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
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"}
busy={busy === "Replace"}
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 }) {
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" />;