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/displaced-rings.md b/.changeset/displaced-rings.md new file mode 100644 index 00000000..e71cfddf --- /dev/null +++ b/.changeset/displaced-rings.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Add Displaced Rings with rings/half-discs, progressive transforms, seeded offsets, and Distort/Cutout output. Support transformed coverage in the editor and runtime so grouped photographic cutouts reveal external layers, with opacity interpolating color and alpha together. 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/group-drag-drop.md b/.changeset/group-drag-drop.md new file mode 100644 index 00000000..36de81c8 --- /dev/null +++ b/.changeset/group-drag-drop.md @@ -0,0 +1,4 @@ +--- +--- + +Fix editor layer dragging so dropping into or out of a group updates membership and order together. Add destination indicators, subtree movement, cancellation, and edge scrolling. This editor-only change does not require a package release. 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/.changeset/transparent-text-defaults.md b/.changeset/transparent-text-defaults.md new file mode 100644 index 00000000..5232c2c6 --- /dev/null +++ b/.changeset/transparent-text-defaults.md @@ -0,0 +1,4 @@ +--- +--- + +New editor text uses normal composition over a transparent background. Clarify the background opacity control and preserve the solid fallback for saved text that omits the setting. This editor-only change does not require a package release. 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..f8dd2ef0 100644 --- a/V3-ROADMAP.md +++ b/V3-ROADMAP.md @@ -6,17 +6,25 @@ 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. El usuario confirmó la prueba de aislamiento de foto + halftone, opacidad y plegado. La corrección posterior de arrastre permite entrar/salir de grupos con indicadores de destino y actualiza orden y pertenencia en una sola operación; la [lista manual ampliada](tests/composition/GROUPS-MANUAL-QA.md) sigue disponible para la validación global. + +**Prioridades revisadas con el usuario (17 de septiembre de 2026):** texto transparente por defecto (2.1) implementado en #156 y confirmado por el usuario. Siguiente entrega: prototipo de los anillos desplazados ya previstos en 3.2–3.3; no es una familia adicional. Posponer las máscaras básicas de círculo/rectángulo y su interfaz genérica: no bloquean texto ni la exploración de anillos; revisar cuándo retomarlas al cerrar el alcance de V3. Desarrollar modos de máscara adaptados al lenguaje visual de los efectos seleccionados. Registrar con prioridad baja una revisión de calidad de Blob Tracking y sus posibilidades como máscara (6.3). ## Fase 1 — Resolver la base de composición y proteger proyectos existentes -**Prioridad máxima:** transparencia, grupos y máscaras sostienen la composición. La preparación inicial debe ser breve y servir a la ejecución. +**Prioridad máxima:** transparencia y grupos sostienen la composición. La cobertura alfa necesaria para los modos creativos de máscara se incorpora con los efectos que la usan; la interfaz de máscaras básicas queda aplazada. La preparación inicial debe ser breve y servir a la ejecución. ### 1.1 Preparar pruebas y referencias mínimas @@ -30,15 +38,17 @@ Permitir regiones vacías junto a contenido totalmente opaco. Bajar la opacidad ### 1.3 Introducir grupos reales con alcance propio -Los grupos y sus máscaras son **requisitos confirmados**, pedidos explícitamente por un colega diseñador. Se propone composición aislada por defecto: procesar los contenidos pertinentes del grupo y luego integrarlo al conjunto. Sus efectos no deben afectar capas externas. Agregar carpetas sobre una cadena global de filtros no resuelve el requisito. +Los grupos con alcance propio son **requisitos confirmados**, pedidos explícitamente por un colega diseñador. Las máscaras de grupo también se solicitaron, pero el usuario decidió posponer la interfaz de máscaras básicas y priorizar modos creativos ligados a efectos (1.4). Se propone composición aislada por defecto: procesar los contenidos pertinentes del grupo y luego integrarlo al conjunto. Sus efectos no deben afectar capas externas. Agregar carpetas sobre una cadena global de filtros no resuelve el requisito. Incluir organización, reordenamiento, plegado, visibilidad, opacidad, deshacer y guardado. Definir la profundidad de anidamiento durante la implementación, sin prometer reproducir todo Photoshop. -### 1.4 Resolver máscaras de capa y grupo +### 1.4 Orientar las máscaras hacia modos creativos por efecto + +**Decisión de producto:** posponer las máscaras básicas de círculo/rectángulo adjuntas a capas o grupos. Priorizar modos adaptados a cada efecto: por ejemplo, anillos o semidiscos desplazados que revelan fotografía y dejan huecos, o regiones detectadas por Blob Tracking que sirven de máscara. Los anillos se exploran en 3.2–3.3; Blob Tracking tiene prioridad baja en 6.3. No asumir que todas las capas necesitan el mismo interruptor genérico de máscara. -Darles asignación clara, inversión, desactivación y bordes consistentes. La máscara de grupo recorta el resultado combinado y revela capas inferiores, sin rellenar de negro ni producir halos negros en bordes suaves. +Al incorporar cada modo, definir qué contenido recorta, cómo se asigna y cómo se invierte/desactiva. La cobertura debe revelar capas inferiores, sin rellenos negros ni halos oscuros en bordes suaves. Mantener la composición aislada del grupo y la apariencia de los modos de máscara guardados. La transformación de la imagen y el recorte de su cobertura son comportamientos distintos que deben quedar claros en los controles. -Usar esta pila de prueba, de arriba hacia abajo: +Conservar esta pila como referencia de alcance para cuando se retomen las máscaras de grupo; no es un requisito del siguiente PR: ```text Texto — fuera del grupo @@ -48,13 +58,13 @@ Grupo «Retrato» — máscara circular Fondo — fuera del grupo ``` -Halftone modifica solo la fotografía; la máscara circular recorta el grupo; texto y fondo quedan fuera de su alcance. Las máscaras de recorte que usan la silueta de otra capa son una extensión sugerida, con alcance inicial pendiente. +Halftone modifica solo la fotografía; la máscara circular recorta el grupo; texto y fondo quedan fuera de su alcance. La asignación arbitraria de la silueta de otra capa sigue como extensión sugerida, con alcance pendiente; no debe confundirse con un modo de máscara propio de un efecto. ### 1.5 Anticipar las decisiones de viabilidad 3D Probar brevemente formatos, materiales, animaciones, conservación de recursos y geometría SVG. Registrar decisiones abiertas para las fases 4 y 5 sin demorar la composición. Una entrada de catálogo o tipo de capa no demuestra soporte completo de modelos. -**Cierre:** huecos transparentes y contenido opaco conviven; máscaras y efectos mantienen su alcance al reordenar, deshacer, guardar, reabrir y exportar. Las escenas anteriores conservan su apariencia y los bordes suaves quedan limpios. +**Cierre de la base:** huecos transparentes y contenido opaco conviven; grupos y efectos mantienen su alcance al reordenar, deshacer, guardar, reabrir y exportar. Las escenas anteriores conservan su apariencia y los bordes suaves quedan limpios. Cada modo creativo de máscara incorporado debe superar esas mismas pruebas en su fase; la interfaz básica aplazada no bloquea el cierre de esta base. ## Fase 2 — Facilitar las piezas tipográficas y pulir las capas actuales @@ -64,7 +74,7 @@ Probar brevemente formatos, materiales, animaciones, conservación de recursos y El usuario reporta texto blanco sobre negro y la necesidad de recurrir a máscaras, Screen o cambiar manualmente el fondo. El requisito es insertar texto visible, con contenido opaco y color editable sobre fondo transparente, sin esos rodeos. Mantener el fondo sólido como una opción deliberada y conservar el uso de texto como máscara cuando se elija expresamente. -La lectura de este checkout confirma que `layers.ts` crea texto en modo máscara y `layer-registry.ts` define fondo negro con alfa 1. `text-pass.ts` ya distingue el alfa del fondo y dibuja el texto opaco; admite color editable y posición mediante anclaje y desplazamiento. Esto orienta la revisión de valores iniciales y composición; no constituye una reproducción visual del problema. +La inspección inicial confirmó que `layers.ts` creaba texto en modo máscara y `layer-registry.ts` definía fondo negro con alfa 1. `text-pass.ts` ya distingue el alfa del fondo y dibuja el texto opaco; admite color editable y posición mediante anclaje y desplazamiento. La corrección debe cambiar los valores de capas nuevas y conservar el fondo sólido de archivos anteriores que omitan ese parámetro. ### 2.2 Explorar selección y arrastre directo en el lienzo @@ -102,7 +112,7 @@ La base principal son las catorce imágenes elegidas, más Lovedance —forma ro | Familia candidata | Resultado y referencias | | --- | --- | -| Anillos desplazados y rotados | Bandas circulares transformables; afiche fotográfico azul y negro con círculos concéntricos desalineados. | +| Anillos desplazados y rotados | Bandas circulares transformables; afiche fotográfico azul y negro con círculos concéntricos desalineados (`16-anillos-desfasados-azules.png`). Incluir en la exploración semidiscos de diferentes tamaños y desplazamientos, con huecos transparentes como modo propio del efecto. | | Recortes por celdas o bloques | Revelar bloques con detalle fotográfico interior y contornos opcionales. El puente presenta siluetas escalonadas, no grandes píxeles planos; el afiche blanco combina regiones de color, bordes celulares y puntos finos. | | Revelado fotográfico alterado | Inversiones tonales parciales, expansión de luces y sombras, neblina y erosión; James Blake verde, retrato azul difuso con anotaciones y retrato gris de apariencia solarizada. | @@ -114,6 +124,12 @@ Exigir el caso de **48 anillos**, aunque la referencia muestre seis u ocho. Expl Permitir destruir completamente el reconocimiento de la imagen. Preparar combinaciones editables, como anillos + dos tintas + erosión, usando componentes disponibles y ampliándolas al incorporar familias posteriores. +Separar la transformación de la fotografía dentro de cada banda del modo que recorta su cobertura. Probar anillos completos y semidiscos, con desplazamiento y rotación, y definir controles útiles para revelar/ocultar regiones. Los huecos deben mostrar el contenido inferior y los efectos deben respetar los límites del grupo. Este trabajo amplía la exploración de anillos existente; no agrega otra familia ni exige implementar primero máscaras geométricas genéricas. + +**Primer prototipo implementado y validado visualmente por el usuario (valores iniciales a cargo del usuario):** Displaced Rings admite 1–128 bandas (48 comprobadas), anillos/semidiscos, centro, radio, distribución, desplazamiento alternado/progresivo/aleatorio con semilla, rotación global y por banda, escala progresiva, separación y bordes suaves. Output distingue Distort y Cutout; dentro de un grupo, Cutout revela las capas externas por los huecos. Editor y runtime comparten el comportamiento, con pruebas de píxeles, hidratación real, deshacer, guardado/reapertura y PNG. El catálogo incorpora una vista previa fotográfica y muestra el efecto primero. Ver [alcance y prueba manual](tests/composition/DISPLACED-RINGS-MANUAL-QA.md). + +Esto no cierra la fase 3: quedan la curaduría visual con las referencias completas, combinaciones con otros efectos, rendimiento con fotografías/video a resoluciones representativas y la selección de las otras familias. El prototipo usa transformaciones progresivas; no incluye controles individuales por anillo ni inversión de cobertura. + ### 3.4 Reorganizar el catálogo junto con cada incorporación Dar protagonismo a los nuevos efectos y menor prominencia a los actuales, manteniéndolos accesibles. «Legacy» es una denominación informal, no una deprecación. El nombre público está abierto; **Essentials** fue una sugerencia no confirmada. Acompañar cada alta posterior con esta organización. @@ -170,7 +186,13 @@ Derivar geometría de los contornos y ofrecer profundidad de extrusión, bisel, Elegir según resultados visuales y viabilidad, aplicar los criterios de controles y valores iniciales, actualizar el catálogo en cada incorporación y ampliar las combinaciones editables. -**Cierre:** las familias seleccionadas pasan sus pruebas visuales, de libertad y composición; las candidatas pospuestas quedan identificadas sin presentarlas como entregas prometidas. +### 6.3 Revisar Blob Tracking y sus modos de máscara — prioridad baja + +Pedido del usuario: dedicar una revisión de calidad a Blob Tracking porque tiene potencial visual desaprovechado. Comparar sus resultados actuales sobre fotografía y video; revisar detección, estabilidad temporal, contornos, valores iniciales y claridad de controles antes de elegir cambios. No asumir que necesita una reescritura completa. + +Explorar modos de máscara adaptados a sus regiones detectadas y al movimiento, distinguiendo visualización de la máscara, aplicación de un efecto dentro de las regiones y recorte real de la fotografía. Validar inversión/desactivación, bordes, cobertura alfa, grupos, guardado, reapertura y exportación. Conservar los resultados de escenas anteriores. El alcance concreto se decide tras esa revisión; esta tarea no bloquea texto ni la exploración inicial de anillos. + +**Cierre:** las familias seleccionadas pasan sus pruebas visuales, de libertad y composición; las candidatas pospuestas quedan identificadas sin presentarlas como entregas prometidas. Registrar el resultado de la revisión de Blob Tracking y qué mejoras o modos se integran o se aplazan. ## Fase 7 — Verificar el conjunto y cerrar V3 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-tsl.d.ts b/packages/shader-lab-react/src/ambient/three-tsl.d.ts index de8f35c0..728e37c9 100644 --- a/packages/shader-lab-react/src/ambient/three-tsl.d.ts +++ b/packages/shader-lab-react/src/ambient/three-tsl.d.ts @@ -1,7 +1,7 @@ declare module "three/tsl" { export interface LoopConfig { condition?: string - end: number + end: number | TSLNode start: number type: "float" | "int" } @@ -113,6 +113,8 @@ declare module "three/tsl" { ): TSLNode export function exp(value: unknown): TSLNode export function fract(value: unknown): TSLNode + export function If(condition: TSLNode, callback: () => unknown): TSLNode + export function int(value?: unknown): TSLNode export function float(value?: unknown): TSLNode export function floor(value: unknown): TSLNode export function mat2( 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..50e4ccc8 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" | "transform" + 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,50 @@ 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))) + if (role === "transform") { + // Spatial transformations replace coverage as well as color. Interpolate + // premultiplied pixels so opacity=0 restores input and cutout holes stay clear. + const outputAlpha = mix(baseAlpha, blendAlpha, normalizedOpacity) + const transformedRgb = mix(blendRgb, composited, baseAlpha) + const premultiplied = mix( + baseRgb.mul(baseAlpha), + transformedRgb.mul(blendAlpha), + normalizedOpacity + ) + return vec4( + premultiplied.div( + select(outputAlpha.greaterThan(0), outputAlpha, float(1)) + ), + outputAlpha + ) + } + 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/displaced-rings-pass.ts b/packages/shader-lab-react/src/renderer/displaced-rings-pass.ts new file mode 100644 index 00000000..497668a4 --- /dev/null +++ b/packages/shader-lab-react/src/renderer/displaced-rings-pass.ts @@ -0,0 +1,276 @@ +import * as THREE from "three/webgpu" +import { + cos, + float, + Fn, + fract, + If, + int, + length, + Loop, + max, + min, + mix, + pow, + select, + sin, + smoothstep, + step, + texture, + type TSLNode, + uniform, + uv, + vec2, + vec3, + vec4, +} from "three/tsl" +import { PassNode } from "./pass-node" +import type { LayerParameterValues } from "../types/editor" + +type Node = TSLNode + +function number( + value: unknown, + fallback: number, + low: number, + high: number +): number { + return typeof value === "number" && Number.isFinite(value) + ? Math.max(low, Math.min(high, value)) + : fallback +} + +/** Inverse-map each photographic band; overlap paints outer bands before inner ones. */ +export class DisplacedRingsPass extends PassNode { + private readonly count = uniform(8) + private readonly radius = uniform(0.9) + private readonly distribution = uniform(1) + private readonly center = uniform(new THREE.Vector2(0.5, 0.5)) + private readonly offset = uniform(new THREE.Vector2(0.045, 0)) + private readonly rotation = uniform(0) + private readonly rotationStep = uniform((12 * Math.PI) / 180) + private readonly scaleStep = uniform(0) + private readonly gap = uniform(0.04) + private readonly softness = uniform(0) + private readonly halfDiscs = uniform(0) + private readonly cutout = uniform(0) + private readonly pattern = uniform(0) + private readonly seed = uniform(1) + private readonly aspect = uniform(new THREE.Vector2(1, 1)) + private readonly resolution = uniform(new THREE.Vector2(1, 1)) + private readonly source: Node + private readonly placeholder: THREE.Texture + + constructor(id: string) { + super(id) + this.placeholder = new THREE.Texture() + this.placeholder.type = THREE.HalfFloatType + this.placeholder.minFilter = THREE.NearestFilter + this.placeholder.magFilter = THREE.NearestFilter + this.placeholder.generateMipmaps = false + this.source = texture(this.placeholder) + this.rebuildEffectNode() + } + + override render( + renderer: THREE.WebGPURenderer, + input: THREE.Texture, + output: THREE.WebGLRenderTarget, + time: number, + delta: number + ): void { + this.source.value = input + super.render(renderer, input, output, time, delta) + } + + override resize(width: number, height: number): void { + ;(this.resolution.value as THREE.Vector2).set( + Math.max(1, width), + Math.max(1, height) + ) + } + + override updateLogicalSize(width: number, height: number): void { + const shorter = Math.max(1, Math.min(width, height)) + ;(this.aspect.value as THREE.Vector2).set( + Math.max(1, width) / shorter, + Math.max(1, height) / shorter + ) + } + + override updateParams(params: LayerParameterValues): void { + this.count.value = Math.round(number(params.count, 8, 1, 128)) + this.radius.value = number(params.radius, 0.9, 0.01, 2) + this.distribution.value = number(params.distribution, 1, 0.25, 3) + const center = Array.isArray(params.center) ? params.center : [0, 0] + ;(this.center.value as THREE.Vector2).set( + 0.5 + number(center[0], 0, -1, 1), + 0.5 - number(center[1], 0, -1, 1) + ) + const offset = Array.isArray(params.offset) ? params.offset : [0.045, 0] + ;(this.offset.value as THREE.Vector2).set( + number(offset[0], 0.045, -1, 1), + -number(offset[1], 0, -1, 1) + ) + this.rotation.value = + (number(params.rotation, 0, -180, 180) * Math.PI) / 180 + this.rotationStep.value = + (number(params.rotationStep, 12, -180, 180) * Math.PI) / 180 + this.scaleStep.value = number(params.scaleStep, 0, -0.9, 3) + this.gap.value = number(params.gap, 0.04, 0, 1) + this.softness.value = number(params.softness, 0, 0, 0.1) + this.halfDiscs.value = params.shape === "half-discs" ? 1 : 0 + this.cutout.value = params.output === "cutout" ? 1 : 0 + this.pattern.value = 0 + if (params.pattern === "progressive") this.pattern.value = 1 + if (params.pattern === "random") this.pattern.value = 2 + this.seed.value = number(params.seed, 1, 0, 1000) + } + + protected override buildEffectNode(): Node { + if (!this.source) return this.inputNode + return Fn(() => { + const screen = vec2(uv().x, float(1).sub(uv().y)) + const point = screen.sub(this.center).mul(this.aspect) + const original = this.inputNode + // Work in premultiplied color only while accumulating soft coverage. + const rgb = select( + this.cutout.greaterThan(0.5), + vec3(0), + original.rgb.mul(original.a) + ).toVar() + const alpha = select( + this.cutout.greaterThan(0.5), + float(0), + original.a + ).toVar() + const edge = max( + max( + this.aspect.x.div(this.resolution.x), + this.aspect.y.div(this.resolution.y) + ).mul(0.75), + this.softness + ) + // Shared boundaries must not apply antialias coverage twice: touching + // concentric bands partition the image without translucent seams. + const sharedEdges = this.gap + .equal(0) + .and(this.softness.equal(0)) + .and(length(this.offset).equal(0)) + .and(this.halfDiscs.equal(0)) + Loop({ start: 0, end: int(this.count), type: "int" }, ({ i }) => { + const index = this.count.sub(1).sub(float(i)) + const progress = index.div(max(this.count.sub(1), 1)) + const alternating = select( + index.mod(2).lessThan(1), + float(-1), + float(1) + ) + const random = fract( + sin(index.mul(127.1).add(this.seed.mul(311.7))).mul(43758.5453) + ) + .mul(2) + .sub(1) + const displacement = select( + this.pattern.greaterThan(1.5), + random, + select( + this.pattern.greaterThan(0.5), + progress, + alternating.mul(progress.mul(0.65).add(0.35)) + ) + ) + const moved = point.sub(this.offset.mul(displacement)) + const angle = this.rotation.add(this.rotationStep.mul(index)) + const c = cos(angle) + const s = sin(angle) + const local = vec2( + moved.x.mul(c).add(moved.y.mul(s)), + moved.y.mul(c).sub(moved.x.mul(s)) + ) + const distance = length(local) + const inner = pow(index.div(this.count), this.distribution).mul( + this.radius + ) + const outer = pow(index.add(1).div(this.count), this.distribution).mul( + this.radius + ) + const inset = outer.sub(inner).mul(this.gap).mul(0.5) + const softOuter = float(1).sub( + smoothstep( + outer.sub(inset).sub(edge), + outer.sub(inset).add(edge), + distance + ) + ) + const insideOuter = select( + sharedEdges.and(index.lessThan(this.count.sub(1))), + float(1).sub(step(outer, distance)), + softOuter + ) + const softInner = select( + index.equal(0), + float(1), + smoothstep( + inner.add(inset).sub(edge), + inner.add(inset).add(edge), + distance + ) + ) + const outsideInner = select( + sharedEdges, + step(inner, distance), + softInner + ) + const half = select( + this.halfDiscs.greaterThan(0.5), + smoothstep(edge.negate(), edge, local.y.negate()), + float(1) + ) + const coverage = insideOuter + .mul(outsideInner) + .mul(half) + .mul(select(this.gap.greaterThanEqual(1), float(0), float(1))) + If(coverage.greaterThan(0), () => { + const scale = max(float(1).add(this.scaleStep.mul(progress)), 0.1) + const sampleUv = local.div(scale).div(this.aspect).add(this.center) + const bounds = step(0, sampleUv.x) + .mul(step(sampleUv.x, 1)) + .mul(step(0, sampleUv.y)) + .mul(step(sampleUv.y, 1)) + // Explicit LOD makes texture sampling valid in per-pixel loop branches. + const sample = this.source.sample(sampleUv).level(0) + const sampleAlpha = sample.a.mul(bounds) + const amount = sampleAlpha.mul(coverage) + const nextRgb = sample.rgb + .mul(amount) + .add(rgb.mul(float(1).sub(amount))) + const nextAlpha = amount.add(alpha.mul(float(1).sub(amount))) + rgb.assign( + select( + this.cutout.greaterThan(0.5), + nextRgb, + mix(rgb, sample.rgb.mul(sampleAlpha), coverage) + ) + ) + alpha.assign( + select( + this.cutout.greaterThan(0.5), + nextAlpha, + mix(alpha, sampleAlpha, coverage) + ) + ) + }) + }) + return vec4( + rgb.div(select(alpha.greaterThan(0), alpha, float(1))), + min(alpha, 1) + ) + })() + } + + override dispose(): void { + this.placeholder.dispose() + super.dispose() + } +} 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..39c83f68 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" @@ -29,6 +36,7 @@ import { PixelTrailPass } from "./pixel-trail-pass" import { PixelationPass } from "./pixelation-pass" import { PlotterPass } from "./plotter-pass" import { PosterizePass } from "./posterize-pass" +import { DisplacedRingsPass } from "./displaced-rings-pass" import { SlicePass } from "./slice-pass" import { SmearPass } from "./smear-pass" import { TextPass } from "./text-pass" @@ -204,6 +212,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 +222,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 +241,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 +316,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 +339,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 +357,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 +386,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 +452,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 +486,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 +502,16 @@ export class PipelineManager { layer: ShaderLabLayerConfig ): void { pass.enabled = layer.visible + if (layer.type === "displaced-rings") { + pass.updateCompositionRole("transform") + } else { + 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 = @@ -542,6 +613,8 @@ export class PipelineManager { return new ThresholdPass(layer.id) case "pixel-sorting": return new PixelSortingPass(layer.id) + case "displaced-rings": + return new DisplacedRingsPass(layer.id) case "slice": return new SlicePass(layer.id) case "smear": diff --git a/packages/shader-lab-react/src/types.ts b/packages/shader-lab-react/src/types.ts index 99c0a9b8..c76ab805 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" @@ -19,6 +19,7 @@ export type ShaderLabSourceLayerType = | "video" export type ShaderLabEffectLayerType = + | "displaced-rings" | "ascii" | "blob-tracking" | "bloom" @@ -44,6 +45,7 @@ export type ShaderLabEffectLayerType = | "voxel" export type ShaderLabLayerType = + | "group" | ShaderLabEffectLayerType | ShaderLabSourceLayerType @@ -156,6 +158,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/public/examples/displaced-rings.webp b/public/examples/displaced-rings.webp new file mode 100644 index 00000000..dd9b0e19 Binary files /dev/null and b/public/examples/displaced-rings.webp differ 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 ? ( - - ) : ( - - )} - + {dragPlacement === "before" && ( +
)} - - { - event.stopPropagation() - onLayerAction(layer.id, "delete") - }} - tooltip="Delete layer" - uiSound="none" - variant="ghost" - > - - - + {content} + {dragPlacement === "after" && ( +
+ )} + ) }) export function LayerSidebar() { + const layerDrag = useLayerDrag() const imageInputRef = useRef(null) const relinkInputRef = useRef(null) const relinkTargetRef = useRef<{ @@ -425,12 +393,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 +517,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 +636,6 @@ export function LayerSidebar() { } } - function handleReorder(nextLayers: EditorLayer[]) { - replaceState(nextLayers, selectedLayerId, hoveredLayerId, selectedLayerIds) - } - function handleSelectLayer( layerId: string, event: ReactMouseEvent @@ -660,6 +654,82 @@ export function LayerSidebar() { setLayersVisibility(targetLayerIds, visible) } + function renderLayerTree( + parentId: string | null, + frozen: boolean + ): ReactNode { + const siblings = layers.filter( + (layer) => (layer.parentId ?? null) === parentId + ) + return ( + + {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 dragStatus = ( + + {layerDrag.preview?.message} + + ) + + 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)} + {dragStatus} + {groupError && ( + + {groupError} + + )} @@ -772,7 +819,7 @@ export function LayerSidebar() { {({ dragHandleProps, suppressResize: _suppressResize }) => (
@@ -807,6 +854,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)} + {dragStatus} + {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 + +