diff --git a/.changeset/alpha-compositing-foundation.md b/.changeset/alpha-compositing-foundation.md new file mode 100644 index 00000000..cd35f4c5 --- /dev/null +++ b/.changeset/alpha-compositing-foundation.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": patch +--- + +Preserve alpha when copying external input textures and applying effect passes. Source layers use straight-alpha source-over blending; effect passes preserve input coverage and retain their existing alpha-as-strength behavior. Opaque compositions and legacy mask semantics are unchanged. diff --git a/.changeset/editor-groups.md b/.changeset/editor-groups.md new file mode 100644 index 00000000..335e783a --- /dev/null +++ b/.changeset/editor-groups.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Support isolated groups in exported ShaderLabConfig layer lists using group layers and parentId references. Evaluate group visibility and opacity before building the render tree, preserving child effect scope in composition and headless rendering. Existing flat configs remain compatible. diff --git a/.changeset/isolated-group-rendering.md b/.changeset/isolated-group-rendering.md new file mode 100644 index 00000000..e4187797 --- /dev/null +++ b/.changeset/isolated-group-rendering.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": patch +--- + +Add isolated, nested group rendering to the internal renderer frame contract. Group children render against transparent targets before the combined result is blended into the parent. Existing flat public shader configs retain their behavior; editor grouping controls and persisted groups follow separately. diff --git a/.changeset/transparent-media-bounds.md b/.changeset/transparent-media-bounds.md new file mode 100644 index 00000000..931e9dab --- /dev/null +++ b/.changeset/transparent-media-bounds.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": patch +--- + +Support transparent borders for contained image and video layers with `params.transparentBounds: true`. Existing runtime configs without the setting retain black borders. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7a0c800..056c78ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: branches: - main - git-chad/shader-lab-v3-plan + - git-chad/v3-* push: branches: - main @@ -50,3 +51,24 @@ jobs: - name: Ensure changeset exists for PR changes if: github.event_name == 'pull_request' run: bunx changeset status --since=origin/${{ github.base_ref }} + + composition: + # Linux Chromium/SwiftShader currently drops its GPU instance during these + # texture-backed shader compilations. Keep the full GPU checks on macOS. + runs-on: macos-14 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - run: bun install --frozen-lockfile + - run: bunx playwright install chromium + - run: bun run test:composition + - name: Upload rendered compositions + if: always() + uses: actions/upload-artifact@v4 + with: + name: composition-actuals + path: .context/composition-test/ + if-no-files-found: ignore diff --git a/V3-ROADMAP.md b/V3-ROADMAP.md index d7a7ac6b..0134361f 100644 --- a/V3-ROADMAP.md +++ b/V3-ROADMAP.md @@ -6,13 +6,19 @@ El orden es una **propuesta de ejecución**; las prioridades de producto son rev ## Flujo de integración y entrega -Todo el trabajo de V3 se entrega mediante PRs pequeños dirigidos a una rama de integración de V3. Un PR principal reúne esa rama y tiene como destino `main`. +Todo el trabajo de V3 se entrega mediante PRs pequeños apilados. Cada nuevo PR parte de la rama del PR anterior y la usa como base. El primer PR de la pila apunta a la rama de integración de V3; un PR principal reúne esa rama y tiene como destino `main`. -**PRs pequeños → rama de integración de V3 → PR principal → `main`.** +**PR nuevo → PR anterior → rama de integración de V3 → PR principal → `main`.** Los PRs pequeños pueden integrarse progresivamente en la rama de V3. Ningún cambio de V3 se integra en `main` hasta completar el alcance obligatorio del plan y su validación global de la fase 7. La fase 8 sigue siendo opcional y no bloquea esa entrega. -La rama de integración es `git-chad/shader-lab-v3-plan`. Todos los PRs pequeños de V3 deben usarla como base; únicamente el PR principal apunta a `main` y permanece en borrador hasta completar la validación obligatoria. +La rama de integración es `git-chad/shader-lab-v3-plan`. La pila comienza con [#151](https://github.com/basementstudio/shader-lab/pull/151), en `git-chad/v3-composition-alpha`, seguido de [#152](https://github.com/basementstudio/shader-lab/pull/152), en `git-chad/v3-transparent-media-bounds`. La base compartida de composición alfa continúa desde esta última rama. Al integrar PRs, actualizar las bases de los descendientes para conservar la pila; únicamente el PR principal apunta a `main` y permanece en borrador hasta completar la validación obligatoria. + +PR principal: [#150 — V3: designer-focused composition and experimental graphics](https://github.com/basementstudio/shader-lab/pull/150). + +Primer paso de la fase 1: [baselines de composición y mapa del canal alfa](tests/composition/README.md). Esta cobertura inicial no cierra la fase ni sustituye la validación visual con las catorce referencias elegidas. + +Avance de 1.3: grupos aislados con hasta ocho niveles, controles del editor, reordenamiento de subárboles, plegado, visibilidad, opacidad, duplicación, deshacer, persistencia v7 y exportación de configuración. Las pruebas cubren estado, hidratación real y píxeles guardados/reabiertos. Queda la [validación manual de grupos](tests/composition/GROUPS-MANUAL-QA.md); máscaras de cobertura y validación global de la fase 1 siguen pendientes. ## Fase 1 — Resolver la base de composición y proteger proyectos existentes diff --git a/bun.lock b/bun.lock index 5860ee77..0b577af4 100644 --- a/bun.lock +++ b/bun.lock @@ -51,6 +51,7 @@ "babel-plugin-react-compiler": "1.0.0", "cross-env": "^10.1.0", "drizzle-kit": "^0.31.10", + "playwright": "^1.63.0", "postcss-preset-env": "^10.6.1", "tailwindcss": "^4.1.18", "typescript": "^5.9.3", @@ -58,7 +59,7 @@ }, "packages/shader-lab-mcp": { "name": "@basementstudio/shader-lab-mcp", - "version": "0.1.0", + "version": "0.1.1", "bin": { "shader-lab-mcp": "dist/stdio.js", }, @@ -78,7 +79,7 @@ }, "packages/shader-lab-react": { "name": "@basementstudio/shader-lab", - "version": "3.0.0", + "version": "3.0.2", "dependencies": { "typescript": "^5.9.3", }, @@ -1912,6 +1913,10 @@ "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="], + + "playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="], + "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], "postcss-attribute-case-insensitive": ["postcss-attribute-case-insensitive@7.0.1", "", { "dependencies": { "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-Uai+SupNSqzlschRyNx3kbCTWgY/2hcwtHEI/ej2LJWc9JJ77qKgGptd8DHwY1mXtZ7Aoh4z4yxfwMBue9eNgw=="], diff --git a/package.json b/package.json index fc2897ef..efc1b5be 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "version-canary": "changeset version --snapshot canary", "version-packages": "changeset version", "start": "next start", + "test:composition": "bun tests/composition/run.mjs", "typecheck": "tsgo --noEmit", "typecheck:runtime": "bun run --cwd packages/shader-lab-react typecheck", "typecheck:compare": "bun run typecheck:tsc && bun run typecheck", @@ -84,6 +85,7 @@ "babel-plugin-react-compiler": "1.0.0", "cross-env": "^10.1.0", "drizzle-kit": "^0.31.10", + "playwright": "^1.63.0", "postcss-preset-env": "^10.6.1", "tailwindcss": "^4.1.18", "typescript": "^5.9.3" diff --git a/packages/shader-lab-react/README.md b/packages/shader-lab-react/README.md index 3136c719..4e0ed564 100644 --- a/packages/shader-lab-react/README.md +++ b/packages/shader-lab-react/README.md @@ -40,6 +40,14 @@ Supported effect layers include ASCII, blob tracking, CRT, directional blur, dit The blob-tracking layer detects moving (or bright) regions of the content below and frames them with CCTV-style shapes, labels, connecting lines, and trails. One inner effect can render inside the detected shapes via `params.innerEffectType` (any pass-backed effect type) and `params.innerEffectParams` (a JSON string of that effect's parameter overrides; see the `ShaderLabBlobInnerEffect` type). Set `params.outputMode` to `"mask"` to emit white-on-black fills for use with `compositeMode: "mask"`. +## Isolated groups + +Exported configs can include layers with `kind: "group"`, `type: "group"`, and `params: {}`. Children reference the group's ID through `parentId`. The flat array uses top-first, depth-first order: group first, then all of its descendants, then the next sibling. Missing or null `parentId` places a layer at the scene root. IDs must be unique and nesting is limited to eight groups. + +For example, the order `[title, portraitGroup, halftone, photo, background]` with `halftone.parentId` and `photo.parentId` set to `portraitGroup.id` confines halftone to the photo. Each group starts transparent, processes its contents, and then applies its own opacity and blend mode to the result. Hiding a group hides its whole subtree without changing child visibility. Group opacity/visibility can use ordinary layer timeline tracks. + +Groups use `compositeMode: "filter"`, `hue: 0`, and `saturation: 1`; coverage masks and group color adjustments are not exposed in this iteration. Group targets currently cost about 32 MiB per group at 1080p. Existing flat configs continue to work without any new fields. + ## Fonts The ASCII and text layers resolve font families at runtime by reading CSS @@ -347,6 +355,13 @@ export function PostProcessingExample({ - Keep the postprocessing `width`/`height` and `postprocessing.resize(...)` in sync with the input render target size, especially for screen-space effects like dithering, pixelation, and ASCII - `postprocessing.texture` always points to the latest output texture +External input textures and output textures use straight (unpremultiplied) RGB +and alpha. The texture path preserves input coverage through effect passes; +effect output alpha controls effect strength. Source layers use source-over +composition. Convert premultiplied input to straight alpha before passing it in. +Legacy mask layers still use their existing RGB-darkening behavior, and the +canvas composition background remains opaque. + ## `useShaderLab` ```ts @@ -653,6 +668,11 @@ export const sketch = Fn(() => { ## Included Runtime Support +Image and video layers in Contain mode support `params.transparentBounds: true` +to reveal the content beneath their empty borders. Omit the setting or use +`false` to retain black borders. This option does not affect Cover mode or make +the composition's canvas background transparent. + - Gradient - Text - Custom shader (source and effect modes) diff --git a/packages/shader-lab-react/src/ambient/three-webgpu.d.ts b/packages/shader-lab-react/src/ambient/three-webgpu.d.ts index 8740a737..122555f4 100644 --- a/packages/shader-lab-react/src/ambient/three-webgpu.d.ts +++ b/packages/shader-lab-react/src/ambient/three-webgpu.d.ts @@ -4,6 +4,7 @@ declare module "three/webgpu" { import type { Box3, Camera, + Color, ColorRepresentation, Material, Scene, @@ -47,6 +48,8 @@ declare module "three/webgpu" { // biome-ignore lint/suspicious/noExplicitAny: compute node type from Fn().compute() is opaque computeAsync(computeNodes: any): Promise clear(): void + getClearAlpha(): number + getClearColor(target: Color): Color copyTextureToTexture( srcTexture: Texture, dstTexture: Texture, diff --git a/packages/shader-lab-react/src/renderer/blend-modes.ts b/packages/shader-lab-react/src/renderer/blend-modes.ts index 9a67bc74..df5a2d88 100644 --- a/packages/shader-lab-react/src/renderer/blend-modes.ts +++ b/packages/shader-lab-react/src/renderer/blend-modes.ts @@ -161,6 +161,8 @@ export type MaskNodeConfig = { source: string } +export type CompositionRole = "source" | "effect" + export function buildBlendNode( mode: string, base: Node, @@ -168,6 +170,7 @@ export function buildBlendNode( opacity: Node, compositeMode: "filter" | "mask" = "filter", maskConfig?: MaskNodeConfig, + role: CompositionRole = "source", ): Node { const baseRgb = base.rgb const blendRgb = blend.rgb @@ -228,9 +231,33 @@ export function buildBlendNode( } if (compositeMode === "filter") { - return vec4(mix(baseRgb, composited, normalizedOpacity.mul(blendAlpha)), float(1)) + const baseAlpha = float(clamp(base.a, float(0), float(1))) + const amount = normalizedOpacity.mul(blendAlpha) + const filteredRgb = mix(baseRgb, composited, amount) + + if (role === "effect") { + // An effect's output alpha controls its strength, not a second layer of + // coverage. Preserve the input silhouette through repeated filtering. + return vec4(filteredRgb, baseAlpha) + } + + // Straight-alpha source-over, with blending only where source and backdrop + // overlap. See https://www.w3.org/TR/compositing-1/#blending . + const outputAlpha = amount.add(baseAlpha.mul(float(1).sub(amount))) + const sourceRgb = mix(blendRgb, composited, baseAlpha) + const premultipliedRgb = sourceRgb.mul(amount).add( + baseRgb.mul(baseAlpha).mul(float(1).sub(amount)), + ) + // Avoid division by zero without dimming tiny but nonzero coverage. + const denominator = select(outputAlpha.greaterThan(0), outputAlpha, float(1)) + const outputRgb = premultipliedRgb.div(denominator) + // Keep the exact existing expression for opaque backdrops. + return vec4(select(baseAlpha.equal(1), filteredRgb, outputRgb), outputAlpha) } + // Existing mask mode is a saved RGB-darkening operation. Coverage masks + // require an explicit compatibility path and are not introduced here. + const source = maskConfig?.source ?? "luminance" let maskValue: Node switch (source) { diff --git a/packages/shader-lab-react/src/renderer/composition-tree.ts b/packages/shader-lab-react/src/renderer/composition-tree.ts new file mode 100644 index 00000000..c206bc5a --- /dev/null +++ b/packages/shader-lab-react/src/renderer/composition-tree.ts @@ -0,0 +1,71 @@ +/** Internal render tree; editor persistence and public shader configs remain flat. */ +export interface CompositionGroup { + blendMode: string + children: CompositionNode[] + id: string + kind: "group" + opacity: number + visible: boolean +} + +export type CompositionNode = Layer | CompositionGroup + +export const MAX_COMPOSITION_GROUP_DEPTH = 8 + +export function isCompositionGroup( + node: CompositionNode +): node is CompositionGroup { + return ( + typeof node === "object" && + node !== null && + "kind" in node && + node.kind === "group" + ) +} + +/** Validate the entire tree before changing live passes. IDs are globally unique. */ +export function flattenComposition( + nodes: CompositionNode[], + getLayerId: (layer: Layer) => string +): CompositionNode[] { + const result: CompositionNode[] = [] + const ids = new Set() + function visit(entries: CompositionNode[], depth: number): void { + for (const entry of entries) { + const group = isCompositionGroup(entry) + const id = group ? entry.id : getLayerId(entry) + if (ids.has(id)) throw new Error(`Duplicate composition ID: ${id}`) + ids.add(id) + result.push(entry) + if (group) { + if (depth >= MAX_COMPOSITION_GROUP_DEPTH) { + throw new Error( + `Composition groups support at most ${MAX_COMPOSITION_GROUP_DEPTH} levels` + ) + } + visit(entry.children, depth + 1) + } + } + } + visit(nodes, 0) + return result +} + +/** Frames use sidebar order (top first); pipelines paint bottom first at every level. */ +export function reverseComposition( + nodes: CompositionNode[], + depth = 0 +): CompositionNode[] { + if (depth > MAX_COMPOSITION_GROUP_DEPTH) { + throw new Error( + `Composition groups support at most ${MAX_COMPOSITION_GROUP_DEPTH} levels` + ) + } + return [...nodes] + .reverse() + .map((node) => + isCompositionGroup(node) + ? { ...node, children: reverseComposition(node.children, depth + 1) } + : node + ) +} diff --git a/packages/shader-lab-react/src/renderer/contracts.ts b/packages/shader-lab-react/src/renderer/contracts.ts index bc80f735..8b077a23 100644 --- a/packages/shader-lab-react/src/renderer/contracts.ts +++ b/packages/shader-lab-react/src/renderer/contracts.ts @@ -1,6 +1,8 @@ +import { buildCompositionTree } from "./layer-hierarchy" import { createRuntimeClock } from "../runtime-clock" import { resolveEvaluatedLayers } from "../timeline" import type { ShaderLabConfig, ShaderLabLayerConfig } from "../types" +import type { CompositionNode } from "./composition-tree" export interface RendererSize { height: number @@ -14,9 +16,13 @@ export interface ProjectClock { time: number } +export type RenderableLayerConfig = ShaderLabLayerConfig & { + kind: "source" | "effect" +} + export interface RendererFrame { clock: ProjectClock - layers: ShaderLabLayerConfig[] + layers: CompositionNode[] logicalSize: RendererSize outputSize: RendererSize pixelRatio: number @@ -49,11 +55,14 @@ export function buildRendererFrame( config.layers, config.timeline.tracks, time - ).filter((layer) => layer.visible) + ) return { clock: createRuntimeClock(config.timeline, time, delta), - layers, + layers: buildCompositionTree( + layers, + (layer) => layer as RenderableLayerConfig + ), logicalSize: options?.logicalSize ?? config.composition ?? viewportSize, outputSize: viewportSize, pixelRatio, diff --git a/packages/shader-lab-react/src/renderer/create-headless-renderer.ts b/packages/shader-lab-react/src/renderer/create-headless-renderer.ts index 43a61156..194ba660 100644 --- a/packages/shader-lab-react/src/renderer/create-headless-renderer.ts +++ b/packages/shader-lab-react/src/renderer/create-headless-renderer.ts @@ -1,6 +1,7 @@ import * as THREE from "three/webgpu" import type { RendererFrame, RendererSize } from "./contracts" import { PipelineManager } from "./pipeline-manager" +import { reverseComposition } from "./composition-tree" export interface HeadlessRenderer { dispose(): void @@ -79,7 +80,7 @@ export function createHeadlessRenderer( } pipeline.updateLogicalSize(frame.logicalSize) - pipeline.syncLayers([...frame.layers].reverse()) + pipeline.syncLayers(reverseComposition(frame.layers)) if (ownsRenderer) { return pipeline.renderToTexture( diff --git a/packages/shader-lab-react/src/renderer/create-webgpu-renderer.ts b/packages/shader-lab-react/src/renderer/create-webgpu-renderer.ts index d8b1b088..d99b4071 100644 --- a/packages/shader-lab-react/src/renderer/create-webgpu-renderer.ts +++ b/packages/shader-lab-react/src/renderer/create-webgpu-renderer.ts @@ -1,6 +1,7 @@ import * as THREE from "three/webgpu" import type { RendererFrame, RuntimeRenderer } from "./contracts" import { PipelineManager } from "./pipeline-manager" +import { reverseComposition } from "./composition-tree" export function browserSupportsWebGPU(): boolean { return typeof navigator !== "undefined" && "gpu" in navigator @@ -60,7 +61,7 @@ export async function createWebGPURenderer( } pipeline.updateLogicalSize(frame.logicalSize) - pipeline.syncLayers([...frame.layers].reverse()) + pipeline.syncLayers(reverseComposition(frame.layers)) return pipeline.render(frame.clock.time, frame.clock.delta) }, diff --git a/packages/shader-lab-react/src/renderer/group-pass.ts b/packages/shader-lab-react/src/renderer/group-pass.ts new file mode 100644 index 00000000..c91e5b74 --- /dev/null +++ b/packages/shader-lab-react/src/renderer/group-pass.ts @@ -0,0 +1,113 @@ +import { float, texture, type TSLNode, uv, vec2 } from "three/tsl" +import * as THREE from "three/webgpu" +import { PassNode } from "./pass-node" + +export type RenderChildPass = ( + pass: PassNode, + input: THREE.Texture, + output: THREE.WebGLRenderTarget, + time: number, + delta: number, + timelineTime: number +) => boolean + +/** Owns only its intermediate targets; the pipeline owns all child passes. */ +export class GroupPass extends PassNode { + private children: PassNode[] = [] + private readonly rtA: THREE.WebGLRenderTarget + private readonly rtB: THREE.WebGLRenderTarget + private readonly groupInput: TSLNode + private readonly clearColor = new THREE.Color() + + constructor( + id: string, + private readonly isActive: (pass: PassNode) => boolean, + private readonly renderChild: RenderChildPass + ) { + super(id) + const options = { + depthBuffer: false, + stencilBuffer: false, + type: THREE.HalfFloatType, + format: THREE.RGBAFormat, + minFilter: THREE.NearestFilter, + magFilter: THREE.NearestFilter, + generateMipmaps: false, + } + this.rtA = new THREE.WebGLRenderTarget(1, 1, options) + this.rtB = new THREE.WebGLRenderTarget(1, 1, options) + this.groupInput = texture( + this.rtA.texture, + vec2(uv().x, float(1).sub(uv().y)) + ) + this.updateCompositionRole("source") + this.rebuildEffectNode() + } + + setChildren(children: PassNode[]): boolean { + if ( + children.length === this.children.length && + children.every((pass, i) => pass === this.children[i]) + ) + return false + this.children = children + return true + } + + override resize(width: number, height: number): void { + this.rtA.setSize(width, height) + this.rtB.setSize(width, height) + } + + override needsContinuousRender(): boolean { + return this.children.some( + (pass) => this.isActive(pass) && pass.needsContinuousRender() + ) + } + + override render( + renderer: THREE.WebGPURenderer, + inputTexture: THREE.Texture, + outputTarget: THREE.WebGLRenderTarget, + time: number, + delta: number, + timelineTime = time + ): void { + // Never seed a group with the parent image or the opaque scene background. + const alpha = renderer.getClearAlpha() + renderer.getClearColor(this.clearColor) + try { + renderer.setRenderTarget(this.rtA) + renderer.setClearColor(0, 0) + renderer.clear() + } finally { + renderer.setClearColor(this.clearColor, alpha) + } + + let read = this.rtA + let write = this.rtB + for (const pass of this.children) { + if ( + !( + this.isActive(pass) && + this.renderChild(pass, read.texture, write, time, delta, timelineTime) + ) + ) + continue + ;[read, write] = [write, read] + } + this.groupInput.value = read.texture + super.render(renderer, inputTexture, outputTarget, time, delta) + } + + protected override buildEffectNode(): TSLNode { + return this.groupInput ?? this.inputNode + } + + override dispose(): void { + this.rtA.dispose() + this.rtB.dispose() + this.children = [] + super.dispose() + } +} diff --git a/packages/shader-lab-react/src/renderer/layer-hierarchy.ts b/packages/shader-lab-react/src/renderer/layer-hierarchy.ts new file mode 100644 index 00000000..8c4c4b2f --- /dev/null +++ b/packages/shader-lab-react/src/renderer/layer-hierarchy.ts @@ -0,0 +1,71 @@ +import { + type CompositionNode, + MAX_COMPOSITION_GROUP_DEPTH, +} from "./composition-tree" + +export interface HierarchyLayer { + id: string + kind: string + parentId?: string | null | undefined +} + +/** A flat, top-first preorder keeps subtrees contiguous for selection and moves. */ +export function validateLayerHierarchy( + layers: readonly HierarchyLayer[] +): void { + const ids = new Set() + const ancestors: string[] = [] + for (const layer of layers) { + if (ids.has(layer.id)) throw new Error(`Duplicate layer ID: ${layer.id}`) + ids.add(layer.id) + const parent = layer.parentId ?? null + while (ancestors.length && ancestors.at(-1) !== parent) ancestors.pop() + if (parent !== null && ancestors.at(-1) !== parent) { + throw new Error("Group children must follow their parent in layer order.") + } + if (layer.kind === "group") { + if (ancestors.length >= MAX_COMPOSITION_GROUP_DEPTH) { + throw new Error( + `Groups support at most ${MAX_COMPOSITION_GROUP_DEPTH} levels.` + ) + } + ancestors.push(layer.id) + } + } +} + +export function buildCompositionTree< + Layer extends HierarchyLayer & { + blendMode: string + opacity: number + visible: boolean + }, + Leaf, +>(layers: Layer[], leaf: (layer: Layer) => Leaf): CompositionNode[] { + validateLayerHierarchy(layers) + const root: CompositionNode[] = [] + const children = new Map[]>() + const hidden = new Set() + for (const layer of layers) { + if (!layer.visible || (layer.parentId && hidden.has(layer.parentId))) { + hidden.add(layer.id) + continue + } + const target = layer.parentId ? children.get(layer.parentId)! : root + if (layer.kind === "group") { + const nested: CompositionNode[] = [] + target.push({ + id: layer.id, + kind: "group", + blendMode: layer.blendMode, + opacity: layer.opacity, + visible: true, + children: nested, + }) + children.set(layer.id, nested) + } else { + target.push(leaf(layer)) + } + } + return root +} diff --git a/packages/shader-lab-react/src/renderer/media-pass.ts b/packages/shader-lab-react/src/renderer/media-pass.ts index 6296bf00..8cca8590 100644 --- a/packages/shader-lab-react/src/renderer/media-pass.ts +++ b/packages/shader-lab-react/src/renderer/media-pass.ts @@ -22,6 +22,7 @@ type Node = TSLNode export class MediaPass extends PassNode { private readonly canvasAspectUniform: Node private readonly fitModeUniform: Node + private readonly boundsAlphaUniform: Node private readonly offsetXUniform: Node private readonly offsetYUniform: Node private readonly scaleUniform: Node @@ -40,6 +41,7 @@ export class MediaPass extends PassNode { this.placeholder = new THREE.Texture() this.canvasAspectUniform = uniform(1) this.fitModeUniform = uniform(0) + this.boundsAlphaUniform = uniform(1) this.offsetXUniform = uniform(0) this.offsetYUniform = uniform(0) this.scaleUniform = uniform(1) @@ -101,6 +103,7 @@ export class MediaPass extends PassNode { override updateParams(params: LayerParameterValues): void { this.fitModeUniform.value = params.fitMode === "contain" ? 1 : 0 + this.boundsAlphaUniform.value = params.transparentBounds === true ? 0 : 1 this.scaleUniform.value = typeof params.scale === "number" ? 1 / Math.max(params.scale, 0.01) : 1 @@ -180,7 +183,11 @@ export class MediaPass extends PassNode { .and(sampledUv.x.lessThanEqual(1)) .and(sampledUv.y.greaterThanEqual(0)) .and(sampledUv.y.lessThanEqual(1)) - const contained = select(inBounds, this.mediaTextureNode, vec4(0, 0, 0, 1)) + const contained = select( + inBounds, + this.mediaTextureNode, + vec4(0, 0, 0, this.boundsAlphaUniform) + ) return mix(this.mediaTextureNode, contained, useContain) } diff --git a/packages/shader-lab-react/src/renderer/pass-node.ts b/packages/shader-lab-react/src/renderer/pass-node.ts index 6e4b7ef9..feae9e6a 100644 --- a/packages/shader-lab-react/src/renderer/pass-node.ts +++ b/packages/shader-lab-react/src/renderer/pass-node.ts @@ -13,7 +13,7 @@ import { vec3, vec4, } from "three/tsl" -import { buildBlendNode } from "./blend-modes" +import { buildBlendNode, type CompositionRole } from "./blend-modes" import type { BloomCompositor } from "./dual-filter-bloom" import type { LayerCompositeMode, LayerParameterValues, MaskConfig } from "../types/editor" @@ -56,6 +56,7 @@ export class PassNode { protected lastOutputTarget: THREE.WebGLRenderTarget | null = null private effectSwapGeneration = 0 private blendMode = "normal" + private compositionRole: CompositionRole = "effect" private compositeMode: LayerCompositeMode = "filter" private maskSource = "luminance" private maskMode = "multiply" @@ -66,7 +67,10 @@ export class PassNode { this.layerId = layerId this.scene = new THREE.Scene() this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1) + // The shader already composites the full pixel. NoBlending also prevents + // Three's opaque-material path from overwriting the computed alpha. this.material = new THREE.MeshBasicNodeMaterial() + this.material.blending = THREE.NoBlending this.opacityUniform = uniform(1) this.hueUniform = uniform(0) this.saturationUniform = uniform(1) @@ -114,6 +118,13 @@ export class PassNode { this.opacityUniform.value = opacity } + updateCompositionRole(role: CompositionRole): void { + if (role !== this.compositionRole) { + this.compositionRole = role + this.colorNodeDirty = true + } + } + updateBlendMode(blendMode: string): boolean { if (blendMode === this.blendMode) { return false @@ -227,6 +238,7 @@ export class PassNode { const generation = ++this.effectSwapGeneration const nextMaterial = new THREE.MeshBasicNodeMaterial() + nextMaterial.blending = THREE.NoBlending nextMaterial.colorNode = this.composeColorNode(nextEffectNode) const standbyScene = new THREE.Scene() @@ -286,6 +298,7 @@ export class PassNode { source: this.maskSource, } : undefined, + this.compositionRole, ) as Node } diff --git a/packages/shader-lab-react/src/renderer/pipeline-manager.ts b/packages/shader-lab-react/src/renderer/pipeline-manager.ts index ca6886b3..dd151f39 100644 --- a/packages/shader-lab-react/src/renderer/pipeline-manager.ts +++ b/packages/shader-lab-react/src/renderer/pipeline-manager.ts @@ -1,3 +1,10 @@ +import type { RenderableLayerConfig } from "./contracts" +import { + type CompositionNode, + flattenComposition, + isCompositionGroup, +} from "./composition-tree" +import { GroupPass } from "./group-pass" import { float, type TSLNode, texture as tslTexture, uv, vec2 } from "three/tsl" import * as THREE from "three/webgpu" import type { ShaderLabCompositeMode, ShaderLabLayerConfig } from "../types" @@ -204,6 +211,7 @@ export class PipelineManager { const blitUv = vec2(uv().x, float(1).sub(uv().y)) this.blitInputNode = tslTexture(createPipelinePlaceholder(), blitUv) this.blitMaterial = new THREE.MeshBasicNodeMaterial() + this.blitMaterial.blending = THREE.NoBlending this.blitMaterial.colorNode = this.blitInputNode const blitMesh = new THREE.Mesh( new THREE.PlaneGeometry(2, 2), @@ -213,8 +221,11 @@ export class PipelineManager { this.blitScene.add(blitMesh) } - syncLayers(layers: ShaderLabLayerConfig[]): void { - const incomingIds = new Set(layers.map((layer) => layer.id)) + syncLayers(layers: CompositionNode[]): void { + const getId = (node: CompositionNode) => + isCompositionGroup(node) ? node.id : node.id + const flattened = flattenComposition(layers, (node) => node.id) + const incomingIds = new Set(flattened.map(getId)) for (const [layerId, pass] of this.passMap) { if (incomingIds.has(layerId)) { @@ -229,33 +240,70 @@ export class PipelineManager { this.dirty = true } - const orderedPasses: LayerPassNode[] = [] - - for (const layer of layers) { - const signature = createLayerSignature(layer) - let pass = this.passMap.get(layer.id) + for (const node of flattened) { + const layerId = getId(node) + const group = isCompositionGroup(node) + const signature = group + ? JSON.stringify([node.visible, node.opacity, node.blendMode]) + : createLayerSignature(node) + let pass = this.passMap.get(layerId) + + if (pass && pass instanceof GroupPass !== group) { + pass.dispose() + this.passMap.delete(layerId) + this.layerSignatures.delete(layerId) + this.compilingPasses.delete(layerId) + this.compiledVersions.delete(layerId) + pass = undefined + } + const created = !pass if (!pass) { - pass = this.createPass(layer) + pass = group + ? new GroupPass( + layerId, + (child) => this.isActive(child), + (...args) => this.renderPass(...args) + ) + : this.createPass(node) pass.resize(this.width, this.height) pass.updateLogicalSize(this.logicalWidth, this.logicalHeight) - this.passMap.set(layer.id, pass) + this.passMap.set(layerId, pass) this.dirty = true } - if (this.layerSignatures.get(layer.id) !== signature) { + if (this.layerSignatures.get(layerId) !== signature) { const versionBefore = pass.getMaterialVersion() - this.layerSignatures.set(layer.id, signature) - this.applyLayerState(pass, layer) + this.layerSignatures.set(layerId, signature) + if (group) { + pass.enabled = node.visible + pass.updateOpacity(clampUnit(node.opacity)) + pass.updateBlendMode(node.blendMode) + pass.flushColorNode() + } else { + this.applyLayerState(pass, node) + } this.dirty = true - if (pass.getMaterialVersion() !== versionBefore) { + if ((created && group) || pass.getMaterialVersion() !== versionBefore) { this.scheduleCompile(pass) } } + } - orderedPasses.push(pass) + // All passes exist before wiring children, so reparenting preserves media state. + for (const node of flattened) { + if (!isCompositionGroup(node)) continue + const pass = this.passMap.get(node.id) as GroupPass + if ( + pass.setChildren( + node.children.map((child) => this.passMap.get(getId(child))!) + ) + ) { + this.dirty = true + } } + const orderedPasses = layers.map((node) => this.passMap.get(getId(node))!) if ( orderedPasses.length !== this.passes.length || @@ -267,12 +315,7 @@ export class PipelineManager { } render(time: number, delta: number): boolean { - const activePasses = this.passes.filter( - (pass) => - pass.enabled && - (!this.compilingPasses.has(pass.layerId) || - this.compiledVersions.has(pass.layerId)) - ) + const activePasses = this.passes.filter((pass) => this.isActive(pass)) const needsContinuousRender = activePasses.some((pass) => pass.needsContinuousRender() ) @@ -295,7 +338,7 @@ export class PipelineManager { let writeTarget = this.rtB for (const pass of activePasses) { - pass.render(this.renderer, readTarget.texture, writeTarget, time, delta) + this.renderPass(pass, readTarget.texture, writeTarget, time, delta) const previousRead = readTarget readTarget = writeTarget writeTarget = previousRead @@ -313,12 +356,7 @@ export class PipelineManager { delta: number, inputTexture?: THREE.Texture ): THREE.Texture | null { - const activePasses = this.passes.filter( - (pass) => - pass.enabled && - (!this.compilingPasses.has(pass.layerId) || - this.compiledVersions.has(pass.layerId)) - ) + const activePasses = this.passes.filter((pass) => this.isActive(pass)) const needsContinuousRender = activePasses.some((pass) => pass.needsContinuousRender() ) @@ -347,7 +385,7 @@ export class PipelineManager { let writeTarget = this.rtB for (const pass of activePasses) { - pass.render(this.renderer, readTarget.texture, writeTarget, time, delta) + this.renderPass(pass, readTarget.texture, writeTarget, time, delta) const previousRead = readTarget readTarget = writeTarget writeTarget = previousRead @@ -413,6 +451,26 @@ export class PipelineManager { this.compiledVersions.clear() } + private isActive(pass: PassNode): boolean { + return ( + pass.enabled && + (!this.compilingPasses.has(pass.layerId) || + this.compiledVersions.has(pass.layerId)) + ) + } + + private renderPass( + pass: PassNode, + input: THREE.Texture, + output: THREE.WebGLRenderTarget, + time: number, + delta: number, + _timelineTime = time + ): boolean { + pass.render(this.renderer, input, output, time, delta) + return true + } + private scheduleCompile(pass: LayerPassNode): void { const version = pass.getMaterialVersion() if (this.compiledVersions.get(pass.layerId) === version) { @@ -427,11 +485,13 @@ export class PipelineManager { renderer .compileAsync(scene, camera) .then(() => { + if (this.passMap.get(pass.layerId) !== pass) return this.compilingPasses.delete(pass.layerId) this.compiledVersions.set(pass.layerId, pass.getMaterialVersion()) this.dirty = true }) .catch(() => { + if (this.passMap.get(pass.layerId) !== pass) return this.compilingPasses.delete(pass.layerId) }) } @@ -441,6 +501,12 @@ export class PipelineManager { layer: ShaderLabLayerConfig ): void { pass.enabled = layer.visible + pass.updateCompositionRole( + layer.kind === "effect" || + (layer.type === "custom-shader" && layer.params.effectMode === true) + ? "effect" + : "source" + ) pass.updateOpacity(clampUnit(layer.opacity)) pass.updateBlendMode(layer.blendMode) const compositeMode: ShaderLabCompositeMode = diff --git a/packages/shader-lab-react/src/types.ts b/packages/shader-lab-react/src/types.ts index 99c0a9b8..70161fa6 100644 --- a/packages/shader-lab-react/src/types.ts +++ b/packages/shader-lab-react/src/types.ts @@ -5,7 +5,7 @@ export type ShaderLabParameterValue = | [number, number] | [number, number, number] -export type ShaderLabLayerKind = "effect" | "source" +export type ShaderLabLayerKind = "effect" | "source" | "group" export type ShaderLabSourceLayerType = | "custom-shader" @@ -44,6 +44,7 @@ export type ShaderLabEffectLayerType = | "voxel" export type ShaderLabLayerType = + | "group" | ShaderLabEffectLayerType | ShaderLabSourceLayerType @@ -156,6 +157,8 @@ export interface ShaderLabTimelineConfig { } export interface ShaderLabLayerConfig { + /** Parent group ID. Layers are stored in top-first, depth-first order. */ + parentId?: string | null asset?: ShaderLabAssetSource blendMode: ShaderLabBlendMode compositeMode: ShaderLabCompositeMode diff --git a/src/components/editor/editor-shortcuts.tsx b/src/components/editor/editor-shortcuts.tsx index fdbd130f..56f4e5f0 100644 --- a/src/components/editor/editor-shortcuts.tsx +++ b/src/components/editor/editor-shortcuts.tsx @@ -30,6 +30,21 @@ export function EditorShortcuts() { return } + if ( + (event.metaKey || event.ctrlKey) && + !event.altKey && + event.key.toLowerCase() === "g" + ) { + event.preventDefault() + const state = useLayerStore.getState() + if (event.shiftKey) { + for (const id of state.selectedLayerIds) state.ungroupLayer(id) + } else { + state.groupLayers(state.selectedLayerIds) + } + return + } + if ( (event.metaKey || event.ctrlKey) && !event.altKey && diff --git a/src/components/editor/editor-topbar.tsx b/src/components/editor/editor-topbar.tsx index f471ad09..6111d389 100644 --- a/src/components/editor/editor-topbar.tsx +++ b/src/components/editor/editor-topbar.tsx @@ -57,12 +57,12 @@ const PublishDialog = dynamic( ) const AuthMenu = dynamic( - () => - import("@/components/community/auth-menu").then((mod) => mod.AuthMenu), + () => import("@/components/community/auth-menu").then((mod) => mod.AuthMenu), { ssr: false } ) -const loadCommunityModal = () => import("@/components/community/community-modal") +const loadCommunityModal = () => + import("@/components/community/community-modal") /* Shown while the community-modal chunk is downloading so the backdrop * appears on the first frame after click. Matches the modal's own scrim, @@ -215,7 +215,6 @@ export function EditorTopBar({ pendingBaseSnapshotRef.current = committedSnapshotRef.current } - if (interactiveEditDepth > 0) { return } @@ -288,6 +287,14 @@ export function EditorTopBar({ } if (state.layers === previousState.layers) { + // Selection alone is not an undo step, but the next group operation + // should restore the selection the user actually grouped. + if ( + !pendingBaseSnapshotRef.current && + state.selectedLayerIds !== previousState.selectedLayerIds + ) { + syncHistorySnapshotRefs() + } return } @@ -352,55 +359,53 @@ export function EditorTopBar({ } ) - const unsubscribeAudio = useAudioStore.subscribe( - (state, previousState) => { - if (applyingHistoryRef.current || isRestoringAutosave()) { - syncHistorySnapshotRefs() - return - } - - if ( - state.bands === previousState.bands && - state.links === previousState.links && - state.offsetSeconds === previousState.offsetSeconds && - state.source === previousState.source - ) { - return - } + const unsubscribeAudio = useAudioStore.subscribe((state, previousState) => { + if (applyingHistoryRef.current || isRestoringAutosave()) { + syncHistorySnapshotRefs() + return + } - const layerState = useLayerStore.getState() - const timelineState = useTimelineStore.getState() - const previousSnapshot = buildEditorHistorySnapshotFromState( - layerState, - timelineState, - { - bands: previousState.bands, - links: previousState.links, - offsetSeconds: previousState.offsetSeconds, - source: previousState.source, - } - ) - const nextSnapshot = buildEditorHistorySnapshotFromState( - layerState, - timelineState, - { - bands: state.bands, - links: state.links, - offsetSeconds: state.offsetSeconds, - source: state.source, - } - ) + if ( + state.bands === previousState.bands && + state.links === previousState.links && + state.offsetSeconds === previousState.offsetSeconds && + state.source === previousState.source + ) { + return + } - if ( - getHistorySnapshotSignature(previousSnapshot) === - getHistorySnapshotSignature(nextSnapshot) - ) { - return + const layerState = useLayerStore.getState() + const timelineState = useTimelineStore.getState() + const previousSnapshot = buildEditorHistorySnapshotFromState( + layerState, + timelineState, + { + bands: previousState.bands, + links: previousState.links, + offsetSeconds: previousState.offsetSeconds, + source: previousState.source, + } + ) + const nextSnapshot = buildEditorHistorySnapshotFromState( + layerState, + timelineState, + { + bands: state.bands, + links: state.links, + offsetSeconds: state.offsetSeconds, + source: state.source, } + ) - scheduleHistoryCommit(nextSnapshot) + if ( + getHistorySnapshotSignature(previousSnapshot) === + getHistorySnapshotSignature(nextSnapshot) + ) { + return } - ) + + scheduleHistoryCommit(nextSnapshot) + }) return () => { unregisterShortcuts() @@ -549,7 +554,6 @@ export function EditorTopBar({ ) : null} -
diff --git a/src/components/editor/layer-group-location.tsx b/src/components/editor/layer-group-location.tsx new file mode 100644 index 00000000..25b1e8e5 --- /dev/null +++ b/src/components/editor/layer-group-location.tsx @@ -0,0 +1,52 @@ +"use client" + +import { Select } from "@/components/ui/select" +import { Typography } from "@/components/ui/typography" +import { moveLayerToGroup } from "@/lib/editor/layer-groups" +import { useLayerStore } from "@/store/layer-store" + +export function LayerGroupLocation({ layerId }: { layerId: string }) { + const layers = useLayerStore((state) => state.layers) + const moveLayer = useLayerStore((state) => state.moveLayer) + const layer = layers.find((entry) => entry.id === layerId) + if (!(layer && layers.some((entry) => entry.kind === "group"))) return null + const groups = layers.filter( + (entry) => entry.kind === "group" && entry.id !== layerId + ) + const paths = new Map() + for (const group of groups) + paths.set( + group.id, + `${group.parentId ? `${paths.get(group.parentId) ?? ""} / ` : ""}${group.name}` + ) + return ( +
+ + Group + + setName(event.target.value)} + onKeyDown={(event) => { + event.stopPropagation() + if (event.key === "Enter") { + event.preventDefault() + finishRename() + } + if (event.key === "Escape") { + cancelRename.current = true + setRenaming(false) + } + }} + ref={focusNameInput} + value={name} + /> + ) : ( +
- + + )}
- onLayerAction(layer.id, value as LayerAction)} - options={LAYER_ACTION_OPTIONS} - placeholder={} - popupClassName="min-w-[152px]" - triggerAriaLabel={`Layer actions for ${layer.name}`} - triggerVariant="icon" - uiSound="none" - valueClassName="inline-flex items-center justify-center leading-none text-[var(--ds-color-text-tertiary)] [&_svg]:h-[14px] [&_svg]:w-[14px]" - /> - - {hasMissingAsset ? ( - { - event.stopPropagation() - onRelinkPick(layer) - }} - uiSound="none" - variant="ghost" - > - - - ) : ( - { - event.stopPropagation() - onSetLayerVisibility(layer.id, !layer.visible) - }} - tooltip="Toggle visibility" - uiSound={layer.visible ? "action.visibilityOff" : "action.visibilityOn"} - variant="ghost" - > - {layer.visible ? ( - - ) : ( - - )} - - )} - - { - event.stopPropagation() - onLayerAction(layer.id, "delete") - }} - tooltip="Delete layer" - uiSound="none" - variant="ghost" - > - - + {content} ) }) @@ -425,12 +390,25 @@ export function LayerSidebar() { const [freezeDesktopLayerList, setFreezeDesktopLayerList] = useState(true) const layers = useLayerStore((state) => state.layers) - const hoveredLayerId = useLayerStore((state) => state.hoveredLayerId) const selectedLayerIds = useLayerStore((state) => state.selectedLayerIds) - const selectedLayerId = useLayerStore((state) => state.selectedLayerId) const addLayer = useLayerStore((state) => state.addLayer) + const groupLayers = useLayerStore((state) => state.groupLayers) + const ungroupLayer = useLayerStore((state) => state.ungroupLayer) + const reorderSiblings = useLayerStore((state) => state.reorderSiblings) + const [groupError, setGroupError] = useState(null) + const canGroup = + !selectedLayerIds.length || + groupingSelection(layers, selectedLayerIds).length > 0 + function handleGroup() { + const id = groupLayers(selectedLayerIds) + setGroupError( + id + ? null + : "Groups support at most eight levels. Select layers within the same group." + ) + if (id) playUISound("action.addLayer") + } const removeLayers = useLayerStore((state) => state.removeLayers) - const replaceState = useLayerStore((state) => state.replaceState) const resetLayerParams = useLayerStore((state) => state.resetLayerParams) const selectLayerWithModifiers = useLayerStore( (state) => state.selectLayerWithModifiers @@ -536,7 +514,24 @@ export function LayerSidebar() { ? selectedLayerIds : [layerId] - if (action === "delete") { + if (action === "ungroup") { + ungroupLayer(layerId) + } else if (action === "up" || action === "down") { + const layer = layers.find((entry) => entry.id === layerId) + if (!layer || layer.locked) return + const siblings = layers + .filter( + (entry) => (entry.parentId ?? null) === (layer.parentId ?? null) + ) + .map((entry) => entry.id) + const from = siblings.indexOf(layerId) + const to = from + (action === "up" ? -1 : 1) + if (to >= 0 && to < siblings.length) { + siblings.splice(from, 1) + siblings.splice(to, 0, layerId) + reorderSiblings(layer.parentId ?? null, siblings) + } + } else if (action === "delete") { removeLayers(targetLayerIds) playUISound("action.deleteLayer") } else if (action === "duplicate") { @@ -638,10 +633,6 @@ export function LayerSidebar() { } } - function handleReorder(nextLayers: EditorLayer[]) { - replaceState(nextLayers, selectedLayerId, hoveredLayerId, selectedLayerIds) - } - function handleSelectLayer( layerId: string, event: ReactMouseEvent @@ -660,6 +651,72 @@ export function LayerSidebar() { setLayersVisibility(targetLayerIds, visible) } + function renderLayerTree( + parentId: string | null, + frozen: boolean + ): ReactNode { + const siblings = layers.filter( + (layer) => (layer.parentId ?? null) === parentId + ) + return ( + + reorderSiblings( + parentId, + next.map((layer) => layer.id) + ) + } + values={siblings} + > + {siblings.map((layer) => { + const asset = layer.assetId + ? (assetsById.get(layer.assetId) ?? null) + : null + return ( + + {layer.kind === "group" && layer.expanded + ? renderLayerTree(layer.id, frozen) + : null} + + ) + })} + + ) + } + + const groupButton = ( + + + + ) + return ( <> + {groupButton} - - {layers.map((layer) => { - const asset = layer.assetId - ? (assetsById.get(layer.assetId) ?? null) - : null - const hasMissingAsset = Boolean(layer.assetId && !asset) - const isSelected = selectedLayerIds.includes(layer.id) - - return ( - - ) - })} - + {renderLayerTree(null, false)} + {groupError && ( + + {groupError} + + )} @@ -807,6 +840,7 @@ export function LayerSidebar() { > + {groupButton} - - {layers.map((layer) => { - const asset = layer.assetId - ? (assetsById.get(layer.assetId) ?? null) - : null - const hasMissingAsset = Boolean(layer.assetId && !asset) - const isSelected = selectedLayerIds.includes(layer.id) - - return ( - - ) - })} - + {renderLayerTree(null, shouldFreezeDesktopLayerList)} + {groupError && ( + + {groupError} + + )} )} diff --git a/src/components/editor/properties-sidebar-content.tsx b/src/components/editor/properties-sidebar-content.tsx index f7f6d560..07bbd06f 100644 --- a/src/components/editor/properties-sidebar-content.tsx +++ b/src/components/editor/properties-sidebar-content.tsx @@ -1,5 +1,7 @@ "use client" +import { LayerGroupLocation } from "@/components/editor/layer-group-location" + import { TextAlignRightIcon } from "@radix-ui/react-icons" import { AnimatePresence, motion } from "motion/react" import { useCallback, useEffect, useMemo, useRef, useState } from "react" @@ -549,6 +551,7 @@ export function SelectedLayerPropertiesContent({ valueSuffix="%" /> +
Blend @@ -566,24 +569,7 @@ export function SelectedLayerPropertiesContent({ />
-
- - Mode - - { if (value) { - setLayerMaskConfig(layerId, { - source: value as MaskSource, - }) + setLayerCompositeMode( + layerId, + value as LayerCompositeMode + ) } }} - options={maskSourceOptions} + options={compositeModeOptions} triggerClassName="w-[132px]" - value={maskConfig.source} + value={compositeMode} />
-
- - Mask Mode - - { + if (value) { + setLayerMaskConfig(layerId, { + source: value as MaskSource, + }) + } + }} + options={maskSourceOptions} + triggerClassName="w-[132px]" + value={maskConfig.source} + /> +
-
- - Invert - - - setLayerMaskConfig(layerId, { invert: nextValue }) - } - /> -
+
+ + Mask Mode + +