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/cell-edge-scatter.md b/.changeset/cell-edge-scatter.md new file mode 100644 index 00000000..9035a4b6 --- /dev/null +++ b/.changeset/cell-edge-scatter.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Add optional, seeded Edge Scatter to Photographic Cells regions and painted reveals. Scatter fragments boundaries without moving source detail or adding texture samples. Missing values and zero preserve existing projects. 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/gradient-map-layer.md b/.changeset/gradient-map-layer.md new file mode 100644 index 00000000..0bd3807f --- /dev/null +++ b/.changeset/gradient-map-layer.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Add a Gradient Map effect layer that recolors by tone with an editable ramp, presets, Amount and Invert. It renders identically in the editor, exports and the runtime package, and can be scoped with groups and masks. The global Color Map is unchanged. 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/layer-masks.md b/.changeset/layer-masks.md new file mode 100644 index 00000000..378f5c83 --- /dev/null +++ b/.changeset/layer-masks.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Add reusable layer masks: linear and radial gradients, ellipse, rectangle and brush on any layer or group. Masks either limit an effect or cut coverage, support invert and feather, and render identically in the editor, PNG/video export and the runtime package. diff --git a/.changeset/photographic-cell-paint.md b/.changeset/photographic-cell-paint.md new file mode 100644 index 00000000..a6117133 --- /dev/null +++ b/.changeset/photographic-cell-paint.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Add persisted Paint selection to Photographic Cells. Painted coverage shares cell geometry and perimeter outlines with automatic regions, stays anchored in composition space, and reuses its GPU texture while the source animates. diff --git a/.changeset/photographic-cell-regions.md b/.changeset/photographic-cell-regions.md new file mode 100644 index 00000000..f29b20b3 --- /dev/null +++ b/.changeset/photographic-cell-regions.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Add coherent Photographic Cells regions with separate region and cell sizes, image-guided or seeded selection, and None/Perimeter/Every Cell outline modes. Preserve saved cell layouts and contour settings while new layers start with connected regions and fine perimeter outlines. diff --git a/.changeset/photographic-cells.md b/.changeset/photographic-cells.md new file mode 100644 index 00000000..a2f86161 --- /dev/null +++ b/.changeset/photographic-cells.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Add Photographic Cells with light/dark/random selection, irregular rectangular cells, full-resolution photographic interiors, alpha cutouts, and optional outlines. Support the effect in runtime configs and grouped compositions. Apply user-curated editor defaults to new Displaced Rings layers while preserving saved settings and renderer fallbacks. diff --git a/.changeset/stable-artboard.md b/.changeset/stable-artboard.md new file mode 100644 index 00000000..2c8fc517 --- /dev/null +++ b/.changeset/stable-artboard.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Add a stable artboard: any composition aspect other than Screen (adaptive) is a fixed document size, centered in the editor and letterboxed in the community player, so framing, masks and text no longer follow the window. Screen mode and older scenes are unchanged. diff --git a/.changeset/threshold-colors.md b/.changeset/threshold-colors.md new file mode 100644 index 00000000..a0602bf5 --- /dev/null +++ b/.changeset/threshold-colors.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Add darkColor and lightColor to Threshold, retaining black/white fallbacks. Recolor the existing threshold field without another render pass; softness, noise, inversion and source coverage retain their behavior. 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/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..721d46bb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,5 @@ +# Shader Lab development instructions + +Before deciding how a shader behaves or looks, open the relevant images listed in [V3-VISUAL-REFERENCES.md](V3-VISUAL-REFERENCES.md). This is an explicit user requirement. Read that guide for reference locations, visual goals, and review criteria; a prose description or passing GPU tests does not replace looking at the images. + +Keep prototype acceptance distinct from completing the visual direction in V3-ROADMAP.md. Preserve the stacked PR workflow; do not merge V3 into main until the plan is complete. diff --git a/V3-ROADMAP.md b/V3-ROADMAP.md index d7a7ac6b..8539d09e 100644 --- a/V3-ROADMAP.md +++ b/V3-ROADMAP.md @@ -6,21 +6,60 @@ 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 dieciséis 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. Anillos desplazados implementados y validados visualmente; el usuario también confirmó la corrección de arrastre entre grupos. Regiones conectadas y contornos de perímetro en Photographic Cells (3.3.1 A+B) confirmados visualmente por el usuario; pintura manual confirmada visualmente por el usuario, con entrada automática a edición al elegir Paint. El usuario reporta alta carga/calor al combinar efectos: evaluar rendimiento durante reproducción y edición de escenas dinámicas en cada entrega. La decisión inicial de posponer máscaras generales queda reemplazada por la aprobación posterior de 1.4: gradiente, elipse, rectángulo y pincel, con prioridad alta y sin herramienta de pluma. Mantener los modos creativos propios de cada efecto. Las anotaciones técnicas y la revisión de Blob Tracking ganan protagonismo en 6.3; Patterns personalizables queda como exploración de prioridad baja en 6.5. + +### Ampliaciones confirmadas tras feedback de diseñadores + +**Aprobadas como dirección del plan; el estado de cada entrega se detalla abajo.** Orden de trabajo acordado: inicio limpio → máscaras reutilizables y Gradient Map local → herramientas de composición con formas y texto → anotaciones técnicas → investigación de profundidad/parallax. Patterns personalizables se registra con prioridad baja. Este orden revisa prioridades entre fases sin eliminar dependencias ni validaciones; no obliga a terminar toda una fase antes de abordar la siguiente entrega útil. + +| Tema | Alcance acordado | Ubicación | +| --- | --- | --- | +| Inicio limpio | Proyecto vacío con ajustes neutros; demo separada y ajustes globales visibles | 1.0 | +| Máscaras generales | Gradiente lineal/radial, elipse/círculo, rectángulo/cuadrado y pincel; sin pluma | 1.4 | +| Gradient Map local | Capa de efecto, combinable con grupos y máscaras; conservar ajuste global | 2.5.2 | +| Formas | Capas editables y presets; recorte y mezclas fotográficas | 2.7 | +| Texto y composición | Edición directa, etiquetas pequeñas, multilínea, alineación y artboard estable; conexión con grilla opcional | 2.2–2.3, 2.8 y fase 8 | +| Anotaciones técnicas | Puntos, cajas, cruces, etiquetas y líneas; dirección artística destacada, vinculada a Blob Tracking | 6.3 | +| Profundidad y parallax | Investigar mapas importados primero; generación automática después | 6.4 | +| Patterns personalizables | Cargar SVG/imágenes, ordenar una secuencia tonal y elegir tratamiento del color; prioridad baja | 6.5 | + +Las nuevas capturas de referencia están identificadas en [V3-VISUAL-REFERENCES.md](V3-VISUAL-REFERENCES.md). La aprobación de estas ideas no implica aceptación visual de las entregas que aún esperan prueba del usuario. ## 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. Resolver primero el inicio limpio y ampliar esa base con máscaras generales simples, sin perder los modos de cobertura propios de cada efecto. La preparación inicial debe ser breve y servir a la ejecución. + +### Referencias visuales obligatorias + +Antes de decidir el comportamiento o la apariencia de cualquier shader, abrir las imágenes pertinentes de [V3-VISUAL-REFERENCES.md](V3-VISUAL-REFERENCES.md). El directorio original y la copia local contienen las dieciséis referencias del usuario. Comparar renders con esas imágenes; las pruebas técnicas no sustituyen la revisión visual ni dan por terminada una familia. + +### 1.0 Ofrecer un inicio realmente limpio — primera corrección + +El usuario reporta que una sesión nueva conserva ajustes globales de la escena demo, alterando su trabajo sin una indicación clara. La inspección confirma que `Start fresh` aplica `getDefaultProjectFile()`, incluidos sus ajustes globales; distinguir ese comportamiento de restaurar un proyecto guardado. + +Ofrecer **New blank project** con escena vacía y ajustes globales neutros, manteniendo la demo como elección separada. Hacer evidente cuándo hay ajustes globales activos y permitir restablecerlos. Definir claramente el recorrido de primera visita, nuevo proyecto y restauración de sesión; no borrar ni neutralizar los ajustes deliberados de archivos existentes. + +**Implementado, pendiente de aceptación manual:** primera visita vacía; menú Project con New blank project / Open demo también en Actions móvil; aviso de gradación global y reset completo reversible que conserva fondo, composición y calidad de preview. Crear/abrir un documento reinicia el historial, incluidas entradas pendientes. Autosave admite proyectos vacíos y los persiste inmediatamente al comenzar uno, evitando restaurar una demo antigua por encima de un vacío nuevo; los proyectos importados/restaurados mantienen su gradación. [Prueba manual](tests/composition/CLEAN-PROJECTS-MANUAL-QA.md). + +**Cierre:** una foto insertada en un proyecto vacío no hereda gradación de la demo. Abrir la demo y restaurar un proyecto conservan sus respectivos ajustes. Verificar también historial, autosave y guardado/reapertura del nuevo proyecto. ### 1.1 Preparar pruebas y referencias mínimas -Reunir las catorce imágenes elegidas y pruebas con fotografías, retratos, objetos, tipografía y video, variando luz y detalle. Guardar escenas existentes y resultados de referencia para comprobar su apariencia desde el primer cambio. +Reunir las dieciséis imágenes elegidas y pruebas con fotografías, retratos, objetos, tipografía y video, variando luz y detalle. Guardar escenas existentes y resultados de referencia para comprobar su apariencia desde el primer cambio. ### 1.2 Comprobar el estado actual y corregir el canal alfa @@ -30,31 +69,46 @@ 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 están confirmadas: tras una postergación inicial, el usuario aprobó retomar máscaras generales simples en 1.4, junto a los modos creativos propios de los efectos. 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 +**Ampliación acordada para más adelante — Pass Through:** añadir un modo opcional de grupo que permita a sus efectos procesar las capas inferiores externas, dentro del contexto de composición de su padre. Mantener **Isolated** como valor predeterminado, también para proyectos guardados que no indiquen un modo. Esta ampliación no bloquea las entregas en curso. + +Ofrecer una elección explícita entre Isolated y Pass Through, con una explicación breve de su alcance. Un grupo con Ink y Displaced Rings sobre una imagen externa debe dejarla intacta en Isolated y procesarla al elegir Pass Through. Las capas superiores y las que estén fuera de un ancestro aislado deben conservar su independencia. Definir el comportamiento de opacidad, mezcla y recorte del grupo antes de implementarlo; no asumir que basta con aplanar sus hijos. + +Validar grupos anidados y cambios de modo, visibilidad, reordenamiento, deshacer/rehacer, guardado/reapertura y paridad entre editor, runtime y exportación. Conservar la apariencia de todos los grupos aislados existentes. -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. +### 1.4 Máscaras generales simples y modos creativos por efecto — prioridad alta -Usar esta pila de prueba, de arriba hacia abajo: +**Decisión actual confirmada:** retomar máscaras generales reutilizables. Reemplaza la postergación anterior. Mantener únicamente cuatro herramientas: **gradiente** (lineal y radial), **elipse** (incluye círculo), **rectángulo** (incluye cuadrado) y **pincel**. **Sin pen tool/pluma ni edición de nodos Bézier.** La selección automática de sujetos o regiones queda para una extensión posterior, no para esta primera entrega. + +Distinguir dos usos mediante controles claros: + +- **Aplicar un efecto dentro de una máscara:** por ejemplo, pintar Halftone sobre parte de una fotografía; fuera de la máscara sigue visible la imagen sin ese efecto. +- **Recortar contenido de una capa o grupo:** fuera de la máscara hay transparencia que revela las capas inferiores. + +Definir asignación y alcance, inversión, desactivación y edición directa de posición/tamaño/dirección según la herramienta. Mantener pocos controles; evaluar suavidad de borde y transiciones del gradiente según el resultado. Reutilizar la interacción y el código útil del pincel de Cells —Reveal/Erase, tamaño, edición explícita y un trazo por acción de historial— sin asumir que ya funciona como máscara general. Conservar las pinturas guardadas y los modos específicos de Cells y Rings; evitar acoplar la nueva máscara a su geometría. ```text Texto — fuera del grupo -Grupo «Retrato» — máscara circular - Halftone +Grupo «Retrato» — máscara de elipse que recorta contenido + Halftone — máscara de pincel que limita el efecto Fotografía 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. +En esta composición, el pincel limita Halftone sin borrar la foto; la elipse recorta el grupo entero. Texto y fondo quedan fuera de ambos alcances. La selección de una capa de formas como máscara se conecta con 2.7; la asignación arbitraria de cualquier silueta de capa requiere una decisión posterior. + +**Cierre:** gradiente, elipse, rectángulo y pincel funcionan con los dos usos definidos, inversión/desactivación, zoom y desplazamiento. Bordes suaves sin halos negros ni pérdida incorrecta de alfa; grupos aislados y máscaras anteriores conservan su apariencia. Probar historial, duplicación, hidratación real, guardado/reapertura y paridad editor/runtime/PNG/video. Las máscaras estáticas se reutilizan mientras cambia la fuente de video; la pintura manual no implica seguimiento automático del sujeto. + +**Implementado, pendiente de aceptación visual del usuario:** cada capa (grupos incluidos) tiene una máscara opcional con forma None / Linear gradient / Radial gradient / Ellipse / Rectangle / Brush, alcance **Limit effect** / **Cut content** (solo efectos; en fuentes y grupos ambos equivalen a recortar la propia capa), Invert, Enabled, centro, tamaño, rotación y Feather (elipse y rectángulo). Manijas directas en el lienzo: centro, eje X (rotación y ancho), eje Y (alto); el gradiente lineal usa inicio/fin. El pincel reutiliza la interacción de Cells (Reveal/Erase, Brush Size, Clear, un trazo por acción de historial, entrada automática al elegir Brush) con el mismo códice compacto, sin acoplarse a la geometría de Cells. Coordenadas centradas en unidades del lado corto, iguales a Paint; la máscara sigue el viewport hasta resolver el artboard estable (2.8). Interpolación premultiplicada sin halos negros; el modo antiguo **Mode → Mask** no cambia. Editor y runtime comparten `renderer/layer-mask.ts`; mover una máscara solo actualiza uniforms. Persistencia .lab, duplicado, historial, export PNG/video, runtime y MCP `update_layer.mask`. Referencias revisadas: 17 y 20. [Prueba manual](tests/composition/LAYER-MASKS-MANUAL-QA.md). Pendiente: máscaras animables por timeline y selección de una capa de formas como máscara (2.7). ### 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. Las máscaras generales acordadas y cada modo creativo incorporado deben superar esas mismas pruebas; la selección automática y una herramienta de pluma no forman parte del alcance inicial. ## Fase 2 — Facilitar las piezas tipográficas y pulir las capas actuales @@ -64,15 +118,17 @@ 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 +### 2.2 Componer texto directamente en el lienzo -Probar el alcance mínimo útil para seleccionar un texto y moverlo arrastrando, sin depender de una grilla. Sincronizar el resultado con los controles existentes de posición, respetando anclaje y zoom. Verificar deshacer el movimiento y recuperar la misma posición al guardar y reabrir. Incorporarlo al alcance inicial si resulta viable como mejora acotada; no prometer un editor completo. +**Dirección aprobada:** seleccionar, arrastrar y redimensionar texto directamente, con controles de alineación y sincronización con posición, anclaje y zoom. Validar deshacer/rehacer y posición guardada. Resolver primero un conjunto útil dentro del editor actual; una sección separada de **Design** sigue como alternativa de producto para evaluar más adelante, no como segunda interfaz comprometida. -### 2.3 Revisar pequeñas mejoras de controles tipográficos +La grilla de la fase 8 se conecta con esta edición y con las formas de 2.7: sirve de referencia para márgenes, columnas y alineaciones. El snapping debe poder desactivarse y conservar colocación libre. La edición directa y la alineación básica deben seguir siendo útiles sin activar una grilla; su carácter opcional no cambia por esta conexión. -Conservar fuente, tamaño, peso, espaciado, color, anclaje y desplazamiento. Como **candidatas a validar**, revisar el límite actual de 32 caracteres, el tamaño mínimo de 48 y la claridad del control de fondo transparente o sólido. Evaluar si ampliar esos límites resuelve usos reales sin degradar la edición. Son oportunidades observadas, no nuevos requisitos ni un compromiso de construir un motor de composición tipográfica completo. +### 2.3 Ampliar controles tipográficos útiles + +Conservar fuente, tamaño, peso, espaciado, color, anclaje y desplazamiento. Incorporar etiquetas más pequeñas y texto multilínea; revisar el límite de 32 caracteres y el mínimo de 48px, que impiden anotaciones finas en los estudios actuales. Definir límites útiles al implementar, sin prometer un motor tipográfico completo. Mantener claro el control de fondo transparente/sólido y comprobar composición, edición y exportación con bloques de texto. ### 2.4 Ajustar halftone como primer efecto @@ -82,27 +138,61 @@ Ajustar parámetros sobre el material de prueba hasta lograr resultados útiles Extender el criterio a los demás efectos. Organizar controles con etiquetas comprensibles, entradas numéricas, deslizadores apropiados y restablecimiento. Aplicarlo también a capas nuevas: valores curados como puntos de partida editables hasta la abstracción extrema. +#### 2.5.1 Colores editables en Threshold + +**Implementado, pendiente de validación visual del usuario:** dos colores editables en la capa **Threshold** existente: **Color oscuro** y **Color claro**, reemplazando la salida fija negro/blanco por una combinación personalizada. Mantener negro y blanco como valores iniciales y como fallback en proyectos anteriores. + +Conservar el funcionamiento del umbral, la inversión y la transparencia; cambiar la paleta solo modifica los colores de salida. Integrar los selectores con deshacer/rehacer, guardado/reapertura y exportación editor/runtime. Evitar un pase de render adicional para esta recoloración, teniendo en cuenta fuentes de video y escenas dinámicas. + +**Cierre:** con los colores iniciales, las escenas existentes se ven iguales; al elegir, por ejemplo, azul y rosa, ambas regiones adoptan esos colores sin desplazar sus límites. La paleta elegida sobrevive al historial, reapertura y exportación. + +Entrega: controles Dark Color / Light Color, fallbacks negro/blanco y mezcla dentro del pase existente, sin render adicional. Referencia 01 revisada y render azul/rosa inspeccionado. Pruebas de GPU editor/runtime, video decodificado, hidratación real, historial, guardado/reapertura y PNG; [prueba enfocada y límites](tests/composition/THRESHOLD-COLORS-MANUAL-QA.md). + +#### 2.5.2 Gradient Map como capa de efecto — prioridad alta + +**Aprobado:** trasladar la capacidad de mapeo tonal global a una capa de efecto reordenable, con colores/puntos del gradiente editables y alcance controlado por grupos y máscaras. Conservar también el ajuste global y la apariencia de proyectos existentes. Distinguir esta recoloración de la capa Gradient, que genera un campo de color. + +Ejemplo de aceptación: Gradient Map recolorea únicamente la foto de su grupo; al enmascararlo con pincel, el resto de la foto conserva sus colores originales y las capas externas no cambian. Probar opacidad, inversión del mapeo si se ofrece, historial, persistencia y exportación con fotografía/video. + +Explorar una paleta de apariencia térmica a partir de la referencia Brockhampton compartida. El falso color tonal puede funcionar sin profundidad; colorear un depth map es otra interpretación. No afirmar que un mapa de profundidad mide temperatura ni que reproduce automáticamente la distribución de color de esa portada. + +**Implementado, pendiente de aceptación visual del usuario:** nueva capa de efecto **Gradient Map** (catálogo Core, tras Threshold) con rampa editable (2–5 paradas, GradientRamp compartido con el ajuste global), presets Thermal / Duotone / Sepia / Neon / Grayscale (los valores iniciales quedan a cargo del usuario), Amount e Invert. Mapea la luminancia Rec.709 con una LUT lineal de 256 muestras compartida con el Color Map global (`renderer/color-map-lut.ts`), preserva la cobertura de entrada y respeta grupos y máscaras; el ajuste global y los proyectos existentes no cambian. Editor/runtime, persistencia, duplicado, historial, export y MCP por parámetros normales. Referencia 18 revisada: paleta de apariencia térmica sin inferir profundidad. [Prueba manual](tests/composition/GRADIENT-MAP-MANUAL-QA.md). Pendiente: paradas animables. + ### 2.6 Preservar las composiciones guardadas Aplicar los nuevos valores únicamente a capas recién creadas y comparar proyectos anteriores con sus resultados de referencia, incluidos textos con fondo sólido, máscaras y modos de mezcla elegidos previamente. -**Cierre:** el texto nuevo se integra sobre fondo transparente sin activar máscaras ni Screen; su contenido permanece opaco y su color es editable. Si se incorpora el arrastre, coincide con controles, deshacer y posición guardada. Los efectos actuales ofrecen valores iniciales útiles, conservan libertad de edición y combinación, y las escenas anteriores —incluidos sus textos— mantienen su apariencia. Resolver estos puntos no depende de la grilla opcional. +### 2.7 Capas de formas y composiciones fotográficas + +**Aprobado por etapas:** formas editables y una pequeña biblioteca de presets, con color, posición, tamaño y rotación. Reutilizar la interacción de composición y las máscaras simples de 1.4. Permitir utilizar formas para enmascarar y para combinar fotografía/color, tomando Lovedance como referencia visual. + +Probar doble exposición con las máscaras y modos de mezcla disponibles antes de añadir un efecto específico. Mantener el alcance simple: no incorporar pluma, edición Bézier ni un Illustrator completo. Dibujar geometría vectorial libre queda fuera de esta entrega; el pincel acordado pinta cobertura de máscara, no crea trazados vectoriales. + +### 2.8 Artboard estable y relación con la grilla + +Definir un área de composición de tamaño estable para conservar encuadre y posiciones de texto, formas y efectos al cambiar el tamaño de la ventana. Los estudios actuales se reencuadran porque el canvas sigue el viewport; separar tamaño del documento, zoom y vista previa. Preservar las escenas anteriores que dependen del comportamiento adaptable y comprobar correspondencia con exportación. + +La grilla opcional de la fase 8 debe utilizar el mismo espacio del documento para alinear texto y formas. Esta base se desarrolla dentro del editor existente antes de decidir si una sección Design separada aporta valor. + +**Implementado, pendiente de aceptación del usuario:** `compositionAspect` distingue **Screen (adaptive)** —comportamiento anterior, conservado por las escenas antiguas y la demo— de los artboards fijos (presets y Custom), cuyo tamaño real es la `composition` guardada y se edita en Scene → Composition → Size. El canvas del editor se ajusta y centra en el viewport con pasteboard; los pases reciben el documento como `logicalSize`, así preview, máscaras, Paint, texto y export coinciden. Cambiar de preset recorta el documento actual al nuevo ratio; las escenas antiguas con preset no-screen pasan de un marco guía a un artboard real (su export adopta ese tamaño). Los proyectos nuevos empiezan en 16:9 1920×1080; los estudios de `public/examples/v3` ahora son artboards Custom 720×960. El visor de la comunidad aplica letterbox a los artboards fijos. Zoom 100% = ajuste al viewport. [Prueba manual](tests/composition/ARTBOARD-MANUAL-QA.md). Pendiente: grilla opcional (fase 8) y render a resolución de zoom. + +**Cierre:** el texto nuevo se integra sobre fondo transparente sin activar máscaras ni Screen; su contenido permanece opaco y su color es editable. La edición directa coincide con controles, deshacer y posición guardada; las formas y el artboard mantienen una composición consistente. Los efectos actuales ofrecen valores iniciales útiles, conservan libertad de edición y combinación, y las escenas anteriores —incluidos sus textos— mantienen su apariencia. Resolver estos puntos no depende de la grilla opcional. ## Fase 3 — Explorar la primera tanda artística e incorporarla al catálogo -**Prioridad propuesta:** anillos, celdas y revelado alterado ofrecen transformaciones diferentes. Esta tanda es una recomendación del asistente, no una selección definitiva del usuario. +**Prioridad revisada:** anillos y celdas ya fueron seleccionados con el usuario. Revelado alterado sigue como familia candidata; su alcance debe confirmarse antes de incorporarla. ### 3.1 Traducir las referencias en criterios visuales Explorar deterioro fotográfico, imágenes sintéticas o escultóricas y geometría, inspirados en afiches experimentales, discos, revistas de música electrónica, Photoshop y diseño de finales de los noventa y los dos mil. Admitir registros claros, oscuros, sobrios y coloridos. -La base principal son las catorce imágenes elegidas, más Lovedance —forma roja sobre fotografía azul, atravesada por luces— y el collage monocromo de foto difusa, curvas técnicas y tipografía precisas. Transformar fotografía con geometría, tono, textura y composición sin limitarse al cyberpunk o una estética retro. Los archivos iniciales fueron exploratorios: solo dos ejemplos interesaron y una página de Paradiso falló. +La base principal son las dieciséis imágenes elegidas, más Lovedance —forma roja sobre fotografía azul, atravesada por luces— y el collage monocromo de foto difusa, curvas técnicas y tipografía precisas. Transformar fotografía con geometría, tono, textura y composición sin limitarse al cyberpunk o una estética retro. Los archivos iniciales fueron exploratorios: solo dos ejemplos interesaron y una página de Paradiso falló. ### 3.2 Probar las tres familias recomendadas | 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 +204,77 @@ 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. + +**Valores iniciales de anillos elegidos por el usuario:** las capas nuevas usan 22 semidiscos, radio 2, rotación por anillo de 45°, desplazamiento y separación 0, con Output en Distort. Mantener los parámetros de escenas existentes y los valores anteriores cuando falten campos en archivos guardados. + +**Primer prototipo de celdas aceptado como base útil por el usuario; corregido el exceso de grosor de contornos al cambiar Gap:** Photographic Cells selecciona bloques rectangulares según zonas claras, oscuras o azar con semilla. Conserva el detalle fotográfico interior y permite ajustar tamaño, proporción, irregularidad de filas, separación, bordes suaves, inversión y contornos. Cutout revela las capas externas al grupo; Keep Image conserva la fotografía y superpone únicamente los contornos seleccionados. Incluye catálogo, editor/runtime, guardado/reapertura y PNG. Ver [alcance y prueba manual](tests/composition/PHOTOGRAPHIC-CELLS-MANUAL-QA.md). No cierra la familia: el usuario señala que todavía no alcanza las referencias. Quedan regiones fotográficas conectadas con siluetas escalonadas y contornos de perímetro (puente, `11-puente-recortes-geometricos.png`), regiones amplias de color con bordes celulares y puntos finos (afiche blanco, `05-recortes-celdas-color.png`), curaduría con las imágenes abiertas y validación temporal/de rendimiento con video. La entrega A+B detallada abajo añade regiones conectadas y contornos sin divisiones internas; el usuario confirmó su utilidad visual con una composición propia de águila, anillos, recortes y trama de puntos. Combinar capas para color/tramas cuando resulte más simple. La selección dirigida mediante Paint ya está implementada y aceptada; el tratamiento editorial completo sigue pendiente. + +#### 3.3.1 Evolucionar Photographic Cells hacia regiones y revelado dirigido + +**Dirección acordada con el usuario; A+B+C implementados y confirmados visualmente por el usuario.** La capa incorpora regiones automáticas, contornos de perímetro y pintura manual como otra fuente de selección, útil aunque el modo automático funcione bien. Mantener pocos controles y reutilizar la geometría, transparencia y composición existentes. + +**Objetivo visual:** abrir y comparar `11-puente-recortes-geometricos.png` y `05-recortes-celdas-color.png` antes de decidir apariencia o valores iniciales. En el puente, conservar fotografía reconocible dentro de una silueta conectada con escalones y un contorno fino de color. En el afiche blanco, formar regiones amplias de color con bordes celulares, pequeños fragmentos alrededor y una trama mucho más fina que las celdas. + +**Estado de A+B:** Regions utiliza un campo espacial coherente con semilla o tonos interpolados de la imagen; Region Size controla los parches y Cell Size los escalones. Outline ofrece None / Perimeter / Every Cell, con contornos alrededor de huecos y sin aristas internas a Gap 0, incluidas filas irregulares. Los valores iniciales nuevos favorecen regiones conectadas; hidratación y runtime conservan la apariencia de configuraciones anteriores. Se revisaron renders fotográficos sobre blanco y una combinación exploratoria con halftone. Paint ya está aceptado; Edge Scatter y estudios editables del afiche están implementados y pendientes de feedback visual, como se detalla en C y D. Ver [prueba manual, cobertura y límites de rendimiento](tests/composition/PHOTOGRAPHIC-CELLS-MANUAL-QA.md). + +**A. Regiones automáticas — primera entrega** + +- Añadir un modo **Regions** que seleccione conjuntos de celdas vecinas y produzca parches continuos. Explorar selección guiada por la imagen y patrones espaciales coherentes con semilla; conservar los modos actuales de selección por celda. +- Separar **Region Size**, que determina la escala de los parches, de **Cell Size**, que determina el tamaño de los escalones del borde. Mantener detalle fotográfico completo dentro de cada región. +- Reutilizar Threshold, inversión y Seed cuando sean pertinentes; mostrar solo los controles relevantes al modo elegido. A Gap 0, las celdas seleccionadas contiguas deben formar una superficie continua. +- Separar internamente la selección de regiones de la generación de celdas y contornos. Tanto la selección automática como la pintura posterior deben alimentar el mismo resultado geométrico. +- Evaluar los resultados con varias fotografías y regiones de color. La selección tonal o procedural no promete identificar sujetos: aislar exactamente el puente requiere una selección dirigida o trabajo posterior de detección. Si el automático no resulta útil, documentar el límite y priorizar pintura sin bloquear toda la mejora. + +**B. Contornos integrados en Photographic Cells — junto con regiones** + +- Mantener el contorno dentro de este efecto: necesita conocer la selección y la vecindad de sus celdas. Un efecto genérico para delinear imágenes, texto o grupos queda como posibilidad futura fuera de esta entrega. +- Controles: **Outline → None / Perimeter / Every Cell**, **Width** y **Color**. Perimeter sigue la silueta revelada; Every Cell conserva la apariencia actual de contornos por celda. +- En Perimeter, eliminar aristas compartidas entre celdas seleccionadas cuando formen una región continua. Delinear también los límites de huecos reales dentro de la región. Comprobar uniones, esquinas y filas irregulares sin costuras internas. +- Definir y comprobar el comportamiento con Gap positivo sobre la cobertura resultante: las separaciones reales pueden crear contornos, sin inflar el grosor. Conservar la corrección reciente de antialiasing, el alfa de la fuente y el comportamiento de Cutout / Keep Image. +- Aplicar los mismos modos de contorno a regiones automáticas y pintadas. Mantener la apariencia de proyectos anteriores: al faltar el nuevo selector, un contorno existente conserva Every Cell y un ancho cero sigue sin dibujar contorno. + +**C. Pintura manual — implementada y confirmada visualmente por el usuario** + +Implementación: Layout Paint entra automáticamente a edición; Done permite salir y Edit Paint retomarla. Reveal/Erase, Brush Size, Clear reversible, Edit/Done, guía tenue temporal y un trazo por acción de historial. Cobertura compacta persistida en parámetros y runtime, textura GPU reutilizada sin reconstruirla por cambios del video. Mantiene el espacio de la grilla al hacer zoom, desplazar o redimensionar; cambiar Cell Size no borra la pintura. Referencia revisada: 11 (puente). [Prueba y límites](tests/composition/CELL-PAINT-MANUAL-QA.md). No cerrar D ni asumir seguimiento del sujeto. + +- Añadir **Paint** como modo de selección de la capa. El usuario pinta dónde quiere revelar la fotografía; la geometría convierte esa área en bloques escalonados. Cambiar Cell Size ajusta la fidelidad al trazo sin borrar lo pintado. +- Interfaz mínima: **Reveal / Erase**, **Brush Size** y **Clear**, con una acción explícita para entrar y salir de pintura. Mostrar la huella del pincel y evitar conflictos con selección, desplazamiento y zoom del lienzo. +- Mantener la pintura alineada en coordenadas de composición al cambiar zoom, tamaño de vista o resolución de exportación. La pintura representa cobertura de esta capa; no incorpora seguimiento automático del sujeto en video. +- Un trazo debe poder deshacerse/rehacerse como una sola acción; Clear también debe ser reversible. Conservar lo pintado al cambiar temporalmente de modo y definir su persistencia en duplicación, guardado/reapertura e hidratación real del editor. +- Incluir la selección pintada en el archivo de proyecto y en la configuración/recursos exportados al runtime. Vista previa, PNG y render de video deben reproducirla sin depender del estado temporal del editor. +- Esta herramienta es un modo de revelado propio de Photographic Cells. No reactiva el trabajo pospuesto de máscaras geométricas genéricas ni exige la revisión de Blob Tracking. + +**D. Refinamientos visuales — implementados, pendientes de feedback visual del usuario** + +Se abrieron las referencias 05 y 11 y se compararon renders reales. Se incorporó **Edge Scatter** (0–1, inicial 0) para Regions y Paint: fragmenta el perímetro mediante un desplazamiento acotado del punto de selección, sin mover la geometría ni el detalle de la fotografía. Reutiliza Seed; no modifica la pintura guardada. Cero conserva el resultado anterior, y Individual Cells lo ignora. Sin pases, texturas ni muestras de fuente adicionales; no depende del tiempo. Detalles pintados muy estrechos pueden fragmentarse con valores altos. + +[Dos estudios editables](public/examples/v3/README.md) muestran campos de color con trama fina y revelado fotográfico dirigido, con tipografía y marcas SVG separadas. Cells se aplica después del Halftone existente dentro del grupo: los puntos son mucho más pequeños que las celdas y respetan su cobertura. No hizo falta añadir más controles de trama a Cells. Las referencias no se incluyen como assets; se utiliza fotografía existente de la app y linework original. + +La curaduría demuestra esas cualidades, no cierra toda la dirección artística ni reproduce los afiches. Sigue pendiente la aceptación del usuario, el acabado analógico más rico y la validación nativa de rendimiento/exportación temporal de 7.2. El mínimo tipográfico actual de 48px limita anotaciones finas; retomar el ajuste acotado de 2.3. La curaduría también confirma que el canvas sigue el viewport: las escenas importadas se reencuadran según la ventana. El artboard estable aprobado en 2.8 debe resolverse antes de presentar estas escenas como plantillas de afiches fijos. [Prueba manual, compatibilidad y límites](tests/composition/CELL-SCATTER-MANUAL-QA.md). + +**Criterios de exploración utilizados:** + +- **Edge Scatter:** probar un único control que fragmente celdas cerca del perímetro, preservando las regiones principales. Comparar con los pequeños cuadrados alrededor de las manchas del afiche blanco antes de decidir su incorporación. +- **Puntos finos:** probar primero una composición con la capa de halftone existente, manteniendo una escala de trama menor que Cell Size. Evaluar el orden y el alcance del grupo para que la trama respete la región revelada. Añadir controles propios solo si esa combinación no alcanza un resultado útil. +- Color, anotaciones técnicas y tipografía pueden venir de otras capas. Preparar composiciones editables que demuestren el conjunto sin recargar este efecto. + +**Orden y aceptación:** entregar A+B primero en un PR apilado; revisar renders y obtener feedback del usuario. Continuar con C en una entrega posterior; evaluar D con esas herramientas disponibles. No considerar implementada ninguna mejora por haberla documentado. Mantener todos los PR dentro de la pila V3 hasta completar el plan. + +**Criterios de cierre de esta mejora:** + +- Una región de varias celdas a Gap 0 muestra fotografía continua, contorno exterior fino y ausencia de divisiones internas en Perimeter; Every Cell reproduce las divisiones deliberadamente. Comprobar regiones separadas, huecos y filas irregulares. +- Region Size cambia la escala de los parches y Cell Size cambia los escalones sin convertir el interior en colores planos. Comparar capturas con las referencias 05 y 11, incluyendo una composición sobre blanco con amplio espacio vacío. +- En Paint, poder revelar un detalle elegido, borrar parte, cambiar Cell Size y zoom, deshacer/rehacer, guardar/reabrir y exportar conservando el área elegida y sus contornos. +- Mantener aislamiento de grupos, transparencia de la fuente, ancho de contorno estable al variar Gap, compatibilidad de proyectos anteriores y equivalencia editor/runtime. Ampliar pruebas de GPU, hidratación mediante `applyLabProjectFile`, historial y exportación según cada entrega. +- Medir respuesta interactiva con fotografía y video a resoluciones representativas; comprobar estabilidad temporal de regiones automáticas. Documentar límites junto a las capturas revisadas. Pasar `bun run check` y `bun run test:composition` al implementar; la revisión visual del usuario sigue siendo necesaria. + +**Idea para ampliar anillos, todavía por elegir:** probar una única familia de formas concéntricas (círculo, triángulo y cuadrado) reutilizando conteo, rotación, desplazamiento y separación. Separar la forma base del corte completo/mitad permitiría extender los semidiscos sin multiplicar controles. No implementar esta ampliación en el prototipo de celdas; presentar la propuesta al usuario primero. + ### 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 +331,35 @@ 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 Anotaciones técnicas y calidad de Blob Tracking — dirección artística destacada + +**Prioridad revisada:** el usuario quiere profundizar en estos detalles; deja de ser una nota de prioridad baja. Los puntos naranjas, cajas rojas, cruces, etiquetas y líneas de las nuevas referencias aportan una dirección visual importante para V3. Desarrollarla después de las herramientas de composición prioritarias, por entregas con revisión visual. + +Revisar primero lo que ya ofrece Blob Tracking: detección, estabilidad temporal, contornos, valores iniciales, controles y coste con fotografía/video. Decidir qué conviene ampliar allí y qué merece una capa de anotaciones independiente, sin duplicar funciones ni asumir una reescritura. + +Explorar colocación guiada por bordes/regiones y colocación decorativa con semilla. Buscar combinaciones de puntos, cajas, cruces, etiquetas y conexiones con control coherente de densidad, escala y color, evitando una lista inmanejable de parámetros. Las etiquetas decorativas deben distinguirse de una detección real: una caja con texto no demuestra reconocimiento de personas ni seguimiento semántico. Revisar renders con las referencias abiertas, tanto en composiciones sutiles como densas. + +En video, evitar parpadeo y saltos de identificadores/posiciones cuando sea viable; medir estabilidad y carga sostenida antes de prometer seguimiento. Conservar la apariencia de escenas anteriores, historial, persistencia y paridad de exportación. Los modos de máscara derivados de detección siguen como extensión posterior a las cuatro máscaras manuales de 1.4; distinguir visualización, efecto localizado y recorte de cobertura. + +### 6.4 Profundidad y parallax — investigación aprobada + +Investigar primero **imagen + depth map importado** para movimiento sutil de cámara/parallax, desplazamiento y aplicación selectiva de efectos según distancia. Parallax es un resultado que puede usar profundidad; no requiere prometer otra clase de mapa al usuario. Definir alineación, inversión/escala de profundidad y tratamiento de bordes/huecos antes de elegir controles o incorporar el efecto. + +Usar [DepthFlow](https://github.com/BrokenSource/DepthFlow) como referencia técnica para la exploración, sin asumir que se integra directamente en el navegador. Revisar arquitectura, dependencias y condiciones de reutilización antes de elegir una integración. Comparar una prueba pequeña con los objetivos de rendimiento de Shader Lab. + +La generación automática de profundidad viene después: evaluar ejecución local o servicio, coste, latencia y reutilización del resultado. Comenzar con fotografía; profundidad de video, estabilidad entre frames y relación con futuras escenas 3D requieren validación separada. No ejecutar inferencia por frame por defecto ni asumir compatibilidad solo porque una biblioteca la ofrezca. La investigación debe producir una decisión de viabilidad y alcance; no constituye una promesa de entregar todas estas variantes en V3. + +### 6.5 Mejorar Pattern con patrones propios — prioridad baja + +**Exploración solicitada:** ampliar la capa Pattern existente para cargar SVG o imágenes propias y organizar una secuencia de motivos. Permitir previsualizar y reordenar los elementos que se asignan de zonas claras a oscuras. El orden manual debe expresar una intención visual, sin depender de ordenar automáticamente por el brillo de los archivos. + +Ejemplo del usuario: una secuencia desde una manzana verde/fresca hasta una podrida, asignada por orden a tonos claros → oscuros. Cambiar el orden cambia qué motivo aparece en cada rango tonal de la imagen; no significa interpolar o transformar una manzana en otra. + +Permitir conservar los colores originales de las imágenes y SVG multicolor. Explorar un modo explícito de recoloración para SVG compatibles, definiendo si reemplaza un color, usa una tinta o permite editar una paleta; no destruir los colores originales por defecto ni prometer recolorear cualquier SVG arbitrario. Definir formatos/subconjunto SVG, transparencia, escala, separación, cantidad de motivos y comportamiento de los rangos al ampliar la herramienta. + +Conservar patrones incorporados y proyectos anteriores. Los motivos cargados, su orden y sus colores deben sobrevivir a duplicación, historial, guardado/reapertura y exportación editor/runtime. Validar con fotografía y video, reutilizando texturas/atlas sin decodificar archivos por frame; medir memoria y fijar límites útiles al implementar. Esta exploración de prioridad baja no bloquea las entregas prioritarias; su incorporación final a V3 queda por decidir. + +**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 anotaciones/Blob Tracking y la decisión de viabilidad de profundidad/parallax. Identificar explícitamente qué alcance de Patterns se incorpora o se aplaza. ## Fase 7 — Verificar el conjunto y cerrar V3 @@ -178,10 +367,19 @@ Elegir según resultados visuales y viabilidad, aplicar los criterios de control ### 7.1 Revisar regresiones y usabilidad de extremo a extremo -Recorrer inserción, edición extrema, combinaciones, catálogo, grupos, máscaras, deshacer y reapertura. Incluir piezas tipográficas y, si se entrega, la concordancia del arrastre con los controles. Comparar escenas antiguas y comprobar que valores iniciales atractivos y libertad creativa coexisten. +Recorrer inserción, edición extrema, combinaciones, catálogo, grupos, máscaras, deshacer y reapertura. Incluir inicio limpio, máscaras de efecto frente a recorte, Gradient Map local, formas, piezas tipográficas y concordancia de edición directa con controles, artboard y exportación. Comparar escenas antiguas y comprobar que valores iniciales atractivos y libertad creativa coexisten. ### 7.2 Verificar rendimiento y exportación +**Requisito transversal confirmado por el usuario (17 de septiembre):** el editor debe mantener una interacción y reproducción útiles en escenas dinámicas con video, efectos animados y, al incorporarse, elementos 3D. El usuario reportó alta carga/calor con los nuevos efectos; aún no se atribuyó el coste a una capa concreta. Optimizar escenas estáticas o evitar renders en pausa es solo una parte del trabajo. + +**Base inicial:** benchmark acotado con video decodificado, Rings/Cells por separado y combinados, disponible en `tests/composition/effect-video-performance.mjs`. SwiftShader identifica Rings como el coste mayor en ese entorno; no sustituye medir la GPU nativa ni demuestra que el calor del usuario esté resuelto. Paint reutiliza su textura y verifica que las fuentes dinámicas siguen actualizándose. + +- Medir Rings y Cells por separado y combinados con video en reproducción, más la pila completa; extender los mismos casos a modelos/animación 3D cuando estén disponibles. Registrar GPU/navegador, resolución real, escala de píxeles, parámetros y número de capas. Los tiempos de SwiftShader no sustituyen la validación en hardware real. +- Evaluar tiempos de frame sostenidos, variaciones/picos, respuesta al editar controles y uso de memoria bajo movimiento continuo. Conservar un caso visual representativo del trabajo del usuario para comparar optimizaciones; una captura PNG no permite reconstruir sus parámetros ni diagnosticar por sí sola el origen de la carga. +- Priorizar menos trabajo y muestreos repetidos por píxel, reutilización de recursos y ausencia de lecturas GPU→CPU innecesarias por frame. Cualquier caché debe invalidarse correctamente al cambiar el video, la animación, los parámetros, la cámara/escena 3D o la resolución. +- Mantener fidelidad visual, transparencia, orden de composición y sincronización temporal. Si se necesita una calidad de previsualización ajustable, distinguirla claramente de la calidad de exportación y verificar ambas rutas. Incorporar mediciones comparables a cada ampliación artística, sin esperar al cierre de V3. + Probar casos representativos con capas, video y 3D. Comparar vista previa y exportación, incluidos tiempos de animación, alcance de efectos y recursos conservados. Comprobar transparencia en formatos que la admitan y documentar límites observados de rendimiento y compatibilidad. **Cierre:** verificar los criterios del alcance seleccionado en las fases 1–7, resolver problemas que impidan cumplirlos y distinguir funciones entregadas de exploraciones futuras. La grilla de la fase 8 no es condición para cerrar V3. @@ -194,12 +392,21 @@ Probar casos representativos con capas, video y 3D. Comparar vista previa y expo Explorar columnas, filas, separación, márgenes y configuraciones o presets editoriales. Validar una grilla visible únicamente como guía del editor: no es una capa ni un efecto de trama y no forma parte de la imagen exportada. Estos detalles son propuestas para probar, no decisiones cerradas. -### 8.2 Evaluar alineación y ajuste a la grilla para texto +### 8.2 Evaluar alineación y ajuste a la grilla para texto y formas -Comenzar la exploración con texto y su arrastre, si este se incorpora. Probar snapping activable para alinear elementos, manteniendo posicionamiento libre al desactivarlo. Evaluar legibilidad de la guía y concordancia entre ajuste, controles y deshacer, sin ampliar el editor innecesariamente. +Conectar la grilla con la edición directa de texto (2.2) y las capas de formas (2.7), usando el artboard de 2.8. Probar snapping activable a columnas, filas y márgenes; conservar posicionamiento libre al desactivarlo. Evaluar legibilidad de la guía y concordancia entre ajuste, controles, zoom y deshacer. La alineación básica puede desarrollarse sin esperar a esta grilla opcional. ### 8.3 Dejar las extensiones futuras identificadas -El posicionamiento de modelos 3D mediante la grilla y las formas básicas —estrellas, cuadrados y círculos— son posibilidades futuras, **no requisitos confirmados**. Evaluarlas por separado solo si la guía inicial demuestra utilidad. +El posicionamiento de modelos 3D mediante la grilla sigue como posibilidad futura, no como requisito confirmado. Las capas de formas ya están aprobadas en 2.7 y no dependen de implementar la grilla; su ajuste a ella se evalúa en 8.2. **Cierre opcional:** decidir si merece desarrollarse o posponerse. Si se desarrolla, validar la utilidad para texto, el ajuste activable y la separación entre guía e imagen exportada. Ningún resultado de esta fase condiciona el cierre obligatorio de V3. + + +## Seguimiento posterior — Página de guías + +**Idea acordada con el usuario; desarrollar el alcance más adelante.** Crear una página de guías para ayudar a diseñadores a aprender Shader Lab y conseguir resultados útiles combinando sus capas. Registrar esta dirección sin iniciar su implementación ni añadirla como requisito de cierre de V3. + +Como punto de partida, reutilizar los [estudios editables y recetas](public/examples/v3/README.md): mostrar el resultado visual, explicar el orden de las capas y el alcance de los grupos, destacar los controles que cambian el resultado y ofrecer archivos `.lab` para explorar. Considerar también guías breves de composición, pintura, color y exportación, con consejos de rendimiento cuando correspondan. + +La organización de contenidos, el diseño de la página, su acceso desde la app y la selección de las primeras guías quedan por definir con el usuario. Priorizar explicaciones prácticas orientadas a resultados y mantener los ejemplos alineados con las herramientas disponibles. diff --git a/V3-VISUAL-REFERENCES.md b/V3-VISUAL-REFERENCES.md new file mode 100644 index 00000000..5d594cc1 --- /dev/null +++ b/V3-VISUAL-REFERENCES.md @@ -0,0 +1,89 @@ +# V3 visual references + +The original sixteen images and the additional designer-feedback references below are the user's visual direction for Shader Lab V3. Before choosing shader behavior, geometry, defaults, or appearance, **open the relevant reference images**. Compare the resulting render with them. Technical correctness alone does not establish visual completion. + +## Where to find them + +- Original folder: `/Users/tobiasmoccagatta/Desktop/Shader Lab V3 - Referencias/` +- Workspace backup: `.context/v3-references/` (all sixteen originals copied September 17, 2026; gitignored). +- Overview for orientation: `.context/v3-reference-sheet.png` (gitignored). Open individual originals to inspect detail. + +The images are visual references, not application assets. They are not bundled into the product or committed here. In another workspace, use the original folder or copy it into that workspace's `.context/v3-references/`. If neither location exists, request the images instead of guessing from their names. + +## Reference index + +| Image | What to inspect | +| --- | --- | +| `01-lovedance-rojo-azul.png` | Bold geometry over red/blue photography, blurred imagery, layered type. | +| `02-collage-editorial-monocromo.png` | Fragmented monochrome photo, circular geometry, dense type and technical lines. | +| `03-rana-falso-color.png` | Selective false color, dark negative space, delicate tracking annotations. | +| `04-trama-puntos-halos.png` | Fine regular dots, luminous cross-shaped halos, optical contrast. | +| `05-recortes-celdas-color.png` | Connected color regions with stepped cellular boundaries, fine dot texture, sparse technical linework on white. | +| `06-james-blake-fotografia-difusa.png` | Washed green photography, diffuse light and grain, drifting photographic forms. | +| `07-industrial-negro-naranja.png` | Burned orange/yellow highlights, deep black, layered industrial photo and type. | +| `08-estrella-halos-color.png` | Soft multicolor contours and halos around a geometric star. | +| `09-collage-tipografia-tramas.png` | Oversized layered type, photographic fragments, print texture and vivid accents. | +| `10-retrato-azul-anotaciones.png` | Diffuse false-color portrait, washed ground, fine annotations and diagram details. | +| `11-puente-recortes-geometricos.png` | Connected stepped photographic silhouettes, thin perimeter contours, generous white negative space and pink linework. | +| `12-retrato-tonos-invertidos.png` | Partial tonal inversions, solarized photographic detail and washed contrast. | +| `13-arrastre-escaner-rojo.png` | Horizontal scanner drag, torn bands, red/black/white distortion. | +| `14-rostro-rosa-erosionado.png` | Eroded photographic reveal, fine grain, pink staining and white negative space. | +| `15-relieve-grabado-plateado.png` | Fine engraved relief, metallic tonal structure and directional edges. | +| `16-anillos-desfasados-azules.png` | Rotated/offset concentric photographic bands, electric blue/black contrast and layered type. | + +## Photographic Cells: accepted prototype, unfinished visual direction + +The user considers the first layer useful enough; the Gap/outline inflation has been corrected. This does **not** mean it achieves references 05 and 11. + +- The bridge is one photographic subject revealed through connected, stepped regions. Its contours follow the region perimeter; it is not a brick grid with every cell individually outlined. +- The white poster combines broad flowing color regions, small stepped edge fragments, fine dots, and delicate technical marks. Large randomly selected rectangular cells alone do not reproduce that appearance. +- Approved next direction: automatic connected regions and integrated None / Perimeter / Every Cell outlines first, followed by a Paint selection mode with Reveal / Erase, Brush Size, Clear, and undo. Preserve photographic detail and share geometry/outline behavior between automatic and painted selection. +- Region Size controls the broad patches; Cell Size controls their stepped boundaries. Painting should remain useful independently of automatic selection, without implying subject detection. +- Edge Scatter and two editable halftone/color/photo studies are implemented, pending user review. The existing halftone supplies the fine dots; no separate dot controls were needed in Cells. The detailed sequence and acceptance criteria are in roadmap section 3.3.1; A+B (automatic regions and outline modes) is implemented and visually accepted by the user; Paint is implemented and visually accepted by the user; D is implemented and awaits user visual feedback. +- Use these images for side-by-side visual review before marking the cell family complete. The current smooth field produces stepped patches and perimeter contours, but does not isolate a chosen subject. Directed painting is implemented and accepted; fine dot/color curation is now demonstrated in the editable studies below, awaiting user review. + +For every shader slice, note the reference filenames used, which visible qualities it achieves, and which remain follow-up work. Keep the experimental editorial, analog photographic, and acid-graphics direction visible in actual renders. + +## Threshold palette + +Reference 01 (Lovedance red/blue) was opened for the custom Threshold palette slice. Actual blue/pink and blue/red photo renders demonstrate strong two-color separation while retaining the existing noisy boundary and softness controls. Black/white defaults remain unchanged. This supplies a palette tool for later compositions; it does not deliver the reference’s lighting, geometric collage or typography. User visual acceptance is pending; see [the focused test](tests/composition/THRESHOLD-COLORS-MANUAL-QA.md). + + +## Edge Scatter and editable editorial studies + +References 05 and 11 were reopened full-size. Before/after renders of existing and scattered boundaries were compared, then the color palette, dot scale and composition were refined in actual renderer exports. [Color Field and Painted Flora](public/examples/v3/README.md) are editable .lab files with committed preview PNGs. They use existing app photography and original SVG marks, never the reference images. + +- Color Field demonstrates broad blue/ochre/violet/teal regions, much finer source-color dots, small edge fragments, white space, and separate type/registration marks. Existing Halftone below Cells inside the isolated group supplies the texture. +- Painted Flora demonstrates full photographic interiors, a directed stepped silhouette, a fine pink perimeter, and independent text/technical lines. Paint remains editable; Scatter changes the edge without overwriting strokes. +- One new Edge Scatter control, default 0, uses bounded seeded selection offsets in Regions/Paint. No extra pass, target, readback or source samples. Random/Paint stay fixed over dynamic sources; tonal regions still react to changing imagery. High scatter can break narrow details. +- This is reference-driven progress, not a reproduction or final V3 visual completion. The richer analog finish, fine typography (current minimum 48px), final visual acceptance, sustained native-GPU performance, and encoded video export remain open. + +The user authorized this next slice while away, without waiting for manual acceptance of Threshold #162. No PRs have been merged. + + +## Additional designer feedback — approved planning direction + +The four screenshots shared on September 17 were opened and reviewed. They supplement the original sixteen; they are reference material, not product assets. Workspace backups live in `.context/v3-references/`: + +| Backup | Original attachment | What to inspect | +| --- | --- | --- | +| `17-feedback-mascaras-celdas.png` | `.context/attachments/rqFvlg/image.png` | Cell poster: local effects, masking and fine texture. | +| `18-feedback-paleta-termica.png` | `.context/attachments/Snglqt/image.png` | Thermal-style false color: tonal palette study, not evidence of depth or temperature data. | +| `19-feedback-anotaciones-tecnicas.png` | `.context/attachments/dAIoad/image.png` | Orange points, red boxes, crosshairs, labels and connections: prominent annotation/Blob Tracking direction. | +| `20-feedback-formas-lovedance.png` | `.context/attachments/aByVza/image.png` | Lovedance: colored shape layers, photographic blends, masking and type. | + +The user approved simple general masks (linear/radial gradient, ellipse/circle, rectangle/square, brush), replacing the earlier postponement. No pen tool. Reuse the accepted Cells brush interaction while implementing general effect masking separately from content cutouts. Automatic selection remains later work. + +Technical annotations are now a prominent artistic direction, rather than only a low-priority Blob Tracking note. Revisit screenshot 19 before choosing their behavior or appearance. Thermal-style color mapping and depth-driven coloring are separate explorations; do not infer how the cover in screenshot 18 was made. + +The new low-priority Pattern proposal has no additional supplied image: its concrete brief is user-uploaded SVG/image motifs in a manually ordered light-to-dark sequence (fresh green apple → rotten apple), preserving original colors and exploring explicit SVG recoloring. Do not mistake ordering motifs for generating an animated morph between them. These are roadmap additions, not implemented features or new visual approvals of existing work. + + +## Reusable layer masks + +References 17 (cell poster with locally masked pixelation and gradient mapping) and 20 (Lovedance shapes) were reopened before implementing roadmap 1.4. Both frame local treatments with simple geometry or painted regions, which is what the four tools supply: gradient (linear/radial), ellipse, rectangle and brush, with limit-effect and cut-content scopes, invert and feather. No pen tool. This is a compositional tool, not a reproduction of either reference; shape layers with editable colors (2.7) and the local Gradient Map (2.5.2) remain separate slices. The user accepted the masks after testing them. + + +## Gradient Map layer + +Reference 18 (Brockhampton thermal-style cover) was reopened before building the Gradient Map effect. The Thermal preset follows its deep blue → green → yellow → red progression as a tonal palette applied to luminance; it does not infer depth or reproduce the cover's exact distribution. The ramp is fully editable, so the reference guides the default palette only. User visual acceptance is pending; see [the focused test](tests/composition/GRADIENT-MAP-MANUAL-QA.md). 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-mcp/src/tools/update-layer.ts b/packages/shader-lab-mcp/src/tools/update-layer.ts index b24a9a4f..ab4a51f1 100644 --- a/packages/shader-lab-mcp/src/tools/update-layer.ts +++ b/packages/shader-lab-mcp/src/tools/update-layer.ts @@ -14,6 +14,25 @@ export const schema = { source: z.string().optional(), }) .optional(), + mask: z + .object({ + shape: z + .enum(["none", "linear", "radial", "ellipse", "rectangle", "brush"]) + .optional(), + scope: z.enum(["effect", "content"]).optional(), + enabled: z.boolean().optional(), + invert: z.boolean().optional(), + center: z.tuple([z.number(), z.number()]).optional(), + size: z.tuple([z.number(), z.number()]).optional(), + rotation: z.number().optional(), + feather: z.number().optional(), + paint: z.string().optional(), + }) + .nullable() + .optional() + .describe( + "Reusable layer mask in centered shorter-edge composition units (y down). shape none disables it; scope effect limits the effect, content cuts coverage (effects only). Pass null to reset." + ), opacity: z.number().optional(), saturation: z.number().optional(), } @@ -23,7 +42,7 @@ export const metadata: ToolMetadata = { title: "Update layer", }, description: - "Update layer-level properties: opacity (0-1), hue (-180 to 180), saturation (0-2), blendMode, compositeMode, and maskConfig. For effect-specific parameters use update_layer_params.", + "Update layer-level properties: opacity (0-1), hue (-180 to 180), saturation (0-2), blendMode, compositeMode, maskConfig, and the reusable mask (gradient, ellipse, rectangle or brush). For effect-specific parameters use update_layer_params.", name: "update_layer", } 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..20fdde4b 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,8 @@ declare module "three/tsl" { export interface LoopConfig { + name?: string condition?: string - end: number + end: number | TSLNode start: number type: "float" | "int" } @@ -103,16 +104,32 @@ declare module "three/tsl" { export function cross(left: unknown, right: unknown): TSLNode export function div(left: unknown, right: unknown): TSLNode export function dot(left: unknown, right: unknown): TSLNode + export interface ShaderFunction { + (...args: unknown[]): TSLNode & { compute(count: number): unknown } + setLayout(layout: { + name: string + type: string + inputs: { name: string; type: string }[] + }): ShaderFunction + } + export interface ConditionalNode extends TSLNode { + Else(callback: () => unknown): ConditionalNode + } export function Fn( fn: ShaderNodeFn | ((...args: never[]) => unknown), layout?: unknown - ): (...args: unknown[]) => TSLNode & { compute(count: number): unknown } + ): ShaderFunction export function Loop( config: LoopConfig | TSLNode | number, callback: (inputs: LoopInputs) => unknown ): TSLNode export function exp(value: unknown): TSLNode export function fract(value: unknown): TSLNode + export function If( + condition: TSLNode, + callback: () => unknown + ): ConditionalNode + 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/index.ts b/packages/shader-lab-react/src/index.ts index bb5d9e56..5a9e7582 100644 --- a/packages/shader-lab-react/src/index.ts +++ b/packages/shader-lab-react/src/index.ts @@ -49,6 +49,9 @@ export type { ShaderLabKeyframeEasing, ShaderLabLayerConfig, ShaderLabLayerKind, + ShaderLabLayerMask, + ShaderLabLayerMaskScope, + ShaderLabLayerMaskShape, ShaderLabLayerType, ShaderLabModuleSketchSource, ShaderLabParameterValue, 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/cell-paint-mask.ts b/packages/shader-lab-react/src/renderer/cell-paint-mask.ts new file mode 100644 index 00000000..d782e519 --- /dev/null +++ b/packages/shader-lab-react/src/renderer/cell-paint-mask.ts @@ -0,0 +1,53 @@ +/** Binary coverage in centered, shorter-edge composition units. Versioned and + * bounded so projects/history need no external assets or growing stroke lists. */ +export const CELL_PAINT_SIZE = 512 +const PIXELS = CELL_PAINT_SIZE * CELL_PAINT_SIZE +const BYTES = PIXELS / 8 +export type CellPaintMask = { data: Uint8Array; width: number; height: number } +export function emptyCellPaintMask(width = 1, height = 1): CellPaintMask { + return { data: new Uint8Array(PIXELS), width, height } +} +export function decodeCellPaintMask(value: unknown): CellPaintMask { + if (typeof value !== "string" || value.length > 44000) + return emptyCellPaintMask() + const parts = value.split(":") + const width = Number(parts[1]) + const height = Number(parts[2]) + if ( + parts.length !== 4 || + parts[0] !== "pc1" || + !Number.isFinite(width) || + !Number.isFinite(height) || + width < 1 || + height < 1 || + width > 100 || + height > 100 || + (parts[3] ?? "").length !== Math.ceil(BYTES / 3) * 4 + ) + return emptyCellPaintMask() + try { + const packed = atob(parts[3] ?? "") + if (packed.length !== BYTES) return emptyCellPaintMask() + const mask = emptyCellPaintMask(width, height) + for (let i = 0; i < PIXELS; i++) + mask.data[i] = packed.charCodeAt(i >> 3) & (1 << (i & 7)) ? 255 : 0 + return mask + } catch { + return emptyCellPaintMask() + } +} +export function encodeCellPaintMask(mask: CellPaintMask): string { + const packed = new Uint8Array(BYTES) + let any = false + for (let i = 0; i < PIXELS; i++) { + if (mask.data[i]) { + packed[i >> 3] = (packed[i >> 3] ?? 0) | (1 << (i & 7)) + any = true + } + } + if (!any) return "" + let binary = "" + for (let i = 0; i < packed.length; i += 4096) + binary += String.fromCharCode(...packed.subarray(i, i + 4096)) + return `pc1:${mask.width}:${mask.height}:${btoa(binary)}` +} diff --git a/packages/shader-lab-react/src/renderer/color-map-lut.ts b/packages/shader-lab-react/src/renderer/color-map-lut.ts new file mode 100644 index 00000000..3e71d3d2 --- /dev/null +++ b/packages/shader-lab-react/src/renderer/color-map-lut.ts @@ -0,0 +1,161 @@ +export type GradientMapStop = { color: string; position: number } + +export const COLOR_MAP_LUT_SIZE = 256 + +export const GRADIENT_MAP_PRESETS: { + id: string + label: string + stops: GradientMapStop[] +}[] = [ + { + id: "thermal", + label: "Thermal", + stops: [ + { position: 0, color: "#08083a" }, + { position: 0.25, color: "#1f5cff" }, + { position: 0.5, color: "#2fd27a" }, + { position: 0.75, color: "#f7e63b" }, + { position: 1, color: "#ff2e63" }, + ], + }, + { + id: "duotone", + label: "Duotone", + stops: [ + { position: 0, color: "#1a1040" }, + { position: 1, color: "#ff7a59" }, + ], + }, + { + id: "sepia", + label: "Sepia", + stops: [ + { position: 0, color: "#2b1d0e" }, + { position: 0.5, color: "#8c6a3f" }, + { position: 1, color: "#f1e4c6" }, + ], + }, + { + id: "neon", + label: "Neon", + stops: [ + { position: 0, color: "#0b0033" }, + { position: 0.5, color: "#ff00aa" }, + { position: 1, color: "#00ffe1" }, + ], + }, + { + id: "grayscale", + label: "Grayscale", + stops: [ + { position: 0, color: "#000000" }, + { position: 1, color: "#ffffff" }, + ], + }, +] + +export const DEFAULT_GRADIENT_MAP_STOPS = GRADIENT_MAP_PRESETS[0]!.stops + +const HEX = /^#[0-9a-f]{6}$/i + +export function hexToRgb(hex: string): [number, number, number] { + const h = hex.replace("#", "") + return [ + Number.parseInt(h.slice(0, 2), 16) / 255, + Number.parseInt(h.slice(2, 4), 16) / 255, + Number.parseInt(h.slice(4, 6), 16) / 255, + ] +} + +function srgbToLinear(value: number): number { + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4 +} + +export function serializeGradientMapStops(stops: GradientMapStop[]): string { + return JSON.stringify( + stops.map((stop) => ({ + position: Math.round(stop.position * 10000) / 10000, + color: stop.color.toLowerCase(), + })) + ) +} + +export function canonicalGradientMapStops(stops: GradientMapStop[]): string { + return serializeGradientMapStops( + [...stops].sort((a, b) => a.position - b.position) + ) +} + +export function parseGradientMapStops(value: unknown): GradientMapStop[] { + let raw: unknown = value + if (typeof value === "string") { + if (value.trim() === "") return DEFAULT_GRADIENT_MAP_STOPS.map((s) => ({ ...s })) + try { + raw = JSON.parse(value) + } catch { + return DEFAULT_GRADIENT_MAP_STOPS.map((s) => ({ ...s })) + } + } + if (!Array.isArray(raw)) return DEFAULT_GRADIENT_MAP_STOPS.map((s) => ({ ...s })) + const stops: GradientMapStop[] = [] + for (const entry of raw.slice(0, 8)) { + if (!entry || typeof entry !== "object") continue + const { color, position } = entry as Record + if (typeof color !== "string" || !HEX.test(color)) continue + if (typeof position !== "number" || !Number.isFinite(position)) continue + stops.push({ + color: color.toLowerCase(), + position: Math.min(1, Math.max(0, position)), + }) + } + if (stops.length < 2) return DEFAULT_GRADIENT_MAP_STOPS.map((s) => ({ ...s })) + return stops +} + +export function evaluateGradientMapStops( + stops: GradientMapStop[], + t: number +): [number, number, number] { + const sorted = [...stops].sort((a, b) => a.position - b.position) + const first = sorted[0] + const last = sorted[sorted.length - 1] + if (!(first && last)) return [t, t, t] + if (t <= first.position) return hexToRgb(first.color) + if (t >= last.position) return hexToRgb(last.color) + for (let s = 0; s < sorted.length - 1; s++) { + const a = sorted[s]! + const b = sorted[s + 1]! + if (t >= a.position && t <= b.position) { + const range = b.position - a.position + const local = range > 0 ? (t - a.position) / range : 0 + const [r0, g0, b0] = hexToRgb(a.color) + const [r1, g1, b1] = hexToRgb(b.color) + return [r0 + (r1 - r0) * local, g0 + (g1 - g0) * local, b0 + (b1 - b0) * local] + } + } + return hexToRgb(last.color) +} + +export function buildColorMapBytes(stops: GradientMapStop[]): Uint8Array { + const data = new Uint8Array(COLOR_MAP_LUT_SIZE * 4) + for (let i = 0; i < COLOR_MAP_LUT_SIZE; i++) { + const [r, g, b] = evaluateGradientMapStops(stops, i / (COLOR_MAP_LUT_SIZE - 1)) + data[i * 4] = Math.round(r * 255) + data[i * 4 + 1] = Math.round(g * 255) + data[i * 4 + 2] = Math.round(b * 255) + data[i * 4 + 3] = 255 + } + return data +} + +export function buildLinearColorMap(stops: GradientMapStop[]): Float32Array { + const data = new Float32Array(COLOR_MAP_LUT_SIZE * 4) + for (let i = 0; i < COLOR_MAP_LUT_SIZE; i++) { + const [r, g, b] = evaluateGradientMapStops(stops, i / (COLOR_MAP_LUT_SIZE - 1)) + data[i * 4] = srgbToLinear(r) + data[i * 4 + 1] = srgbToLinear(g) + data[i * 4 + 2] = srgbToLinear(b) + data[i * 4 + 3] = 1 + } + return data +} 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..d8098946 --- /dev/null +++ b/packages/shader-lab-react/src/renderer/composition-tree.ts @@ -0,0 +1,72 @@ +/** Internal render tree; editor persistence and public shader configs remain flat. */ +export interface CompositionGroup { + blendMode: string + children: CompositionNode[] + id: string + kind: "group" + mask?: unknown + 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/gradient-map-pass.ts b/packages/shader-lab-react/src/renderer/gradient-map-pass.ts new file mode 100644 index 00000000..9f6f94a3 --- /dev/null +++ b/packages/shader-lab-react/src/renderer/gradient-map-pass.ts @@ -0,0 +1,97 @@ +import { + clamp, + dot, + float, + mix, + pow, + select, + texture, + type TSLNode, + uniform, + vec2, + vec3, + vec4, +} from "three/tsl" +import * as THREE from "three/webgpu" +import { + buildLinearColorMap, + COLOR_MAP_LUT_SIZE, + parseGradientMapStops, + serializeGradientMapStops, +} from "./color-map-lut" +import { PassNode } from "./pass-node" +import type { LayerParameterValues } from "../types/editor" + +type Node = TSLNode + +export class GradientMapPass extends PassNode { + private readonly amountUniform: Node + private readonly invertUniform: Node + private readonly lut: THREE.DataTexture + private stopsKey = "" + + constructor(layerId: string) { + super(layerId) + this.amountUniform = uniform(1) + this.invertUniform = uniform(0) + this.lut = new THREE.DataTexture( + new Float32Array(COLOR_MAP_LUT_SIZE * 4), + COLOR_MAP_LUT_SIZE, + 1, + THREE.RGBAFormat, + THREE.FloatType + ) + this.lut.magFilter = THREE.LinearFilter + this.lut.minFilter = THREE.LinearFilter + this.lut.generateMipmaps = false + this.updateParams({}) + this.rebuildEffectNode() + } + + override updateParams(params: LayerParameterValues): void { + this.amountUniform.value = + typeof params.amount === "number" + ? Math.max(0, Math.min(1, params.amount)) + : 1 + this.invertUniform.value = params.invert === true ? 1 : 0 + const stops = parseGradientMapStops(params.stops) + const key = serializeGradientMapStops(stops) + if (key !== this.stopsKey) { + this.stopsKey = key + ;(this.lut.image.data as Float32Array).set(buildLinearColorMap(stops)) + this.lut.needsUpdate = true + } + } + + protected override buildEffectNode(): Node { + if (!this.lut) { + return this.inputNode + } + const source = vec3( + float(this.inputNode.r), + float(this.inputNode.g), + float(this.inputNode.b) + ) + const luma = clamp(dot(source, vec3(0.2126, 0.7152, 0.0722)), 0, 1) + const perceptual = select( + luma.lessThanEqual(float(0.0031308)), + luma.mul(12.92), + pow(luma, float(1 / 2.4)).mul(1.055).sub(0.055) + ) + const tone = select( + this.invertUniform.greaterThan(float(0.5)), + float(1).sub(perceptual), + perceptual + ) + const mapped = texture(this.lut, vec2(tone, float(0.5))).level(0) + return vec4( + mix(source, vec3(mapped.r, mapped.g, mapped.b), this.amountUniform), + float(1) + ) + } + + override dispose(): void { + this.lut.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..eef28aae --- /dev/null +++ b/packages/shader-lab-react/src/renderer/layer-hierarchy.ts @@ -0,0 +1,73 @@ +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 + mask?: unknown + 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, + mask: layer.mask ?? null, + 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/layer-mask.ts b/packages/shader-lab-react/src/renderer/layer-mask.ts new file mode 100644 index 00000000..0b9a2c85 --- /dev/null +++ b/packages/shader-lab-react/src/renderer/layer-mask.ts @@ -0,0 +1,279 @@ +import { + abs, + clamp, + cos, + float, + length, + max, + min, + mix, + select, + sin, + smoothstep, + texture, + type TSLNode, + uniform, + uv, + vec2, + vec4, +} from "three/tsl" +import * as THREE from "three/webgpu" +import { CELL_PAINT_SIZE, decodeCellPaintMask } from "./cell-paint-mask" + +export const LAYER_MASK_SHAPES = [ + "none", + "linear", + "radial", + "ellipse", + "rectangle", + "brush", +] as const +export type LayerMaskShape = (typeof LAYER_MASK_SHAPES)[number] +export const LAYER_MASK_SCOPES = ["effect", "content"] as const +export type LayerMaskScope = (typeof LAYER_MASK_SCOPES)[number] + +export interface LayerMaskState { + shape: LayerMaskShape + scope: LayerMaskScope + enabled: boolean + invert: boolean + center: [number, number] + size: [number, number] + rotation: number + feather: number + paint: string +} + +export const DEFAULT_LAYER_MASK: LayerMaskState = { + shape: "none", + scope: "effect", + enabled: true, + invert: false, + center: [0, 0], + size: [0.5, 0.5], + rotation: 0, + feather: 0.01, + paint: "", +} + +const MIN_SIZE = 0.001 +const MAX_SIZE = 8 + +function finite(value: unknown, fallback: number, low: number, high: number) { + return typeof value === "number" && Number.isFinite(value) + ? Math.min(high, Math.max(low, value)) + : fallback +} + +function pair( + value: unknown, + fallback: [number, number], + low: number, + high: number +): [number, number] { + return Array.isArray(value) + ? [ + finite(value[0], fallback[0], low, high), + finite(value[1], fallback[1], low, high), + ] + : fallback +} + +export function normalizeLayerMask(value: unknown): LayerMaskState | null { + if (!value || typeof value !== "object") return null + const input = value as Record + const shape = LAYER_MASK_SHAPES.includes(input.shape as LayerMaskShape) + ? (input.shape as LayerMaskShape) + : "none" + return { + shape, + scope: input.scope === "content" ? "content" : "effect", + enabled: input.enabled !== false, + invert: input.invert === true, + center: pair(input.center, DEFAULT_LAYER_MASK.center, -MAX_SIZE, MAX_SIZE), + size: pair(input.size, DEFAULT_LAYER_MASK.size, MIN_SIZE, MAX_SIZE), + rotation: finite(input.rotation, 0, -360, 360), + feather: finite(input.feather, DEFAULT_LAYER_MASK.feather, 0, 1), + paint: typeof input.paint === "string" ? input.paint : "", + } +} + +export function isLayerMaskActive( + mask: LayerMaskState | null | undefined +): mask is LayerMaskState { + return !!mask && mask.enabled && mask.shape !== "none" +} + +export function layerMaskSignature( + mask: LayerMaskState | null | undefined +): string { + if (!isLayerMaskActive(mask)) return "mask:off" + return [ + "mask", + mask.shape, + mask.scope, + mask.invert ? "1" : "0", + mask.center.join(","), + mask.size.join(","), + mask.rotation, + mask.feather, + mask.shape === "brush" ? mask.paint : "", + ].join(":") +} + +export type LayerMaskRole = "source" | "effect" | "transform" + +export class LayerMaskNode { + private readonly center = uniform(new THREE.Vector2(0, 0)) + private readonly size = uniform(new THREE.Vector2(0.5, 0.5)) + private readonly rotation = uniform(0) + private readonly feather = uniform(0.01) + private readonly aspect = uniform(new THREE.Vector2(1, 1)) + private readonly paintAspect = uniform(new THREE.Vector2(1, 1)) + private paintTexture: THREE.DataTexture | null = null + private paintValue = "" + private shape: LayerMaskShape = "none" + private scope: LayerMaskScope = "effect" + private invert = false + private enabled = true + + update(mask: LayerMaskState | null | undefined): boolean { + const next = mask ?? DEFAULT_LAYER_MASK + const structural = + next.shape !== this.shape || + next.scope !== this.scope || + next.invert !== this.invert || + next.enabled !== this.enabled + this.shape = next.shape + this.scope = next.scope + this.invert = next.invert + this.enabled = next.enabled + ;(this.center.value as THREE.Vector2).set(next.center[0], next.center[1]) + ;(this.size.value as THREE.Vector2).set( + Math.max(MIN_SIZE, Math.abs(next.size[0])), + Math.max(MIN_SIZE, Math.abs(next.size[1])) + ) + this.rotation.value = (next.rotation * Math.PI) / 180 + this.feather.value = Math.max(0, next.feather) + if (next.shape === "brush") { + if (!this.paintTexture) { + this.paintTexture = new THREE.DataTexture( + new Uint8Array(CELL_PAINT_SIZE ** 2), + CELL_PAINT_SIZE, + CELL_PAINT_SIZE, + THREE.RedFormat + ) + this.paintTexture.minFilter = THREE.LinearFilter + this.paintTexture.magFilter = THREE.LinearFilter + this.paintValue = "" + } + if (next.paint !== this.paintValue) { + const decoded = decodeCellPaintMask(next.paint) + this.paintTexture.image.data?.set(decoded.data) + this.paintTexture.needsUpdate = true + ;(this.paintAspect.value as THREE.Vector2).set( + decoded.width, + decoded.height + ) + this.paintValue = next.paint + } + } + return structural + } + + 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 + ) + } + + isActive(): boolean { + return this.enabled && this.shape !== "none" + } + + coverage(): TSLNode { + const screen = vec2(uv().x, float(1).sub(uv().y)) + const point = screen.sub(0.5).mul(this.aspect) + const local = point.sub(this.center) + const c = cos(this.rotation) + const s = sin(this.rotation) + const q = vec2( + local.x.mul(c).add(local.y.mul(s)), + local.y.mul(c).sub(local.x.mul(s)) + ) + const half = this.size.mul(0.5) + const softness = max(this.feather, float(0.0005)) + let value: TSLNode + switch (this.shape) { + case "linear": + value = clamp( + float(0.5).sub(q.x.div(max(this.size.x, float(MIN_SIZE)))), + 0, + 1 + ) + break + case "radial": + value = clamp(float(1).sub(length(q.div(half))), 0, 1) + break + case "rectangle": { + const d = abs(q).sub(half) + const distance = length(max(d, vec2(0))).add( + min(max(d.x, d.y), float(0)) + ) + value = float(1).sub(smoothstep(softness.negate(), softness, distance)) + break + } + case "brush": { + const paintUv = point.div(this.paintAspect).add(0.5) + const inside = paintUv.x + .greaterThanEqual(0) + .and(paintUv.x.lessThan(1)) + .and(paintUv.y.greaterThanEqual(0)) + .and(paintUv.y.lessThan(1)) + const sampled = this.paintTexture + ? texture(this.paintTexture).sample(paintUv).level(0).r + : float(0) + value = select(inside, sampled, float(0)) + break + } + default: { + const distance = length(q.div(half)) + .sub(1) + .mul(min(half.x, half.y)) + value = float(1).sub(smoothstep(softness.negate(), softness, distance)) + break + } + } + return this.invert ? float(1).sub(value) : value + } + + apply(input: TSLNode, composited: TSLNode, role: LayerMaskRole): TSLNode { + if (!this.isActive()) return composited + const coverage = this.coverage() + const compositedAlpha = clamp(float(composited.a), float(0), float(1)) + if (this.scope === "content" && role !== "source") { + return vec4(composited.rgb, compositedAlpha.mul(coverage)) + } + const inputAlpha = clamp(float(input.a), float(0), float(1)) + const outputAlpha = mix(inputAlpha, compositedAlpha, coverage) + const premultiplied = mix( + input.rgb.mul(inputAlpha), + composited.rgb.mul(compositedAlpha), + coverage + ) + return vec4( + premultiplied.div( + select(outputAlpha.greaterThan(0), outputAlpha, float(1)) + ), + outputAlpha + ) + } + + dispose(): void { + this.paintTexture?.dispose() + this.paintTexture = null + this.paintValue = "" + } +} 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..8961cc66 100644 --- a/packages/shader-lab-react/src/renderer/pass-node.ts +++ b/packages/shader-lab-react/src/renderer/pass-node.ts @@ -13,7 +13,8 @@ import { vec3, vec4, } from "three/tsl" -import { buildBlendNode } from "./blend-modes" +import { buildBlendNode, type CompositionRole } from "./blend-modes" +import { LayerMaskNode, type LayerMaskState } from "./layer-mask" import type { BloomCompositor } from "./dual-filter-bloom" import type { LayerCompositeMode, LayerParameterValues, MaskConfig } from "../types/editor" @@ -56,17 +57,22 @@ 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" private maskInvert = false + private readonly layerMask = new LayerMaskNode() private colorNodeDirty = false constructor(layerId: string) { 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 +120,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 @@ -149,6 +162,16 @@ export class PassNode { } } + updateLayerMask(mask: LayerMaskState | null | undefined): void { + if (this.layerMask.update(mask)) { + this.colorNodeDirty = true + } + } + + updateMaskLogicalSize(width: number, height: number): void { + this.layerMask.updateLogicalSize(width, height) + } + flushColorNode(): void { if (!this.colorNodeDirty) { return @@ -184,6 +207,7 @@ export class PassNode { this.effectSwapGeneration += 1 this.bloomCompositor?.dispose() this.bloomCompositor = null + this.layerMask.dispose() this.scene.clear() this.material.dispose() this.compositeGeometry.dispose() @@ -227,6 +251,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() @@ -273,7 +298,7 @@ export class PassNode { private composeColorNode(effectNode: Node): Node { const adjustedEffectNode = this.applySharedColorAdjustments(effectNode) - return buildBlendNode( + const blended = buildBlendNode( this.blendMode, this.inputNode, adjustedEffectNode, @@ -286,7 +311,9 @@ export class PassNode { source: this.maskSource, } : undefined, + this.compositionRole, ) as Node + return this.layerMask.apply(this.inputNode, blended, this.compositionRole) } private applySharedColorAdjustments(sourceNode: Node): Node { diff --git a/packages/shader-lab-react/src/renderer/photographic-cells-pass.ts b/packages/shader-lab-react/src/renderer/photographic-cells-pass.ts new file mode 100644 index 00000000..b8f90f9d --- /dev/null +++ b/packages/shader-lab-react/src/renderer/photographic-cells-pass.ts @@ -0,0 +1,443 @@ +import * as THREE from "three/webgpu" +import { + abs, + clamp, + dot, + float, + floor, + Fn, + fract, + If, + int, + length, + Loop, + max, + min, + mix, + select, + sin, + smoothstep, + step, + texture, + type TSLNode, + uniform, + uv, + vec2, + vec3, + vec4, +} from "three/tsl" +import { CELL_PAINT_SIZE, decodeCellPaintMask } from "./cell-paint-mask" +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 +} + +/** Select photographic cells without replacing their interiors with flat samples. */ +export class PhotographicCellsPass extends PassNode { + private readonly painted = uniform(0) + private readonly paintGuide = uniform(0) + private readonly paintAspect = uniform(new THREE.Vector2(1, 1)) + private readonly paintTexture = new THREE.DataTexture( + new Uint8Array(CELL_PAINT_SIZE ** 2), + CELL_PAINT_SIZE, + CELL_PAINT_SIZE, + THREE.RedFormat + ) + private readonly paintSource = texture(this.paintTexture) + private paintValue = "" + private readonly regions = uniform(0) + private readonly regionSize = uniform(0.35) + private readonly edgeScatter = uniform(0) + private readonly outlineMode = uniform(2) + private readonly size = uniform(0.1) + private readonly cellAspect = uniform(1) + private readonly irregularity = uniform(0.35) + private readonly seed = uniform(1) + private readonly selection = uniform(0) + private readonly threshold = uniform(0.35) + private readonly invert = uniform(0) + private readonly gap = uniform(0.08) + private readonly softness = uniform(0) + private readonly outline = uniform(0) + private readonly outlineColor = uniform(new THREE.Color("#e8e5dc")) + private readonly cutout = 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.paintTexture.minFilter = THREE.NearestFilter + this.paintTexture.magFilter = THREE.NearestFilter + this.paintTexture.generateMipmaps = false + this.paintTexture.needsUpdate = true + 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.painted.value = params.mode === "paint" ? 1 : 0 + this.paintGuide.value = params._paintGuide === true ? 0.18 : 0 + const paintValue = + typeof params.paintMask === "string" ? params.paintMask : "" + // Video/source changes do not decode or upload the painted mask again. + if (paintValue !== this.paintValue) { + const mask = decodeCellPaintMask(paintValue) + this.paintTexture.image.data?.set(mask.data) + this.paintTexture.needsUpdate = true + ;(this.paintAspect.value as THREE.Vector2).set(mask.width, mask.height) + this.paintValue = paintValue + } + this.regions.value = params.mode === "regions" ? 1 : 0 + this.regionSize.value = number(params.regionSize, 0.35, 0.05, 2) + this.edgeScatter.value = number(params.edgeScatter, 0, 0, 1) + this.outlineMode.value = 2 + if (params.outlineMode === "none") this.outlineMode.value = 0 + if (params.outlineMode === "perimeter") this.outlineMode.value = 1 + this.size.value = number(params.size, 0.1, 0.015, 0.5) + this.cellAspect.value = number(params.cellAspect, 1, 0.25, 4) + this.irregularity.value = number(params.irregularity, 0.35, 0, 1) + this.seed.value = number(params.seed, 1, 0, 1000) + this.selection.value = 0 + if (params.selection === "dark") this.selection.value = 1 + if (params.selection === "random") this.selection.value = 2 + this.threshold.value = number(params.threshold, 0.35, 0, 1) + this.invert.value = params.invert === true ? 1 : 0 + this.gap.value = number(params.gap, 0.08, 0, 1) + this.softness.value = number(params.softness, 0, 0, 0.025) + this.outline.value = number(params.outline, 0, 0, 0.5) + ;(this.outlineColor.value as THREE.Color).set( + typeof params.outlineColor === "string" ? params.outlineColor : "#e8e5dc" + ) + this.cutout.value = params.output === "keep-image" ? 0 : 1 + } + + protected override buildEffectNode(): Node { + if (!this.source) return this.inputNode + return Fn(() => { + const hash = (value: Node) => + fract( + sin(dot(value, vec2(127.1, 311.7)).add(this.seed.mul(74.7))).mul( + 43758.5453 + ) + ) + // Automatic and painted selection share the same geometry and outline. + const sampleTone = (position: Node) => { + const inset = vec2(0.5).div(this.resolution) + const probe = this.source + .sample( + clamp(position.div(this.aspect).add(0.5), inset, vec2(1).sub(inset)) + ) + .level(0) + return clamp(dot(probe.rgb, vec3(0.2126, 0.7152, 0.0722)), 0, 1) + } + const rowGeometry = (row: Node) => { + const width = this.size + .mul(this.cellAspect) + .mul(mix(1, hash(vec2(row, 17)).mul(0.8).add(0.6), this.irregularity)) + const shift = hash(vec2(row, 53)) + .sub(0.5) + .mul(width) + .mul(this.irregularity) + return { width, shift } + } + const cellAt = Fn(([id]: [Node]) => { + const { width, shift } = rowGeometry(id.y) + const center = vec2( + id.x.add(0.5).mul(width).sub(shift), + id.y.add(0.5).mul(this.size) + ) + // Jitter only where selection is read, never the cell geometry or photo. + // A bounded lookup (at most 1.5 cell widths/heights per axis) fragments + // boundaries while leaving the interior intact. No extra source samples, + // neighborhood search or time dependence; zero keeps the legacy path. + const selectionPoint = center.toVar() + If( + this.edgeScatter + .greaterThan(0) + .and( + this.regions.greaterThan(0.5).or(this.painted.greaterThan(0.5)) + ), + () => { + selectionPoint.addAssign( + vec2(hash(id.add(vec2(71, 193))), hash(id.add(vec2(137, 47)))) + .sub(0.5) + .mul(vec2(width, this.size)) + .mul(this.edgeScatter.mul(3)) + ) + } + ) + const score = float(0).toVar() + If(this.painted.greaterThan(0.5), () => { + const paintUV = selectionPoint.div(this.paintAspect).add(0.5) + const inside = paintUV.x + .greaterThanEqual(0) + .and(paintUV.x.lessThan(1)) + .and(paintUV.y.greaterThanEqual(0)) + .and(paintUV.y.lessThan(1)) + score.assign( + select( + inside, + this.paintSource.sample(paintUV).level(0).r, + float(0) + ) + ) + }).Else(() => { + If(this.regions.greaterThan(0.5), () => { + // Smooth a field in composition space, then quantize only its boundary + // into cells. Region Size never changes the photographic samples inside. + const field = selectionPoint.div(this.regionSize) + const base = floor(field) + const fraction = fract(field) + const blend = fraction + .mul(fraction) + .mul(float(3).sub(fraction.mul(2))) + const values = vec4(0).toVar() + If(this.selection.greaterThan(1.5), () => { + values.assign( + vec4( + hash(base), + hash(base.add(vec2(1, 0))), + hash(base.add(vec2(0, 1))), + hash(base.add(1)) + ) + ) + }).Else(() => { + values.assign( + vec4( + sampleTone(base.mul(this.regionSize)), + sampleTone(base.add(vec2(1, 0)).mul(this.regionSize)), + sampleTone(base.add(vec2(0, 1)).mul(this.regionSize)), + sampleTone(base.add(1).mul(this.regionSize)) + ) + ) + If(this.selection.greaterThan(0.5), () => { + values.assign(float(1).sub(values)) + }) + }) + score.assign( + mix( + mix(values.x, values.y, blend.x), + mix(values.z, values.w, blend.x), + blend.y + ) + ) + }).Else(() => { + If(this.selection.greaterThan(1.5), () => { + score.assign(hash(id)) + }).Else(() => { + const luma = sampleTone(center) + score.assign( + select( + this.selection.greaterThan(0.5), + float(1).sub(luma), + luma + ) + ) + }) + }) + }) + const automatic = select( + this.threshold.lessThanEqual(0), + float(1), + select( + this.threshold.greaterThanEqual(1), + float(0), + step(this.threshold, score) + ) + ) + const chosen = select( + this.painted.greaterThan(0.5), + step(0.5, score), + automatic + ) + const selected = select( + this.invert.greaterThan(0.5), + float(1).sub(chosen), + chosen + ) + return vec4(center, width, selected) + }).setLayout({ + name: "photographicCell", + type: "vec4", + inputs: [{ name: "id", type: "vec2" }], + }) + const screen = vec2(uv().x, float(1).sub(uv().y)) + const point = screen.sub(0.5).mul(this.aspect) + const row = floor(point.y.div(this.size)) + const geometry = rowGeometry(row) + const column = floor(point.x.add(geometry.shift).div(geometry.width)) + const current = cellAt(vec2(column, row)).toVar() + const center = current.xy + const width = current.z + const selected = current.w + const cell = vec2(width, this.size) + const local = abs(point.sub(center)) + const half = cell.mul(float(1).sub(this.gap)).mul(0.5) + const distance = max(local.x.sub(half.x), local.y.sub(half.y)) + const pixel = max( + this.aspect.x.div(this.resolution.x), + this.aspect.y.div(this.resolution.y) + ) + const edge = max(pixel.mul(0.75), this.softness) + const coverage = select( + this.gap.equal(0), + float(1), + float(1).sub(smoothstep(edge.negate(), edge, distance)) + ).mul(select(this.gap.greaterThanEqual(1), float(0), float(1))) + const mask = coverage.mul(selected) + const strokeWidth = min(width, this.size).mul(this.outline) + const outlineDistance = distance.toVar() + If( + this.outlineMode + .equal(1) + .and(this.gap.equal(0)) + .and(this.outline.greaterThan(0)), + () => { + // Distance to the complement of the selected union, rather than to each + // cell edge. Search neighboring rows by their own widths/staggers so + // T-junctions and narrow cells do not leave internal seams. + const boundary = min( + this.aspect.x.mul(0.5).sub(abs(point.x)), + this.aspect.y.mul(0.5).sub(abs(point.y)) + ).toVar() + // Only pixels close enough to a cell edge can carry a perimeter. + // Bound neighbor queries by the stroke reach before sampling the source. + const reach = strokeWidth.add(edge) + If( + selected + .greaterThan(0.5) + .and(distance.greaterThanEqual(reach.negate())), + () => { + const rows = min(floor(reach.div(this.size)).add(1), 2) + Loop( + { start: 0, end: int(rows.mul(2).add(1)), type: "int" }, + ({ i }) => { + const neighborRow = row.add(float(i).sub(rows)) + const adjacent = rowGeometry(neighborRow) + const nearestColumn = floor( + point.x.add(adjacent.shift).div(adjacent.width) + ) + const columns = min( + floor(reach.div(adjacent.width)).add(1), + 2 + ) + Loop( + { + start: 0, + end: int(columns.mul(2).add(1)), + type: "int", + name: "j", + }, + ({ j }) => { + const neighborColumn = nearestColumn.add( + float(j).sub(columns) + ) + const neighborCenter = vec2( + neighborColumn + .add(0.5) + .mul(adjacent.width) + .sub(adjacent.shift), + neighborRow.add(0.5).mul(this.size) + ) + const outside = length( + max( + abs(point.sub(neighborCenter)).sub( + vec2(adjacent.width, this.size).mul(0.5) + ), + vec2(0) + ) + ) + If( + outside.lessThan(boundary).and(outside.lessThan(reach)), + () => { + const neighbor = cellAt( + vec2(neighborColumn, neighborRow) + ) + If(neighbor.w.lessThan(0.5), () => { + boundary.assign(outside) + }) + } + ) + } + ) + } + ) + } + ) + outlineDistance.assign(boundary.negate()) + } + ) + const interior = float(1).sub( + smoothstep(edge.negate(), edge, outlineDistance.add(strokeWidth)) + ) + // Subtract the inset fill from the outer coverage. Multiplying two edge + // fades adds extra ink when a gap opens, especially for thin outlines. + const strokeCoverage = max(coverage.sub(interior), 0).mul( + select( + this.outline.greaterThan(0).and(this.outlineMode.greaterThan(0)), + float(1), + float(0) + ) + ) + // RGB is straight-alpha: normalize here because mask applies coverage below. + const stroke = strokeCoverage.div(max(coverage, 0.000001)) + const original = this.inputNode + const rgb = mix(original.rgb, this.outlineColor, stroke) + // Outlines never manufacture coverage in transparent parts of the source. + return select( + this.cutout.greaterThan(0.5), + vec4(rgb, original.a.mul(max(mask, this.paintGuide.mul(this.painted)))), + vec4(mix(original.rgb, rgb, mask), original.a) + ) + })() + } + + override dispose(): void { + this.paintTexture.dispose() + this.placeholder.dispose() + super.dispose() + } +} diff --git a/packages/shader-lab-react/src/renderer/pipeline-manager.ts b/packages/shader-lab-react/src/renderer/pipeline-manager.ts index ca6886b3..63797471 100644 --- a/packages/shader-lab-react/src/renderer/pipeline-manager.ts +++ b/packages/shader-lab-react/src/renderer/pipeline-manager.ts @@ -1,3 +1,11 @@ +import type { RenderableLayerConfig } from "./contracts" +import { + type CompositionNode, + flattenComposition, + isCompositionGroup, +} from "./composition-tree" +import { GroupPass } from "./group-pass" +import { layerMaskSignature, normalizeLayerMask } from "./layer-mask" 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,10 +37,13 @@ import { PixelTrailPass } from "./pixel-trail-pass" import { PixelationPass } from "./pixelation-pass" import { PlotterPass } from "./plotter-pass" import { PosterizePass } from "./posterize-pass" +import { PhotographicCellsPass } from "./photographic-cells-pass" +import { DisplacedRingsPass } from "./displaced-rings-pass" import { SlicePass } from "./slice-pass" import { SmearPass } from "./smear-pass" import { TextPass } from "./text-pass" import { ThresholdPass } from "./threshold-pass" +import { GradientMapPass } from "./gradient-map-pass" import { VoxelPass } from "./voxel-pass" type LayerPassNode = @@ -66,6 +77,7 @@ type LayerPassNode = | SlicePass | SmearPass | ThresholdPass + | GradientMapPass | TextPass | VoxelPass @@ -108,6 +120,7 @@ function createLayerSignature(layer: ShaderLabLayerConfig): string { layer.maskConfig?.source ?? "luminance", layer.maskConfig?.mode ?? "multiply", layer.maskConfig?.invert ? "1" : "0", + layerMaskSignature(normalizeLayerMask(layer.mask)), typeof layer.params.sourceRevision === "number" ? String(layer.params.sourceRevision) : "0", @@ -139,6 +152,7 @@ function createLayerSignature(layer: ShaderLabLayerConfig): string { layer.maskConfig?.source ?? "luminance", layer.maskConfig?.mode ?? "multiply", layer.maskConfig?.invert ? "1" : "0", + layerMaskSignature(normalizeLayerMask(layer.mask)), parameterValuesSignature(layer.params), ].join("|") } @@ -204,6 +218,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 +228,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 +247,77 @@ 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, + layerMaskSignature(normalizeLayerMask(node.mask)), + ]) + : 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) + pass.updateMaskLogicalSize(this.logicalWidth, this.logicalHeight) + 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.updateLayerMask(normalizeLayerMask(node.mask)) + 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 +329,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 +352,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 +370,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 +399,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 @@ -392,6 +444,7 @@ export class PipelineManager { for (const pass of this.passMap.values()) { pass.updateLogicalSize(this.logicalWidth, this.logicalHeight) + pass.updateMaskLogicalSize(this.logicalWidth, this.logicalHeight) } this.dirty = true @@ -413,6 +466,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 +500,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,12 +516,26 @@ export class PipelineManager { layer: ShaderLabLayerConfig ): void { pass.enabled = layer.visible + if ( + layer.type === "displaced-rings" || + layer.type === "photographic-cells" + ) { + 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 = layer.compositeMode === "mask" ? "mask" : "filter" pass.updateCompositeMode(compositeMode) pass.updateMaskConfig(layer.maskConfig ?? DEFAULT_MASK_CONFIG) + pass.updateLayerMask(normalizeLayerMask(layer.mask)) pass.updateLayerColorAdjustments(layer.hue, layer.saturation) pass.updateParams(layer.params) pass.flushColorNode() @@ -540,8 +629,14 @@ export class PipelineManager { return new PosterizePass(layer.id) case "threshold": return new ThresholdPass(layer.id) + case "gradient-map": + return new GradientMapPass(layer.id) case "pixel-sorting": return new PixelSortingPass(layer.id) + case "photographic-cells": + return new PhotographicCellsPass(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/renderer/threshold-pass.ts b/packages/shader-lab-react/src/renderer/threshold-pass.ts index 7aa39608..5a110cdc 100644 --- a/packages/shader-lab-react/src/renderer/threshold-pass.ts +++ b/packages/shader-lab-react/src/renderer/threshold-pass.ts @@ -1,8 +1,10 @@ +import { Color } from "three/webgpu" import { clamp, dot, float, fract, + mix, select, sin, smoothstep, @@ -28,6 +30,8 @@ export class ThresholdPass extends PassNode { private readonly thresholdUniform: Node private readonly softnessUniform: Node private readonly noiseUniform: Node + private readonly darkColorUniform: Node + private readonly lightColorUniform: Node private readonly invertUniform: Node private readonly logicalWidthUniform: Node private readonly logicalHeightUniform: Node @@ -38,6 +42,8 @@ export class ThresholdPass extends PassNode { this.softnessUniform = uniform(0.02) this.noiseUniform = uniform(0.08) this.invertUniform = uniform(0) + this.darkColorUniform = uniform(new Color("#000000")) + this.lightColorUniform = uniform(new Color("#ffffff")) this.logicalWidthUniform = uniform(1) this.logicalHeightUniform = uniform(1) this.rebuildEffectNode() @@ -62,6 +68,12 @@ export class ThresholdPass extends PassNode { ? Math.max(0, Math.min(0.3, params.noise)) : 0.08 this.invertUniform.value = params.invert === true ? 1 : 0 + ;(this.darkColorUniform.value as Color).set( + typeof params.darkColor === "string" ? params.darkColor : "#000000" + ) + ;(this.lightColorUniform.value as Color).set( + typeof params.lightColor === "string" ? params.lightColor : "#ffffff" + ) } protected override buildEffectNode(): Node { @@ -92,6 +104,11 @@ export class ThresholdPass extends PassNode { const isInverted = this.invertUniform.greaterThan(float(0.5)) const output = select(isInverted, float(1).sub(thresholded), thresholded) - return vec4(vec3(output, output, output), float(1)) + // Recolor the existing threshold field in linear space, including its soft + // transitions and noise. Effect composition preserves the source coverage. + return vec4( + mix(this.darkColorUniform, this.lightColorUniform, output), + float(1) + ) } } diff --git a/packages/shader-lab-react/src/types.ts b/packages/shader-lab-react/src/types.ts index 99c0a9b8..11f4fff0 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,8 @@ export type ShaderLabSourceLayerType = | "video" export type ShaderLabEffectLayerType = + | "photographic-cells" + | "displaced-rings" | "ascii" | "blob-tracking" | "bloom" @@ -41,9 +43,11 @@ export type ShaderLabEffectLayerType = | "slice" | "smear" | "threshold" + | "gradient-map" | "voxel" export type ShaderLabLayerType = + | "group" | ShaderLabEffectLayerType | ShaderLabSourceLayerType @@ -81,6 +85,27 @@ export interface ShaderLabMaskConfig { source: ShaderLabMaskSource } +export type ShaderLabLayerMaskShape = + | "none" + | "linear" + | "radial" + | "ellipse" + | "rectangle" + | "brush" +export type ShaderLabLayerMaskScope = "effect" | "content" + +export interface ShaderLabLayerMask { + shape: ShaderLabLayerMaskShape + scope?: ShaderLabLayerMaskScope + enabled?: boolean + invert?: boolean + center?: [number, number] + size?: [number, number] + rotation?: number + feather?: number + paint?: string +} + export type ShaderLabAssetSource = | { fileName?: string @@ -156,10 +181,13 @@ 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 maskConfig?: ShaderLabMaskConfig + mask?: ShaderLabLayerMask | null hue: number id: string kind: ShaderLabLayerKind 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/public/examples/gradient-map.webp b/public/examples/gradient-map.webp new file mode 100644 index 00000000..c8d8e709 Binary files /dev/null and b/public/examples/gradient-map.webp differ diff --git a/public/examples/photographic-cells.webp b/public/examples/photographic-cells.webp new file mode 100644 index 00000000..e23d2d11 Binary files /dev/null and b/public/examples/photographic-cells.webp differ diff --git a/public/examples/v3/README.md b/public/examples/v3/README.md new file mode 100644 index 00000000..4e093e62 --- /dev/null +++ b/public/examples/v3/README.md @@ -0,0 +1,43 @@ +# Editable V3 studies + +Two starting points for the editorial direction in [V3 visual references](../../../V3-VISUAL-REFERENCES.md). These are original compositions using existing app media, procedural layers, editable text, and original SVG linework; the user's reference images are not bundled. + +Open the editor, choose **Export → Project → Import .lab**, and select a study. Save your current project first: importing replaces the scene. On the local dev server, download [Color Field](http://localhost:55000/examples/v3/color-field.lab) or [Painted Flora](http://localhost:55000/examples/v3/painted-flora.lab). The files also work on the deployed branch that includes their bundled assets. The preview PNGs were rendered at 720 × 960. The current editor canvas follows the window size, so importing into a wider viewport changes the framing; these are editable studies, not fixed-size poster templates. + +## Color Field + +[Open project file](color-field.lab) + +![Color regions, stepped fragments, fine dots and sparse type on white](color-field.png) + +The layer order inside **Color / fine dots**, from top to bottom, is: + +1. **Stepped regions** — Photographic Cells: Regions, Random, Cell Size 0.03, Region Size 0.4, Edge Scatter 0.65, no outline. +2. **Fine source-color dots** — existing Halftone: Source, Spacing 3, Dot Size 0.8, Dot Min 0.45, Angle 45°. +3. **Blue / ochre / violet** — existing Gradient: five editable color points, modest distortion, animation off. + +Cells cuts the already dotted color field. At 720px width the cells are about 22px wide, so several dots fit inside each cell. The group isolates the effects; text and registration marks remain outside. Source mode uses one dot grid; CMYK uses four and is more expensive. No new halftone controls or extra colorization pass were needed. + +Try **Edge Scatter 0 → 0.65 → 1**. Small notches and detached cells should appear near the boundary while the broad patches remain. **Seed** changes both the automatic regions and their edge pattern. **Cell Size** changes the fragment scale; **Region Size** changes the broad patches. Turn on Gradient's **Animate** for a moving color source inside a fixed Random selection. + +## Painted Flora + +[Open project file](painted-flora.lab) + +![Full-detail painted photographic silhouette with pink perimeter and linework](painted-flora.png) + +**Painted specimen** contains Cells above the photo. The saved Paint mask reveals a broad diagonal specimen and a detached patch; a fine pink Perimeter follows the result. Text and annotation linework stay outside the group. The photo is the app's existing `slice.webp`, copied into its allowed bundled-media directory so import does not require relinking. + +Select **Painted photographic reveal → Edit Paint** to reveal or erase areas. **Done** removes the temporary source guide. Try Edge Scatter at 0 and 0.5: it modifies the edge without overwriting your strokes. The saved example uses 0.25. Photograph pixels stay in their original positions inside each selected cell. + +Text, colors, coverage, source, and layer order remain editable. The technical marks are separate SVG image layers: move, fade, hide, or replace them in the editor; edit their paths in the SVG files for different linework. They are not a new vector drawing tool. + +## Scope and performance + +These studies demonstrate broad color patches, finer dots, small edge fragments, photographic interiors, a thin colored perimeter, white space, and separate type/linework from references 05 and 11. They are not reproductions or final approval of the entire V3 visual direction. Paper distress, denser annotations, and finer typography remain further work; the existing 48px text minimum still limits small labels at this canvas size. + +Scatter only shifts the point used to read selection, by at most 1.5 cell widths/heights per axis at maximum. Geometry and photographic sampling stay unchanged. It adds arithmetic, no extra source samples, full-screen pass, render target, or readback. Zero is the default and restores earlier behavior. Individual Cells ignores Scatter. Very narrow painted details can fragment at high amounts; reduce Scatter or Cell Size to retain them. Random/Paint remain fixed while video plays; Light/Dark regions react to video tones and can change cells. + +The studies are static by default. A short decoded-video software benchmark found similar 1080p pass medians with Scatter off/on (Random 60/60.8ms, Light 91.7/90.4ms, Paint 56.6/56.6ms). This is not native GPU FPS, sustained load, or a thermal diagnosis. Timing includes video upload and queue completion, excludes waiting for the next frame, and uses two warm-up plus five timed frames. Native hardware, full effect stacks, and encoded video export remain open in roadmap 7.2. + +Rebuild the project files with `bun scripts/generate-editorial-studies.ts`. The PNGs are actual editor-renderer exports, not mockups. Browser QA: `bun tests/composition/editorial-studies-ui.mjs` with the dev server running. Bounded video comparison: `bun tests/composition/effect-video-performance.mjs --scatter`. diff --git a/public/examples/v3/color-field.lab b/public/examples/v3/color-field.lab new file mode 100644 index 00000000..0e0da88b --- /dev/null +++ b/public/examples/v3/color-field.lab @@ -0,0 +1,450 @@ +{ + "format": "shader-lab", + "version": 7, + "composition": { + "width": 720, + "height": 960 + }, + "selectedLayerId": "cells", + "sceneConfig": { + "backgroundColor": "#ffffff", + "compositionAspect": "custom", + "compositionWidth": 720, + "compositionHeight": 960, + "exposure": 0, + "brightness": 0, + "contrast": 0, + "saturation": 1, + "vibrance": 0, + "hue": 0, + "temperature": 0, + "tint": 0, + "invert": false, + "channelMixer": { + "rr": 1, + "rg": 0, + "rb": 0, + "gr": 0, + "gg": 1, + "gb": 0, + "br": 0, + "bg": 0, + "bb": 1 + }, + "colorCurves": { + "blue": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + }, + "green": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + }, + "red": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + }, + "rgb": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + } + }, + "clampMin": 0, + "clampGamma": 1, + "clampMax": 1, + "quantizeEnabled": false, + "quantizeLevels": 256, + "colorMap": null + }, + "timeline": { + "duration": 5, + "loop": true, + "tracks": [] + }, + "assets": [ + { + "id": "registration", + "kind": "image", + "url": "/scenes/default/editorial/registration.svg", + "fileName": "registration.svg", + "mimeType": "image/svg+xml", + "width": 720, + "height": 960 + } + ], + "layers": [ + { + "assetId": null, + "blendMode": "normal", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "title", + "kind": "source", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "FIELD", + "opacity": 1, + "params": { + "text": "FIELD", + "anchor": "top-left", + "offset": [ + 0.55, + -0.12 + ], + "fontSize": 48, + "fontFamily": "mono", + "fontWeight": 400, + "letterSpacing": -0.04, + "textColor": "#202020", + "backgroundColor": "#000000", + "backgroundAlpha": 0 + }, + "runtimeError": null, + "saturation": 1, + "type": "text", + "visible": true + }, + { + "assetId": null, + "blendMode": "normal", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "subtitle", + "kind": "source", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "NOTES", + "opacity": 1, + "params": { + "text": "NOTES", + "anchor": "top-left", + "offset": [ + 0.55, + -0.18 + ], + "fontSize": 48, + "fontFamily": "mono", + "fontWeight": 400, + "letterSpacing": -0.04, + "textColor": "#202020", + "backgroundColor": "#000000", + "backgroundAlpha": 0 + }, + "runtimeError": null, + "saturation": 1, + "type": "text", + "visible": true + }, + { + "assetId": null, + "blendMode": "normal", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "edition", + "kind": "source", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "01 /", + "opacity": 1, + "params": { + "text": "01 /", + "anchor": "top-left", + "offset": [ + 0.72, + -0.89 + ], + "fontSize": 48, + "fontFamily": "mono", + "fontWeight": 400, + "letterSpacing": -0.04, + "textColor": "#202020", + "backgroundColor": "#000000", + "backgroundAlpha": 0 + }, + "runtimeError": null, + "saturation": 1, + "type": "text", + "visible": true + }, + { + "assetId": "registration", + "blendMode": "normal", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "marks", + "kind": "source", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Registration marks", + "opacity": 0.35, + "params": { + "fitMode": "contain", + "transparentBounds": true, + "scale": 1, + "offset": [ + 0, + 0 + ], + "svgRasterResolution": "2048" + }, + "runtimeError": null, + "saturation": 1, + "type": "image", + "visible": true + }, + { + "assetId": null, + "blendMode": "normal", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "study", + "kind": "group", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Color / fine dots", + "opacity": 1, + "params": {}, + "runtimeError": null, + "saturation": 1, + "type": "group", + "visible": true + }, + { + "assetId": null, + "blendMode": "normal", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "cells", + "kind": "effect", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Stepped regions", + "opacity": 1, + "params": { + "paintMask": "", + "output": "cutout", + "mode": "regions", + "regionSize": 0.4, + "selection": "random", + "threshold": 0.48, + "invert": false, + "size": 0.03, + "cellAspect": 1, + "irregularity": 0, + "edgeScatter": 0.65, + "seed": 11, + "gap": 0, + "softness": 0, + "outlineMode": "none", + "outline": 0.025, + "outlineColor": "#b53387" + }, + "runtimeError": null, + "saturation": 1, + "type": "photographic-cells", + "visible": true, + "parentId": "study" + }, + { + "assetId": null, + "blendMode": "normal", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "dots", + "kind": "effect", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Fine source-color dots", + "opacity": 1, + "params": { + "colorMode": "source", + "shape": "circle", + "spacing": 3, + "dotSize": 0.8, + "dotMin": 0.45, + "angle": 45, + "contrast": 1.2, + "softness": 0.25, + "dotGain": 0, + "dotMorph": 0, + "invertLuma": false, + "customColorCount": 4, + "customLuminanceBias": 0, + "bloomEnabled": false, + "bloomIntensity": 1.25, + "bloomThreshold": 0.6, + "bloomRadius": 6, + "bloomSoftness": 0.35, + "customBgColor": "#F5F5F0", + "customColor1": "#0d1014", + "customColor2": "#4d5057", + "customColor3": "#969aa2", + "customColor4": "#e1e2de", + "ink": "#0d1014", + "duotoneLight": "#F5F5F0", + "duotoneDark": "#1d1d1c", + "preset": "process", + "gcr": 0.5, + "cmykBlend": "subtractive", + "cyanAngle": 15, + "magentaAngle": 75, + "yellowAngle": 0, + "keyAngle": 45, + "paperColor": "#F5F5F0", + "paperGrain": 0, + "registration": 0, + "inkCyan": "#00AEEF", + "inkMagenta": "#EC008C", + "inkYellow": "#FFF200", + "inkKey": "#1a1a1a" + }, + "runtimeError": null, + "saturation": 1, + "type": "halftone", + "visible": true, + "parentId": "study" + }, + { + "assetId": null, + "blendMode": "normal", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "color", + "kind": "source", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Blue / ochre / violet", + "opacity": 1, + "params": { + "preset": "custom", + "activePoints": 5, + "point1Color": "#e9a019", + "point1Position": [ + -0.5, + -0.55 + ], + "point1Weight": 1, + "point2Color": "#397bca", + "point2Position": [ + 0.55, + -0.4 + ], + "point2Weight": 1, + "point3Color": "#008f97", + "point3Position": [ + -0.5, + 0.65 + ], + "point3Weight": 1, + "point4Color": "#5534ac", + "point4Position": [ + 0.05, + 0.1 + ], + "point4Weight": 0.7, + "point5Color": "#f4d545", + "point5Position": [ + 0.6, + 0.65 + ], + "point5Weight": 1, + "noiseType": "simplex", + "noiseSeed": 12, + "warpAmount": 0.5, + "warpScale": 2, + "warpIterations": 1, + "warpDecay": 1, + "warpBias": 0.5, + "vortexAmount": 0.4, + "animate": false, + "motionAmount": 0, + "motionSpeed": 0.4, + "falloff": 3.8, + "tonemapMode": "none", + "glowStrength": 0, + "glowThreshold": 0, + "grainAmount": 0, + "vignetteStrength": 0, + "vignetteRadius": 1.5, + "vignetteSoftness": 1 + }, + "runtimeError": null, + "saturation": 1, + "type": "gradient", + "visible": true, + "parentId": "study" + } + ] +} diff --git a/public/examples/v3/color-field.png b/public/examples/v3/color-field.png new file mode 100644 index 00000000..2bc7d999 Binary files /dev/null and b/public/examples/v3/color-field.png differ diff --git a/public/examples/v3/painted-flora.lab b/public/examples/v3/painted-flora.lab new file mode 100644 index 00000000..6764069b --- /dev/null +++ b/public/examples/v3/painted-flora.lab @@ -0,0 +1,316 @@ +{ + "format": "shader-lab", + "version": 7, + "composition": { + "width": 720, + "height": 960 + }, + "selectedLayerId": "cells", + "sceneConfig": { + "backgroundColor": "#ffffff", + "compositionAspect": "custom", + "compositionWidth": 720, + "compositionHeight": 960, + "exposure": 0, + "brightness": 0, + "contrast": 0, + "saturation": 1, + "vibrance": 0, + "hue": 0, + "temperature": 0, + "tint": 0, + "invert": false, + "channelMixer": { + "rr": 1, + "rg": 0, + "rb": 0, + "gr": 0, + "gg": 1, + "gb": 0, + "br": 0, + "bg": 0, + "bb": 1 + }, + "colorCurves": { + "blue": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + }, + "green": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + }, + "red": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + }, + "rgb": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + } + }, + "clampMin": 0, + "clampGamma": 1, + "clampMax": 1, + "quantizeEnabled": false, + "quantizeLevels": 256, + "colorMap": null + }, + "timeline": { + "duration": 5, + "loop": true, + "tracks": [] + }, + "assets": [ + { + "id": "annotations", + "kind": "image", + "url": "/scenes/default/editorial/specimen-lines.svg", + "fileName": "specimen-lines.svg", + "mimeType": "image/svg+xml", + "width": 720, + "height": 960 + }, + { + "id": "flora", + "kind": "image", + "url": "/scenes/default/editorial/flora.webp", + "fileName": "flora.webp", + "mimeType": "image/webp", + "width": 1512, + "height": 909 + } + ], + "layers": [ + { + "assetId": null, + "blendMode": "normal", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "title", + "kind": "source", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "FIELD / 02", + "opacity": 1, + "params": { + "text": "FIELD / 02", + "anchor": "top-left", + "offset": [ + 0.06, + -0.055 + ], + "fontSize": 48, + "fontFamily": "mono", + "fontWeight": 400, + "letterSpacing": -0.04, + "textColor": "#aa3676", + "backgroundColor": "#000000", + "backgroundAlpha": 0 + }, + "runtimeError": null, + "saturation": 1, + "type": "text", + "visible": true + }, + { + "assetId": null, + "blendMode": "normal", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "subtitle", + "kind": "source", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "flora", + "opacity": 1, + "params": { + "text": "flora", + "anchor": "top-left", + "offset": [ + 0.06, + -0.89 + ], + "fontSize": 48, + "fontFamily": "mono", + "fontWeight": 400, + "letterSpacing": -0.04, + "textColor": "#aa3676", + "backgroundColor": "#000000", + "backgroundAlpha": 0 + }, + "runtimeError": null, + "saturation": 1, + "type": "text", + "visible": true + }, + { + "assetId": "annotations", + "blendMode": "normal", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "marks", + "kind": "source", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Specimen linework", + "opacity": 0.6, + "params": { + "fitMode": "contain", + "transparentBounds": true, + "scale": 1, + "offset": [ + 0, + 0 + ], + "svgRasterResolution": "2048" + }, + "runtimeError": null, + "saturation": 1, + "type": "image", + "visible": true + }, + { + "assetId": null, + "blendMode": "normal", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "study", + "kind": "group", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Painted specimen", + "opacity": 1, + "params": {}, + "runtimeError": null, + "saturation": 1, + "type": "group", + "visible": true + }, + { + "assetId": null, + "blendMode": "normal", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "cells", + "kind": "effect", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Painted photographic reveal", + "opacity": 1, + "params": { + "paintMask": "pc1:1:1.3333333333333333:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAID//x8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////HwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD4//////8DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACA////////PwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+P////////8BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//////////DwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOD//////////38AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD4////////////AwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////////////w8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwP////////////8/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPD//////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD8//////////////8DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/v//////////////DwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgP///////////////z8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOD///////////////9/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADw/////////////////wEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/P////////////////8DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP7/////////////////DwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//////////////////x8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADA//////////////////8/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4P//////////////////fwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPD///////////////////8BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD4////////////////////AwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/P///////////////////wcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP7///////////////////8PAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////////////////////HwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACA/////////////////////z8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwP////////////////////9/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOD//////////////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADw//////////////////////8BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+P//////////////////////AwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPj//////////////////////wMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD8//////////////////////8HAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/v//////////////////////DwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP7//////////////////////x8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///////////////////////8/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACA////////////////////////fwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgP////////////////////////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMD/////////////////////////AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADA/////////////////////////wMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4P////////////////////////8HAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOD/////////////////////////DwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADw/////////////////////////x8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8P////////////////////////8/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPj/////////////////////////fwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD4//////////////////////////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+P//////////////////////////AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPz//////////////////////////wMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD8//////////////////////////8HAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/P//////////////////////////DwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPz//////////////////////////x8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD+//////////////////////////8/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/v//////////////////////////PwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP7//////////////////////////38AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD+////////////////////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/v///////////////////////////wEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP7///////////////////////////8DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////////////////////////////BwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////////////////////////////w8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP////////////////////////////8fAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////////////////////////////PwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////////////////////////////38AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//////////////////////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//////////////////////////////wEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//////////////////////////////8DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP7/////////////////////////////BwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD+/////////////////////////////w8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/v////////////////////////////8fAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP7/////////////////////////////PwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD+/////////////////////////////38AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/v//////////////////////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP7//////////////////////////////wEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD8//////////////////////////////8DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/P//////////////////////////////AwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPz//////////////////////////////wcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD4//////////////////////////////8PAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+P//////////////////////////////HwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPj//////////////////////////////z8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADw//////////////////////////////9/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8P///////////////////////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPD///////////////////////////////8BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADg////////////////////////////////DwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4P///////////////////////////////z8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMD/////////////////////////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADA/////////////////////////////////wMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgP////////////////////////////////8PAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAID/////////////////////////////////PwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//////////////////////////////////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP7/////////////////////////////////AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD+/////////////////////////////////wcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/P////////////////////////////////8fAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPj/////////////////////////////////PwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADw/////////////////////////////////38AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8P//////////////////////////////////AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOD//////////////////////////////////wMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADA//////////////////////////////////8HAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgP//////////////////////////////////DwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//////////////////////////////////z8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/v////////////////////////////////9/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPz//////////////////////////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD4//////////////////////////////////8BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8P//////////////////////////////////AwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOD//////////////////////////////////wcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADA//////////////////////////////////8PAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgP//////////////////////////////////DwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//////////////////////////////////x8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/v////////////////////////////////8/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPz/////////////////////////////////fwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD8//////////////////////////////////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+P//////////////////////////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPD//////////////////////////////////wEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADg//////////////////////////////////8DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwP//////////////////////////////////AwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAID//////////////////////////////////wcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//////////////////////////////////8PAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP7/////////////////////////////////DwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD8/////////////////////////////////x8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+P////////////////////////////////8fAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPD/////////////////////////////////PwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADg/////////////////////////////////z8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwP////////////////////////////////9/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAID/////////////////////////////////fwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////////////////////////////////38AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP7/////////////////////////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD8/////////////////////////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+P////////////////////////////////8BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPD/////////////////////////////////AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADg/////////////////////////////////wEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwP////////////////////////////////8BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMD/////////////////////////////////AwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACA/////////////////////////////////wMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP////////////////////////////////8DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD+////////////////////////////////AwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/P///////////////////////////////wcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPj///////////////////////////////8HAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADw////////////////////////////////BwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4P///////////////////////////////wcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMD///////////////////////////////8HAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACA////////////////////////////////BwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///////////////////////////////wcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD+//////////////////////////////8HAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/P//////////////////////////////BwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPj//////////////////////////////wcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADw//////////////////////////////8HAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4P//////////////////////////////BwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMD//////////////////////////////wcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACA//////////////////////////////8HAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//////////////////////////////BwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//////////////////////////////wcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//////////////////////////////8HAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//////////////////////////////BwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//////////////////////////////wMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACA//////////////////////////////8DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgP//////////////////////////////AwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAID//////////////////////////////wMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACA//////////////////////////////8BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgP//////////////////////////////AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMD//////////////////////////////wEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADA//////////////////////////////8BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwP//////////////////////////////AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMD//////////////////////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADg//////////////////////////////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4P//////////////////////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOD//////////////////////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADg//////////////////////////////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4P////////////////////////////9/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPD/////////////////////////////fwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADw/////////////////////////////38AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8P////////////////////////////9/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPD/////////////////////////////PwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD4/////////////////////////////z8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+P////////////////////////////8/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPj/////////////////////////////PwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD4/////////////////////////////z8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+P////////////////////////////8fAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPz/////////////////////////////HwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD8/////////////////////////////x8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/P////////////////////////////8fAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPz/////////////////////////////DwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD8/////////////////////////////w8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/v////////////////////////////8PAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP7/////////////////////////////DwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD+/////////////////////////////w8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/v////////////////////////////8HAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//////////////////////////////BwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//////////////////////////////wcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//////////////////////////////8HAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//////////////////////////////BwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//////////////////////////////wMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACA//////////////////////////////8DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgP//////////////////////////////AwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAID//////////////////////////////wMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACA//////////////////////////////8BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwP//////////////////////////////AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMD//////////////////////////////wEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADA//////////////////////////////8BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwP//////////////////////////////AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMD//////////////////////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADg//////////////////////////////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4P//////////////////////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOD//////////////////////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADg/////////////////////////////38AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4P////////////////////////////9/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPD/////////////////////////////fwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADw/////////////////////////////38AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8P////////////////////////////9/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPD/////////////////////////////PwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD4/////////////////////////////z8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+P////////////////////////////8/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPj/////////////////////////////PwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD4/////////////////////////////z8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+P////////////////////////////8fAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPz/////////////////////////////HwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD8/////////////////////////////x8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/P////////////////////////////8fAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPz/////////////////////////////DwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD+/////////////////////////////w8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/v////////////////////////////8PAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP7/////////////////////////////DwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD+/////////////////////////////w8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/v////////////////////////////8HAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//////////////////////////////BwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//////////////////////////////wcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//////////////////////////////8HAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//////////////////////////////BwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//////////////////////////////wMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACA//////////////////////////////8DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgP//////////////////////////////AwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAID//////////////////////////////wMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACA//////////////////////////////8BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwP//////////////////////////////AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMD//////////////////////////////wEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADA//////////////////////////////8BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwP//////////////////////////////AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMD//////////////////////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADg//////////////////////////////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4P//////////////////////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOD//////////////////////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADg/////////////////////////////38AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4P////////////////////////////9/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPD/////////////////////////////fwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADw/////////////////////////////38AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8P////////////////////////////9/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPD/////////////////////////////PwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD4/////////////////////////////z8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+P////////////////////////////8/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPj/////////////////////////////PwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD4/////////////////////////////z8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+P////////////////////////////8fAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPz/////////////////////////////HwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD8/////////////////////////////x8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/P////////////////////////////8fAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPz/////////////////////////////DwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPwHAAAAAAAAAAAAAAAAAAD+/////////////////////////////w8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPj//wMAAAAAAAAAAAAAAAAA/v////////////////////////////8PAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMD///8/AAAAAAAAAAAAAAAAAP7/////////////////////////////DwAAAAAAAAAAAAAAAAAAAAAAAAAAAAD4/////wMAAAAAAAAAAAAAAAD+/////////////////////////////w8AAAAAAAAAAAAAAAAAAAAAAAAAAAAA/v////8PAAAAAAAAAAAAAAAA/v////////////////////////////8HAAAAAAAAAAAAAAAAAAAAAAAAAAAAwP//////fwAAAAAAAAAAAAAAAP//////////////////////////////BwAAAAAAAAAAAAAAAAAAAAAAAAAAAPD///////8BAAAAAAAAAAAAAAD//////////////////////////////wcAAAAAAAAAAAAAAAAAAAAAAAAAAAD8////////AwAAAAAAAAAAAAAA//////////////////////////////8HAAAAAAAAAAAAAAAAAAAAAAAAAAAA/v///////w8AAAAAAAAAAAAAAP//////////////////////////////AwAAAAAAAAAAAAAAAAAAAAAAAAAAgP////////8/AAAAAAAAAAAAAAD//////////////////////////////wMAAAAAAAAAAAAAAAAAAAAAAAAAAMD/////////fwAAAAAAAAAAAACA//////////////////////////////8DAAAAAAAAAAAAAAAAAAAAAAAAAADg//////////8AAAAAAAAAAAAAgP//////////////////////////////AwAAAAAAAAAAAAAAAAAAAAAAAAAA+P//////////AQAAAAAAAAAAAID//////////////////////////////wMAAAAAAAAAAAAAAAAAAAAAAAAAAPz//////////wcAAAAAAAAAAACA//////////////////////////////8BAAAAAAAAAAAAAAAAAAAAAAAAAAD+//////////8PAAAAAAAAAAAAwP//////////////////////////////AQAAAAAAAAAAAAAAAAAAAAAAAAAA////////////HwAAAAAAAAAAAMD//////////////////////////////wEAAAAAAAAAAAAAAAAAAAAAAAAAgP///////////x8AAAAAAAAAAADA//////////////////////////////8BAAAAAAAAAAAAAAAAAAAAAAAAAID///////////8/AAAAAAAAAAAAwP//////////////////////////////AQAAAAAAAAAAAAAAAAAAAAAAAADA////////////fwAAAAAAAAAAAMD//////////////////////////////wAAAAAAAAAAAAAAAAAAAAAAAAAA4P////////////8AAAAAAAAAAADg//////////////////////////////8AAAAAAAAAAAAAAAAAAAAAAAAAAOD/////////////AAAAAAAAAAAA4P//////////////////////////////AAAAAAAAAAAAAAAAAAAAAAAAAADw/////////////wEAAAAAAAAAAPD//////////////////////////////wAAAAAAAAAAAAAAAAAAAAAAAAAA+P////////////8BAAAAAAAAAADw/////////////////////////////38AAAAAAAAAAAAAAAAAAAAAAAAAAPj/////////////AwAAAAAAAAAA+P////////////////////////////9/AAAAAAAAAAAAAAAAAAAAAAAAAAD8/////////////wMAAAAAAAAAAPz/////////////////////////////fwAAAAAAAAAAAAAAAAAAAAAAAAAA/P////////////8HAAAAAAAAAAD8/////////////////////////////38AAAAAAAAAAAAAAAAAAAAAAAAAAPz/////////////BwAAAAAAAAAA/v////////////////////////////9/AAAAAAAAAAAAAAAAAAAAAAAAAAD+/////////////wcAAAAAAAAAAP7/////////////////////////////PwAAAAAAAAAAAAAAAAAAAAAAAAAA/v////////////8PAAAAAAAAAAD///////////////////////////////8AAAAAAAAAAAAAAAAAAAAAAAAAAP7/////////////DwAAAAAAAAAA////////////////////////////////AQAAAAAAAAAAAAAAAAAAAAAAAAD+/////////////w8AAAAAAAAAgP///////////////////////////////wMAAAAAAAAAAAAAAAAAAAAAAAAA/v////////////8PAAAAAAAAAID///////////////////////////////8HAAAAAAAAAAAAAAAAAAAAAAAAAP//////////////HwAAAAAAAADA////////////////////////////////DwAAAAAAAAAAAAAAAAAAAAAAAAD//////////////x8AAAAAAAAAwP///////////////////////////////z8AAAAAAAAAAAAAAAAAAAAAAAAA//////////////8fAAAAAAAAAMD///////////////////////////////9/AAAAAAAAAAAAAAAAAAAAAAAAAP//////////////HwAAAAAAAADg/////////////////////////////////wAAAAAAAAAAAAAAAAAAAAAAAAD//////////////x8AAAAAAAAA4P////////////////////////////////8BAAAAAAAAAAAAAAAAAAAAAAAA//////////////8/AAAAAAAAAPD/////////////////////////////////BwAAAAAAAAAAAAAAAAAAAAAAAP7/////////////PwAAAAAAAADw/////////////////////////////////w8AAAAAAAAAAAAAAAAAAAAAAAD+/////////////z8AAAAAAAAA8P////////////////////////////////8fAAAAAAAAAAAAAAAAAAAAAAAA/v////////////8/AAAAAAAAAPD/////////////////////////////////PwAAAAAAAAAAAAAAAAAAAAAAAP7/////////////PwAAAAAAAAD4/////////////////////////////////38AAAAAAAAAAAAAAAAAAAAAAAD+/////////////38AAAAAAAAA+P//////////////////////////////////AQAAAAAAAAAAAAAAAAAAAAAA/P////////////9/AAAAAAAAAPj//////////////////////////////////wMAAAAAAAAAAAAAAAAAAAAAAPz/////////////fwAAAAAAAAD4//////////////////////////////////8HAAAAAAAAAAAAAAAAAAAAAAD8/////////////38AAAAAAAAA+P//////////////////////////////////DwAAAAAAAAAAAAAAAAAAAAAA/P////////////9/AAAAAAAAAPz//////////////////////////////////x8AAAAAAAAAAAAAAAAAAAAAAPz//////////////wAAAAAAAAD8//////////////////////////////////9/AAAAAAAAAAAAAAAAAAAAAAD4//////////////8AAAAAAAAA/P///////////////////////////////////wAAAAAAAAAAAAAAAAAAAAAA+P//////////////AAAAAAAAAPz///////////////////////////////////8BAAAAAAAAAAAAAAAAAAAAAPj//////////////wAAAAAAAAD8////////////////////////////////////AwAAAAAAAAAAAAAAAAAAAAD4//////////////8AAAAAAAAA/P///////////////////////////////////wcAAAAAAAAAAAAAAAAAAAAA+P//////////////AQAAAAAAAPz///////////////////////////////////8fAAAAAAAAAAAAAAAAAAAAAPD//////////////wEAAAAAAAD8////////////////////////////////////PwAAAAAAAAAAAAAAAAAAAADw//////////////8BAAAAAAAA/P///////////////////////////////////38AAAAAAAAAAAAAAAAAAAAA8P//////////////AQAAAAAAAPz/////////////////////////////////////AAAAAAAAAAAAAAAAAAAAAPD//////////////wEAAAAAAAD8/////////////////////////////////////wMAAAAAAAAAAAAAAAAAAADw//////////////8DAAAAAAAA/P////////////////////////////////////8HAAAAAAAAAAAAAAAAAAAA4P//////////////AwAAAAAAAPz/////////////////////////////////////DwAAAAAAAAAAAAAAAAAAAOD//////////////wMAAAAAAAD8/////////////////////////////////////x8AAAAAAAAAAAAAAAAAAADg//////////////8DAAAAAAAA/P////////////////////////////////////8/AAAAAAAAAAAAAAAAAAAA4P//////////////AwAAAAAAAPz//////////////////////////////////////wAAAAAAAAAAAAAAAAAAAOD//////////////wcAAAAAAAD4//////////////////////////////////////8BAAAAAAAAAAAAAAAAAADA//////////////8HAAAAAAAA+P//////////////////////////////////////AwAAAAAAAAAAAAAAAAAAwP//////////////BwAAAAAAAPj//////////////////////////////////////wcAAAAAAAAAAAAAAAAAAMD//////////////wcAAAAAAAD4//////////////////////////////////////8PAAAAAAAAAAAAAAAAAADA//////////////8PAAAAAAAA+P//////////////////////////////////////PwAAAAAAAAAAAAAAAAAAwP//////////////DwAAAAAAAPD//////////////////////////////////////38AAAAAAAAAAAAAAAAAAID//////////////w8AAAAAAADw////////////////////////////////////////AAAAAAAAAAAAAAAAAACA//////////////8PAAAAAAAA8P///////////////////////////////////////wEAAAAAAAAAAAAAAAAAgP//////////////DwAAAAAAAPD///////////////////////////////////////8HAAAAAAAAAAAAAAAAAID//////////////x8AAAAAAADg////////////////////////////////////////DwAAAAAAAAAAAAAAAACA//////////////8fAAAAAAAA4P///////////////////////////////////////x8AAAAAAAAAAAAAAAAAAP//////////////HwAAAAAAAOD///////////////////////////////////////8/AAAAAAAAAAAAAAAAAAD//////////////x8AAAAAAADA////////////////////////////////////////fwAAAAAAAAAAAAAAAAAA//////////////8fAAAAAAAAwP////////////////////////////////////////8BAAAAAAAAAAAAAAAAAP//////////////PwAAAAAAAID/////////////////////////////////////////AwAAAAAAAAAAAAAAAAD+/////////////z8AAAAAAACA/////////////////////////////////////////wcAAAAAAAAAAAAAAAAA/v////////////8/AAAAAAAAAP////////////////////////////////////////8PAAAAAAAAAAAAAAAAAP7/////////////PwAAAAAAAAD/////////////////////////////////////////HwAAAAAAAAAAAAAAAAD+/////////////z8AAAAAAAAA/v///////////////////////////////////////38AAAAAAAAAAAAAAAAA/v////////////9/AAAAAAAAAP7/////////////////////////////////////////AAAAAAAAAAAAAAAAAPz/////////////fwAAAAAAAAD8/////////////////////////////////////////wEAAAAAAAAAAAAAAAD8/////////////38AAAAAAAAA/P////////////////////////////////////////8DAAAAAAAAAAAAAAAA/P////////////9/AAAAAAAAAPj/////////////////////////////////////////BwAAAAAAAAAAAAAAAPz/////////////fwAAAAAAAAD4/////////////////////////////////////////x8AAAAAAAAAAAAAAAD8/////////////38AAAAAAAAA8P////////////////////////////////////////8/AAAAAAAAAAAAAAAA+P////////////9/AAAAAAAAAOD/////////////////////////////////////////fwAAAAAAAAAAAAAAAPj/////////////PwAAAAAAAADg//////////////////////////////////////////8AAAAAAAAAAAAAAAD4/////////////z8AAAAAAAAAwP//////////////////////////////////////////AwAAAAAAAAAAAAAA+P////////////8/AAAAAAAAAID//////////////////////////////////////////wcAAAAAAAAAAAAAAPj/////////////PwAAAAAAAAAA//////////////////////////////////////////8PAAAAAAAAAAAAAADw/////////////z8AAAAAAAAAAP//////////////////////////////////////////HwAAAAAAAAAAAAAA8P////////////8fAAAAAAAAAAD+/////////////////////////////////////////z8AAAAAAAAAAAAAAPD/////////////HwAAAAAAAAAA/P////////////////////////////////////////9/AAAAAAAAAAAAAADg/////////////w8AAAAAAAAAAPj//////////////////////////////////////////wAAAAAAAAAAAAAA4P////////////8PAAAAAAAAAADw//////////////////////////////////////////8BAAAAAAAAAAAAAMD/////////////DwAAAAAAAAAA4P//////////////////////////////////////////AwAAAAAAAAAAAADA/////////////wcAAAAAAAAAAMD//////////////////////////////////////////wcAAAAAAAAAAAAAgP////////////8DAAAAAAAAAACA//////////////////////////////////////////8PAAAAAAAAAAAAAAD/////////////AwAAAAAAAAAAAP//////////////////////////////////////////HwAAAAAAAAAAAAAA/////////////wEAAAAAAAAAAAD+/////////////////////////////////////////x8AAAAAAAAAAAAAAP7///////////8AAAAAAAAAAAAA/P////////////////////////////////////////8/AAAAAAAAAAAAAAD8//////////9/AAAAAAAAAAAAAPD/////////////////////////////////////////fwAAAAAAAAAAAAAA+P//////////fwAAAAAAAAAAAADg//////////////////////////////////////////8AAAAAAAAAAAAAAPD//////////z8AAAAAAAAAAAAAwP//////////////////////////////////////////AAAAAAAAAAAAAADg//////////8fAAAAAAAAAAAAAID//////////////////////////////////////////wEAAAAAAAAAAAAAwP//////////BwAAAAAAAAAAAAAA//////////////////////////////////////////8DAAAAAAAAAAAAAID//////////wMAAAAAAAAAAAAAAPz/////////////////////////////////////////AwAAAAAAAAAAAAAA//////////8BAAAAAAAAAAAAAAD4/////////////////////////////////////////wcAAAAAAAAAAAAAAPz/////////AAAAAAAAAAAAAAAA8P////////////////////////////////////////8HAAAAAAAAAAAAAAD4////////PwAAAAAAAAAAAAAAAOD/////////////////////////////////////////DwAAAAAAAAAAAAAA4P///////w8AAAAAAAAAAAAAAADA/////////////////////////////////////////w8AAAAAAAAAAAAAAID///////8DAAAAAAAAAAAAAAAAAP////////////////////////////////////////8fAAAAAAAAAAAAAAAA/v//////AAAAAAAAAAAAAAAAAAD+////////////////////////////////////////HwAAAAAAAAAAAAAAAPj/////PwAAAAAAAAAAAAAAAAAA/P///////////////////////////////////////z8AAAAAAAAAAAAAAADA/////wcAAAAAAAAAAAAAAAAAAPj///////////////////////////////////////8/AAAAAAAAAAAAAAAAAPz///8AAAAAAAAAAAAAAAAAAADg////////////////////////////////////////fwAAAAAAAAAAAAAAAADA//8HAAAAAAAAAAAAAAAAAAAAwP///////////////////////////////////////38AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAID///////////////////////////////////////9/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////////////////////////////////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP7///////////////////////////////////////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD4////////////////////////////////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8P///////////////////////////////////////wEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOD///////////////////////////////////////8BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADA////////////////////////////////////////AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgP///////////////////////////////////////wMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD+//////////////////////////////////////8DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/P//////////////////////////////////////AwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPj//////////////////////////////////////wMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADw//////////////////////////////////////8DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwP//////////////////////////////////////AwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAID//////////////////////////////////////wcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//////////////////////////////////////8HAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP7/////////////////////////////////////BwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD8/////////////////////////////////////wcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8P////////////////////////////////////8HAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOD/////////////////////////////////////BwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADA/////////////////////////////////////wcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgP////////////////////////////////////8HAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////////////////////////////////////BwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/P///////////////////////////////////wcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPj///////////////////////////////////8HAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADw////////////////////////////////////BwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4P///////////////////////////////////wcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMD///////////////////////////////////8DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////////////////////////////////////AwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP7//////////////////////////////////wMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD8//////////////////////////////////8DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+P//////////////////////////////////AwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOD//////////////////////////////////wMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADA//////////////////////////////////8BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgP//////////////////////////////////AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//////////////////////////////////wEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/v////////////////////////////////8BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPj/////////////////////////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADw/////////////////////////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4P////////////////////////////////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMD///////////////////////////////9/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACA////////////////////////////////fwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP7//////////////////////////////z8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD8//////////////////////////////8/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+P//////////////////////////////PwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPD//////////////////////////////x8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADA//////////////////////////////8fAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgP//////////////////////////////DwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//////////////////////////////wcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/v////////////////////////////8HAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPz/////////////////////////////AwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADw/////////////////////////////wMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4P////////////////////////////8BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMD/////////////////////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACA/////////////////////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///////////////////////////38AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD8//////////////////////////8/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+P//////////////////////////PwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPD//////////////////////////x8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADg//////////////////////////8PAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwP//////////////////////////BwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//////////////////////////wMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/v////////////////////////8BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPz/////////////////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD4////////////////////////fwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4P///////////////////////z8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMD///////////////////////8fAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACA////////////////////////DwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///////////////////////wcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD+//////////////////////8DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+P//////////////////////AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPD/////////////////////fwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADg/////////////////////z8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgP////////////////////8fAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////////////////////BwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/P///////////////////wMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPj///////////////////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADg//////////////////8/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwP//////////////////HwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//////////////////wcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/P////////////////8BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPD///////////////9/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADA////////////////HwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///////////////wcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD4//////////////8BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4P////////////8/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////////////BwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+P///////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMD//////////x8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/P////////8DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMD///////8fAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+P//////AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD+////AwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "output": "cutout", + "mode": "paint", + "regionSize": 0.35, + "selection": "random", + "threshold": 0.5, + "invert": false, + "size": 0.04, + "cellAspect": 1, + "irregularity": 0, + "edgeScatter": 0.25, + "seed": 1, + "gap": 0, + "softness": 0, + "outlineMode": "perimeter", + "outline": 0.035, + "outlineColor": "#b53387" + }, + "runtimeError": null, + "saturation": 1, + "type": "photographic-cells", + "visible": true, + "parentId": "study" + }, + { + "assetId": "flora", + "blendMode": "normal", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "photo", + "kind": "source", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Flora \u2014 replace with your photo", + "opacity": 1, + "params": { + "fitMode": "cover", + "transparentBounds": true, + "scale": 1, + "offset": [ + 0, + 0 + ], + "svgRasterResolution": "2048" + }, + "runtimeError": null, + "saturation": 1, + "type": "image", + "visible": true, + "parentId": "study" + } + ] +} diff --git a/public/examples/v3/painted-flora.png b/public/examples/v3/painted-flora.png new file mode 100644 index 00000000..9d475cb1 Binary files /dev/null and b/public/examples/v3/painted-flora.png differ diff --git a/public/scenes/default/editorial/flora.webp b/public/scenes/default/editorial/flora.webp new file mode 100644 index 00000000..7c0e732f Binary files /dev/null and b/public/scenes/default/editorial/flora.webp differ diff --git a/public/scenes/default/editorial/registration.svg b/public/scenes/default/editorial/registration.svg new file mode 100644 index 00000000..4b10b9b0 --- /dev/null +++ b/public/scenes/default/editorial/registration.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/scenes/default/editorial/specimen-lines.svg b/public/scenes/default/editorial/specimen-lines.svg new file mode 100644 index 00000000..ac78196f --- /dev/null +++ b/public/scenes/default/editorial/specimen-lines.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/scripts/generate-editorial-studies.ts b/scripts/generate-editorial-studies.ts new file mode 100644 index 00000000..de9baafe --- /dev/null +++ b/scripts/generate-editorial-studies.ts @@ -0,0 +1,230 @@ +import { write } from "bun" +// Rebuild the editable studies with: bun scripts/generate-editorial-studies.ts +// Reference images are visual direction only; all assets here are existing app +// media or original linework. Stable IDs make the generated .lab diffs reviewable. +import { createLayer } from "../src/lib/editor/layers" +import { + DEFAULT_SCENE_CONFIG, + type LayerType, + type LayerParameterValues, +} from "../src/types/editor" +import { + emptyCellPaintMask, + encodeCellPaintMask, +} from "../src/renderer/cell-paint-mask" +import { paintCellSegment } from "../src/lib/editor/paint/cell-paint-brush" + +function layer( + type: LayerType, + id: string, + name: string, + params: LayerParameterValues = {}, + parentId?: string +) { + const base = createLayer(type) + return { + ...base, + id, + name, + ...(parentId ? { parentId } : {}), + params: { ...base.params, ...params }, + } +} +function text( + id: string, + words: string, + x: number, + y: number, + color = "#202020" +) { + return layer("text", id, words, { + text: words, + fontFamily: "mono", + fontWeight: 400, + fontSize: 48, + letterSpacing: -0.04, + anchor: "top-left", + offset: [x, -y], + textColor: color, + }) +} +const base = { + format: "shader-lab", + version: 7, + composition: { width: 720, height: 960 }, + selectedLayerId: "cells", + sceneConfig: { ...DEFAULT_SCENE_CONFIG, backgroundColor: "#ffffff" }, + timeline: { duration: 5, loop: true, tracks: [] }, +} +const group = layer("group", "study", "Color / fine dots") +const color = { + ...base, + assets: [ + { + id: "registration", + kind: "image", + url: "/scenes/default/editorial/registration.svg", + fileName: "registration.svg", + mimeType: "image/svg+xml", + width: 720, + height: 960, + }, + ], + layers: [ + text("title", "FIELD", 0.55, 0.12), + text("subtitle", "NOTES", 0.55, 0.18), + text("edition", "01 /", 0.72, 0.89), + { + ...layer("image", "marks", "Registration marks", { fitMode: "contain" }), + assetId: "registration", + opacity: 0.35, + }, + group, + layer( + "photographic-cells", + "cells", + "Stepped regions", + { + mode: "regions", + seed: 11, + threshold: 0.48, + regionSize: 0.4, + size: 0.03, + edgeScatter: 0.65, + outlineMode: "none", + }, + group.id + ), + layer( + "halftone", + "dots", + "Fine source-color dots", + { + colorMode: "source", + spacing: 3, + angle: 45, + dotSize: 0.8, + dotMin: 0.45, + contrast: 1.2, + paperGrain: 0, + bloomEnabled: false, + }, + group.id + ), + layer( + "gradient", + "color", + "Blue / ochre / violet", + { + animate: false, + motionAmount: 0, + tonemapMode: "none", + grainAmount: 0, + glowStrength: 0, + vignetteStrength: 0, + activePoints: 5, + falloff: 3.8, + warpAmount: 0.5, + warpScale: 2, + vortexAmount: 0.4, + noiseType: "simplex", + noiseSeed: 12, + point1Color: "#e9a019", + point1Position: [-0.5, -0.55], + point1Weight: 1, + point2Color: "#397bca", + point2Position: [0.55, -0.4], + point2Weight: 1, + point3Color: "#008f97", + point3Position: [-0.5, 0.65], + point3Weight: 1, + point4Color: "#5534ac", + point4Position: [0.05, 0.1], + point4Weight: 0.7, + point5Color: "#f4d545", + point5Position: [0.6, 0.65], + point5Weight: 1, + }, + group.id + ), + ], +} + +const mask = emptyCellPaintMask(1, 4 / 3) +// A directed, diagonal specimen cutout with a small detached fragment. These +// are ordinary editable Paint strokes, not a flattened or hidden image mask. +for (const [x0, y0, x1, y1, radius] of [ + [0.02, -0.46, 0.17, -0.25, 0.16], + [0.17, -0.25, 0.1, 0.18, 0.18], + [0.1, 0.18, 0.3, 0.4, 0.19], + [-0.3, 0.13, -0.28, 0.26, 0.09], +]) + paintCellSegment(mask, { x: x0!, y: y0! }, { x: x1!, y: y1! }, radius!, false) +const photoGroup = layer("group", "study", "Painted specimen") +const photo = { + ...base, + assets: [ + { + id: "annotations", + kind: "image", + url: "/scenes/default/editorial/specimen-lines.svg", + fileName: "specimen-lines.svg", + mimeType: "image/svg+xml", + width: 720, + height: 960, + }, + { + id: "flora", + kind: "image", + url: "/scenes/default/editorial/flora.webp", + fileName: "flora.webp", + mimeType: "image/webp", + width: 1512, + height: 909, + }, + ], + layers: [ + text("title", "FIELD / 02", 0.06, 0.055, "#aa3676"), + text("subtitle", "flora", 0.06, 0.89, "#aa3676"), + { + ...layer("image", "marks", "Specimen linework", { fitMode: "contain" }), + assetId: "annotations", + opacity: 0.6, + }, + photoGroup, + layer( + "photographic-cells", + "cells", + "Painted photographic reveal", + { + mode: "paint", + paintMask: encodeCellPaintMask(mask), + size: 0.04, + edgeScatter: 0.25, + outlineMode: "perimeter", + outline: 0.035, + outlineColor: "#b53387", + }, + photoGroup.id + ), + { + ...layer( + "image", + "photo", + "Flora — replace with your photo", + { fitMode: "cover" }, + photoGroup.id + ), + assetId: "flora", + }, + ], +} +for (const [name, project] of [ + ["color-field", color], + ["painted-flora", photo], +] as const) { + await write( + `public/examples/v3/${name}.lab`, + `${JSON.stringify(project, null, 2)}\n` + ) +} diff --git a/src/components/community/scene-viewer.tsx b/src/components/community/scene-viewer.tsx index 0c33382b..13d114b1 100644 --- a/src/components/community/scene-viewer.tsx +++ b/src/components/community/scene-viewer.tsx @@ -1,5 +1,6 @@ "use client" +import { fitDocumentToViewport, getDocumentSize } from "@/lib/editor/composition" import { PlayIcon } from "@radix-ui/react-icons" import { useCallback, useEffect, useRef, useState } from "react" import { Typography } from "@/components/ui/typography" @@ -155,19 +156,30 @@ export function SceneViewer({ } const pixelRatio = Math.min(window.devicePixelRatio || 1, 2) - let viewportSize = measureElement(container) - renderer.resize(viewportSize, pixelRatio) + const documentSize = getDocumentSize( + project.sceneConfig, + project.composition + ) + const fit = (bounds: Size): Size => + documentSize ? fitDocumentToViewport(documentSize, bounds, 0) : bounds + let viewportSize = fit(measureElement(container)) + const applyCanvasSize = () => { + canvas.style.width = `${viewportSize.width}px` + canvas.style.height = `${viewportSize.height}px` + renderer.resize(viewportSize, pixelRatio) + } + applyCanvasSize() resizeObserver = new ResizeObserver(([entry]) => { if (!entry) { return } - viewportSize = { + viewportSize = fit({ height: Math.max(1, Math.round(entry.contentRect.height)), width: Math.max(1, Math.round(entry.contentRect.width)), - } - renderer.resize(viewportSize, pixelRatio) + }) + applyCanvasSize() }) resizeObserver.observe(container) @@ -199,6 +211,7 @@ export function SceneViewer({ clockTime, delta, layers: project.layers, + ...(documentSize ? { logicalSize: documentSize } : {}), outputSize: project.composition, pixelRatio, sceneConfig: project.sceneConfig, @@ -366,7 +379,7 @@ export function SceneViewer({ (null) - const restoredFromRef = useRef(null) const editedBeforeReadyRef = useRef(false) const pendingWriteRef = useRef | null>(null) @@ -136,15 +136,22 @@ export function AutosaveMount() { signatureRef.current = signature - const write = saveAutosaveRecord({ - activeDraft, - projectFile, - remixOrigin, - }).then((written) => { - if (written) { - whenIdle(collectStoredAssetGarbage) - } - }) + // Serialize writes: a slow older snapshot must not overwrite a new document. + const write = (pendingWriteRef.current ?? Promise.resolve()) + .then(() => + saveAutosaveRecord({ + activeDraft, + projectFile, + remixOrigin, + }) + ) + .then((written) => { + if (written) { + whenIdle(collectStoredAssetGarbage) + } else if (signatureRef.current === signature) { + signatureRef.current = null + } + }) pendingWriteRef.current = write @@ -191,6 +198,7 @@ export function AutosaveMount() { if (!candidate) { readyRef.current = true + if (editedBeforeReadyRef.current) scheduler.request() return } @@ -253,8 +261,6 @@ export function AutosaveMount() { remixOrigin: candidate.remixOrigin, }) - restoredFromRef.current = candidate.sessionId - if (!resuming) { setRestored(true) } @@ -331,6 +337,15 @@ export function AutosaveMount() { } }), useEditorStore.subscribe((state, previous) => { + if ( + !readyRef.current && + state.sceneRevision !== previous.sceneRevision && + !isRestoringAutosave() + ) { + // File imports/remixes suppress autosave during hydration, but still + // represent an explicit choice that must win over pending recovery. + editedBeforeReadyRef.current = true + } if (editorChanged(previous, state)) { request() } @@ -377,39 +392,31 @@ export function AutosaveMount() { } }, [scheduler]) - const startFresh = useCallback(() => { - setRestored(false) - scheduler.cancel() - - withAutosaveSuppressed(() => { - applyLabProjectFile( - getDefaultProjectFile(), - useAssetStore.getState().assets - ) - useRemixOriginStore.getState().clearRemixOrigin() - useDraftStore.getState().clearActiveDraft() - disarmRemixDraft() - }) - - signatureRef.current = null - - const restoredFrom = restoredFromRef.current - - restoredFromRef.current = null - - void forgetOwnAutosaveRecord() + const beginProject = useCallback( + (kind: ProjectStart) => { + setRestored(false) + scheduler.cancel() + editedBeforeReadyRef.current = true + readyRef.current = true + replaceWithNewProject(kind) + signatureRef.current = null + // Persist even an untouched blank project, preventing older scenes returning. + persist() + }, + [persist, scheduler] + ) - if (restoredFrom) { - void forgetAutosaveRecord(restoredFrom) - } - }, [scheduler]) + useEffect(() => { + registerProjectStarter(beginProject) + return () => registerProjectStarter(null) + }, [beginProject]) return ( {restored ? ( Restored your last session - + + Painted reveal + + + Paint over your subject. Cell Size shapes the stepped edge. The faint + guide disappears when you finish. + + + + ) +} diff --git a/src/components/editor/cell-paint-overlay.tsx b/src/components/editor/cell-paint-overlay.tsx new file mode 100644 index 00000000..8d8543bb --- /dev/null +++ b/src/components/editor/cell-paint-overlay.tsx @@ -0,0 +1,251 @@ +"use client" +import { type PointerEvent, useCallback, useEffect, useRef } from "react" +import { + decodeCellPaintMask, + encodeCellPaintMask, + type CellPaintMask, +} from "@/renderer/cell-paint-mask" +import { + expandCellPaintMask, + paintCellSegment, + type PaintPoint, +} from "@/lib/editor/paint/cell-paint-brush" +import { + canPaintTarget, + type PaintTarget, + readPaint, + useCellPaintStore, + writePaint, +} from "@/store/cell-paint-store" +import { useEditorStore } from "@/store/editor-store" +import { useLayerStore } from "@/store/layer-store" + +type Stroke = { + pointer: number + mask: CellPaintMask + point: PaintPoint + radius: number + erase: boolean + base: string +} +export function CellPaintOverlay({ + panning, + disabled, +}: { + panning: boolean + disabled: boolean +}) { + const id = useCellPaintStore((s) => s.layerId) + const target = useCellPaintStore((s) => s.target) + const allowed = useLayerStore((s) => + canPaintTarget(s.layers, id, s.selectedLayerId, target) + ) + useEffect(() => { + if (id && !allowed) useCellPaintStore.getState().edit(null) + }, [id, allowed]) + // Unmount the gesture surface on selection, mode, visibility, lock or export changes. + return id && allowed && !disabled ? ( + + ) : null +} +function PaintSurface({ + id, + target, + panning, +}: { + id: string + target: PaintTarget + panning: boolean +}) { + const brushSize = useCellPaintStore((s) => s.brushSize) + const tool = useCellPaintStore((s) => s.tool) + const surface = useRef(null) + const cursor = useRef(null) + const stroke = useRef(null) + const pending = useRef(null) + const finish = useCallback( + (commit: boolean) => { + if (pending.current !== null) cancelAnimationFrame(pending.current) + pending.current = null + const active = stroke.current + stroke.current = null + if (active) { + const state = useLayerStore.getState() + const layer = state.layers.find((l) => l.id === id) + if ( + commit && + canPaintTarget(state.layers, id, state.selectedLayerId, target) && + readPaint(layer, target) === active.base + ) { + writePaint(id, target, encodeCellPaintMask(active.mask)) + } + if (surface.current?.hasPointerCapture(active.pointer)) + surface.current.releasePointerCapture(active.pointer) + useEditorStore.getState().endInteractiveEdit() + } + useCellPaintStore.getState().setDraft(null) + }, + [id, target] + ) + useEffect(() => { + const cancel = () => finish(false) + const key = (event: KeyboardEvent) => { + if ( + event.key === "Escape" && + !event.defaultPrevented && + !( + event.target instanceof Element && + event.target.closest( + '[role="dialog"], [role="menu"], [role="listbox"]' + ) + ) + ) { + cancel() + useCellPaintStore.getState().edit(null) + } + } + const beforeHistory = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "z") + cancel() + } + // A project replacement/history change must never receive an old stroke. + const unsubscribe = useLayerStore.subscribe((s) => { + if ( + stroke.current && + readPaint( + s.layers.find((l) => l.id === id), + target + ) !== stroke.current.base + ) + cancel() + }) + const unsubscribeScene = useEditorStore.subscribe((state, previous) => { + if (state.sceneRevision !== previous.sceneRevision) { + cancel() + useCellPaintStore.getState().edit(null) + } + }) + window.addEventListener("blur", cancel) + window.addEventListener("keydown", key) + window.addEventListener("keydown", beforeHistory, true) + return () => { + cancel() + unsubscribe() + unsubscribeScene() + window.removeEventListener("blur", cancel) + window.removeEventListener("keydown", key) + window.removeEventListener("keydown", beforeHistory, true) + } + }, [finish, id, target]) + const position = (event: PointerEvent) => { + const rect = event.currentTarget.getBoundingClientRect() + const shorter = Math.max(1, Math.min(rect.width, rect.height)) + const point = { + x: (event.clientX - rect.left - rect.width / 2) / shorter, + y: (event.clientY - rect.top - rect.height / 2) / shorter, + } + if (cursor.current) { + const diameter = + brushSize * + Math.min( + event.currentTarget.clientWidth, + event.currentTarget.clientHeight + ) + cursor.current.style.cssText = `display:block;left:${((event.clientX - rect.left) / rect.width) * 100}%;top:${((event.clientY - rect.top) / rect.height) * 100}%;width:${diameter}px;height:${diameter}px` + } + return { point, width: rect.width / shorter, height: rect.height / shorter } + } + const publish = () => { + if (pending.current !== null) return + pending.current = requestAnimationFrame(() => { + pending.current = null + if (stroke.current) + useCellPaintStore + .getState() + .setDraft(encodeCellPaintMask(stroke.current.mask)) + }) + } + const move = (event: PointerEvent) => { + const { point } = position(event) + const active = stroke.current + if (!active || event.pointerId !== active.pointer) return + event.stopPropagation() + paintCellSegment( + active.mask, + active.point, + point, + active.radius, + active.erase + ) + active.point = point + publish() + } + return ( +
{ + if (panning || event.button !== 0 || !event.isPrimary || stroke.current) + return + event.preventDefault() + event.stopPropagation() + const { point, width, height } = position(event) + const base = readPaint( + useLayerStore.getState().layers.find((l) => l.id === id), + target + ) + const mask = expandCellPaintMask( + decodeCellPaintMask(base), + width, + height + ) + stroke.current = { + pointer: event.pointerId, + mask, + point, + radius: brushSize / 2, + erase: tool === "erase", + base, + } + useEditorStore.getState().beginInteractiveEdit() + event.currentTarget.setPointerCapture(event.pointerId) + paintCellSegment(mask, point, point, brushSize / 2, tool === "erase") + publish() + }} + onPointerMove={move} + onPointerUp={(event) => { + if (event.pointerId === stroke.current?.pointer) { + move(event) + finish(true) + } + }} + onPointerCancel={(event) => { + if (event.pointerId === stroke.current?.pointer) finish(false) + }} + onLostPointerCapture={(event) => { + if (event.pointerId === stroke.current?.pointer) finish(false) + }} + onPointerLeave={() => { + if (cursor.current) cursor.current.style.display = "none" + }} + > + + ) +} diff --git a/src/components/editor/editor-canvas-viewport.tsx b/src/components/editor/editor-canvas-viewport.tsx index e774b88c..f74a0487 100644 --- a/src/components/editor/editor-canvas-viewport.tsx +++ b/src/components/editor/editor-canvas-viewport.tsx @@ -10,10 +10,12 @@ import { useState, useSyncExternalStore, } from "react" +import { CellPaintOverlay } from "./cell-paint-overlay" +import { MaskHandlesOverlay } from "./mask-handles-overlay" import { MadeByBasement } from "@/components/editor/made-by-basement" import { useMobileCanvasFit } from "@/components/editor/use-mobile-canvas-fit" import { useEditorRenderer } from "@/hooks/use-editor-renderer" -import { getCompositionFrame } from "@/lib/editor/composition" +import { getDocumentSize } from "@/lib/editor/composition" import { isEditableTarget } from "@/lib/editor/is-editable-target" import { inferFileAssetKind } from "@/lib/editor/media-file" import { @@ -51,29 +53,16 @@ export function EditorCanvasViewport() { const sceneConfig = useEditorStore((state) => state.sceneConfig) const canvasSize = useEditorStore((state) => state.canvasSize) - const compositionOverlay = useMemo(() => { - if (canvasSize.width === 0 || canvasSize.height === 0) return null - - const frame = getCompositionFrame(sceneConfig, canvasSize) - - if ( - frame.x === 0 && - frame.y === 0 && - frame.width === canvasSize.width && - frame.height === canvasSize.height - ) { - return null - } - - return { - heightPercent: (frame.height / canvasSize.height) * 100, - widthPercent: (frame.width / canvasSize.width) * 100, - } - }, [canvasSize, sceneConfig]) + const outputSize = useEditorStore((state) => state.outputSize) + const fixedArtboard = useMemo( + () => getDocumentSize(sceneConfig, outputSize) !== null, + [sceneConfig, outputSize] + ) const [isDragOver, setIsDragOver] = useState(false) const [isSpacePressed, setIsSpacePressed] = useState(false) const [isPointerPanning, setIsPointerPanning] = useState(false) + const isEmpty = useLayerStore((state) => state.layers.length === 0) const addLayer = useLayerStore((state) => state.addLayer) const setLayerAsset = useLayerStore((state) => state.setLayerAsset) const seedDurationFromMedia = useTimelineStore( @@ -134,6 +123,14 @@ export function EditorCanvasViewport() { useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === " " && !isEditableTarget(event.target)) { + if ( + !( + event.target instanceof Element && + event.target.closest("button, a[href], summary, [role='button']") + ) + ) { + event.preventDefault() + } setIsSpacePressed(true) } } @@ -341,22 +338,33 @@ export function EditorCanvasViewport() { transformOrigin: "center center", }} > - - {compositionOverlay && ( - {immersiveCanvas ? ( <>
+ {isEmpty && + isReady && + !pendingSceneSlug && + !immersiveCanvas && + !exportingPreview ? ( +
+
+

+ Add a layer to start +

+

+ Use + in Layers, or drop an image or video here. +

+
+
+ ) : null} + {isDragOver ? (
diff --git a/src/components/editor/editor-export-dialog.tsx b/src/components/editor/editor-export-dialog.tsx index 329bcec4..33eb061a 100644 --- a/src/components/editor/editor-export-dialog.tsx +++ b/src/components/editor/editor-export-dialog.tsx @@ -1,5 +1,6 @@ "use client" +import { getDocumentSize } from "@/lib/editor/composition" import { CopyIcon, Cross2Icon, @@ -61,12 +62,14 @@ import { buildShaderExportConfig, validateShaderExportSupport, } from "@/lib/editor/shader-export" +import { disarmRemixDraft } from "@/lib/editor/remix-draft" import { generateShaderExportSnippet } from "@/lib/editor/shader-export-snippet" import { type AudioAnalysisStatus, selectAudioModulationInput, } from "@/store/audio-store" import { useDraftStore } from "@/store/draft-store" +import { useRemixOriginStore } from "@/store/remix-origin-store" import { useAssetStore, useAudioStore, @@ -217,7 +220,10 @@ export function EditorExportDialog({ const outputSize = useEditorStore((state) => state.outputSize) const sceneConfig = useEditorStore((state) => state.sceneConfig) const liveCanvas = useEditorStore((state) => state.liveCanvas) - const compositionSize = outputSize + const compositionSize = useMemo( + () => getDocumentSize(sceneConfig, outputSize) ?? outputSize, + [sceneConfig, outputSize] + ) const suggestedAspectPreset = useMemo( () => getSuggestedExportAspectPreset(sceneConfig), [sceneConfig] @@ -855,7 +861,9 @@ export function EditorExportDialog({ } const result = withAutosaveSuppressed(() => { + disarmRemixDraft() useDraftStore.getState().clearActiveDraft() + useRemixOriginStore.getState().clearRemixOrigin() return applyLabProjectFile(projectFile, useAssetStore.getState().assets) }) @@ -1898,7 +1906,9 @@ function buildRenderProjectState() { return { assets, audio: selectAudioModulationInput(useAudioStore.getState()), - compositionSize: editorState.outputSize, + compositionSize: + getDocumentSize(editorState.sceneConfig, editorState.outputSize) ?? + editorState.outputSize, layers, sceneConfig: editorState.sceneConfig, timeline: { 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..867e3173 100644 --- a/src/components/editor/editor-topbar.tsx +++ b/src/components/editor/editor-topbar.tsx @@ -16,6 +16,8 @@ import { import { AnimatePresence, motion } from "motion/react" import dynamic from "next/dynamic" import { useCallback, useEffect, useRef, useState } from "react" +import { ProjectMenu } from "@/components/editor/project-menu" +import { hasSceneAdjustments } from "@/lib/editor/scene-adjustments" import { AgentConnectPanel } from "@/components/editor/agent-connect-panel" import { FloatingDesktopPanel } from "@/components/editor/floating-desktop-panel" import { fitMobileCanvas } from "@/components/editor/use-mobile-canvas-fit" @@ -57,12 +59,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, @@ -125,6 +127,9 @@ export function EditorTopBar({ const immersiveCanvas = useEditorStore((state) => state.immersiveCanvas) const mobilePanel = useEditorStore((state) => state.mobilePanel) const rightSidebarVisible = useEditorStore((state) => state.sidebars.right) + const globalColorsActive = useEditorStore((state) => + hasSceneAdjustments(state.sceneConfig) + ) const sidebarView = useEditorStore((state) => state.sidebarView) const setSidebarView = useEditorStore((state) => state.setSidebarView) const zoom = useEditorStore((state) => state.zoom) @@ -215,7 +220,6 @@ export function EditorTopBar({ pendingBaseSnapshotRef.current = committedSnapshotRef.current } - if (interactiveEditDepth > 0) { return } @@ -288,6 +292,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,53 +364,79 @@ export function EditorTopBar({ } ) - const unsubscribeAudio = useAudioStore.subscribe( + 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 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 ( + getHistorySnapshotSignature(previousSnapshot) === + getHistorySnapshotSignature(nextSnapshot) + ) { + return + } + + scheduleHistoryCommit(nextSnapshot) + }) + + const unsubscribeEditor = useEditorStore.subscribe( (state, previousState) => { - if (applyingHistoryRef.current || isRestoringAutosave()) { + if (state.sceneRevision !== previousState.sceneRevision) { + // A document replacement is a boundary, including any pending debounce. + if (historyTimerRef.current !== null) { + window.clearTimeout(historyTimerRef.current) + historyTimerRef.current = null + } + pendingBaseSnapshotRef.current = null + useHistoryStore.getState().clearHistory() syncHistorySnapshotRefs() return } - - if ( - state.bands === previousState.bands && - state.links === previousState.links && - state.offsetSeconds === previousState.offsetSeconds && - state.source === previousState.source - ) { + 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.sceneConfig === previousState.sceneConfig) return + const next = buildEditorHistorySnapshot() if ( - getHistorySnapshotSignature(previousSnapshot) === - getHistorySnapshotSignature(nextSnapshot) + getHistorySnapshotSignature(next) !== + getHistorySnapshotSignature(latestSnapshotRef.current) ) { - return + scheduleHistoryCommit(next) } - - scheduleHistoryCommit(nextSnapshot) } ) @@ -407,6 +445,7 @@ export function EditorTopBar({ unsubscribeLayers() unsubscribeTimeline() unsubscribeAudio() + unsubscribeEditor() if (historyTimerRef.current !== null) { window.clearTimeout(historyTimerRef.current) @@ -464,6 +503,8 @@ export function EditorTopBar({ + +
) : null} -
+ {globalColorsActive ? ( + + ) : null} +
{hasMovedFloatingPanels ? ( @@ -637,6 +692,7 @@ export function EditorTopBar({ className="pointer-events-auto flex w-full max-w-[420px] flex-col gap-1.5 p-1.5" variant="panel" > +
({ + label: preset.label, + value: preset.id, + })), + { label: "Custom", value: "custom" }, +] + +export function GradientMapControls({ + layerId, + values, + updateLayerParam, + onInteractionStart, + onInteractionEnd, +}: { + layerId: string + values: LayerParameterValues + updateLayerParam: (id: string, key: string, value: ParameterValue) => void + onInteractionStart?: (() => void) | undefined + onInteractionEnd?: (() => void) | undefined +}) { + const stops = parseGradientMapStops(values.stops) + const canonical = canonicalGradientMapStops(stops) + const preset = + GRADIENT_MAP_PRESETS.find( + (entry) => canonicalGradientMapStops(entry.stops) === canonical + )?.id ?? "custom" + const write = (next: typeof stops) => + updateLayerParam(layerId, "stops", serializeGradientMapStops(next)) + return ( +
+ + Ramp + +
+ + Preset + + + moveLayer( + layerId, + value === "scene" ? null : (value?.slice(6) ?? null) + ) + } + options={[ + { label: "Scene", value: "scene" }, + ...groups + .filter( + (group) => moveLayerToGroup(layers, layerId, group.id) !== null + ) + .map((group) => ({ + label: paths.get(group.id)!, + value: `group:${group.id}`, + })), + ]} + value={layer.parentId ? `group:${layer.parentId}` : "scene"} + /> +
+ ) +} diff --git a/src/components/editor/layer-mask-section.tsx b/src/components/editor/layer-mask-section.tsx new file mode 100644 index 00000000..7dcf9047 --- /dev/null +++ b/src/components/editor/layer-mask-section.tsx @@ -0,0 +1,222 @@ +"use client" +import { Select } from "@/components/ui/select" +import { Slider } from "@/components/ui/slider" +import { Toggle } from "@/components/ui/toggle" +import { Typography } from "@/components/ui/typography" +import { XYPad } from "@/components/ui/xy-pad" +import { useCellPaintStore } from "@/store/cell-paint-store" +import { + DEFAULT_LAYER_MASK, + type LayerKind, + type LayerMask, + type LayerMaskScope, + type LayerMaskShape, +} from "@/types/editor" +import { PaintBrushControls } from "./paint-brush-controls" + +export const layerMaskShapeOptions: { label: string; value: LayerMaskShape }[] = + [ + { label: "None", value: "none" }, + { label: "Linear gradient", value: "linear" }, + { label: "Radial gradient", value: "radial" }, + { label: "Ellipse", value: "ellipse" }, + { label: "Rectangle", value: "rectangle" }, + { label: "Brush", value: "brush" }, + ] + +const scopeOptions: { label: string; value: LayerMaskScope }[] = [ + { label: "Limit effect", value: "effect" }, + { label: "Cut content", value: "content" }, +] + +const GEOMETRIC: LayerMaskShape[] = ["linear", "radial", "ellipse", "rectangle"] + +function scopeCaption(inGroup: boolean, scope: LayerMaskScope): string { + if (!inGroup) + return "Put this effect in a group to cut content: transparency reveals the layers outside the group." + if (scope === "content") + return "Outside the mask the group becomes transparent, revealing layers outside it." + return "Outside the mask the image below stays untouched." +} + +export function LayerMaskSection({ + layerId, + layerKind, + inGroup, + mask, + setLayerMask, + onInteractionStart, + onInteractionEnd, +}: { + layerId: string + layerKind: LayerKind | string + inGroup: boolean + mask: LayerMask | null | undefined + setLayerMask: (id: string, updates: Partial) => void + onInteractionStart?: (() => void) | undefined + onInteractionEnd?: (() => void) | undefined +}) { + const current = mask ?? DEFAULT_LAYER_MASK + const shape = current.shape + const isEffect = layerKind === "effect" + const geometric = GEOMETRIC.includes(shape) + const row = "grid items-center gap-[10px] [grid-template-columns:minmax(0,1fr)_132px]" + const chooseShape = (next: LayerMaskShape) => { + setLayerMask(layerId, { shape: next }) + const paint = useCellPaintStore.getState() + if (next === "brush") paint.edit(layerId, "mask") + else if (paint.layerId === layerId && paint.target === "mask") + paint.edit(null) + } + return ( +
+ + Mask + +
+ + Shape + + { + if (value === "effect" || value === "content") + setLayerMask(layerId, { scope: value }) + }} + /> +
+ + {scopeCaption(inGroup, current.scope)} + + + ) : ( + + Cuts this layer's content. Lower layers show through outside the + mask. + + )} +
+ + setLayerMask(layerId, { enabled: checked }) + } + /> + + setLayerMask(layerId, { invert: checked }) + } + /> +
+ {geometric && ( + <> + + setLayerMask(layerId, { center: [x, -y] }) + } + /> + + setLayerMask(layerId, { size: [v, current.size[1]] }) + } + onValueCommitted={() => onInteractionEnd?.()} + /> + {shape !== "linear" && ( + + setLayerMask(layerId, { size: [current.size[0], v] }) + } + onValueCommitted={() => onInteractionEnd?.()} + /> + )} + setLayerMask(layerId, { rotation: v })} + onValueCommitted={() => onInteractionEnd?.()} + /> + {(shape === "ellipse" || shape === "rectangle") && ( + setLayerMask(layerId, { feather: v })} + onValueCommitted={() => onInteractionEnd?.()} + /> + )} + + Drag the handles on the canvas to move, resize and rotate. + + + )} + {shape === "brush" && ( + + )} + + )} +
+ ) +} diff --git a/src/components/editor/layer-picker.tsx b/src/components/editor/layer-picker.tsx index 56539187..cab32a47 100644 --- a/src/components/editor/layer-picker.tsx +++ b/src/components/editor/layer-picker.tsx @@ -47,6 +47,7 @@ export type AddLayerAction = | "fluted-glass" | "fluid" | "gradient" + | "gradient-map" | "halftone" | "image" | "ink" @@ -59,6 +60,8 @@ export type AddLayerAction = | "pixel-sorting" | "plotter" | "posterize" + | "photographic-cells" + | "displaced-rings" | "slice" | "smear" | "threshold" @@ -113,6 +116,8 @@ const SOURCE_ITEMS: readonly SourceItem[] = SOURCE_ICONS.map( ) const EFFECT_ORDER: readonly AddLayerAction[] = [ + "photographic-cells", + "displaced-rings", "ascii", "ink", "pattern", @@ -124,6 +129,7 @@ const EFFECT_ORDER: readonly AddLayerAction[] = [ "voxel", "posterize", "threshold", + "gradient-map", "bloom", "plotter", "blob-tracking", diff --git a/src/components/editor/layer-sidebar.tsx b/src/components/editor/layer-sidebar.tsx index d444a1d1..84d9fb32 100644 --- a/src/components/editor/layer-sidebar.tsx +++ b/src/components/editor/layer-sidebar.tsx @@ -1,10 +1,13 @@ "use client" import { + ChevronDownIcon, + ChevronRightIcon, DotsVerticalIcon, DragHandleDots2Icon, EyeClosedIcon, EyeOpenIcon, FileIcon, + GroupIcon, ImageIcon, LayoutIcon, ShadowIcon, @@ -12,14 +15,12 @@ import { TransparencyGridIcon, TrashIcon, } from "@radix-ui/react-icons" -import { Reorder, useDragControls } from "motion/react" import Image from "next/image" import { type ChangeEvent, memo, type MouseEvent as ReactMouseEvent, type ReactNode, - type PointerEvent as ReactPointerEvent, useEffect, useMemo, useRef, @@ -30,6 +31,7 @@ import { type AddLayerAction, LayerPicker, } from "@/components/editor/layer-picker" +import { useLayerDrag } from "@/components/editor/use-layer-drag" import { GlassPanel } from "@/components/ui/glass-panel" import { IconButton } from "@/components/ui/icon-button" import { Select } from "@/components/ui/select" @@ -38,6 +40,10 @@ import { Typography } from "@/components/ui/typography" import { playUISound } from "@/lib/audio/shader-lab-sounds" import { cn } from "@/lib/cn" import { duplicateLayers } from "@/lib/editor/duplicate-layers" +import { + groupingSelection, + type LayerDropTarget, +} from "@/lib/editor/layer-groups" import { getAssetAccept, inferFileAssetKind } from "@/lib/editor/media-file" import { getSeedableMediaDuration } from "@/lib/editor/timeline-duration" import { useAssetStore } from "@/store/asset-store" @@ -46,7 +52,7 @@ import { useLayerStore } from "@/store/layer-store" import { useTimelineStore } from "@/store/timeline-store" import type { AssetKind, EditorAsset, EditorLayer } from "@/types/editor" -type LayerAction = "delete" | "duplicate" | "reset" +type LayerAction = "delete" | "duplicate" | "reset" | "ungroup" | "up" | "down" const thumbnailBaseClassName = "relative size-7 overflow-hidden rounded-[var(--ds-radius-thumb)] border border-[var(--ds-border-divider)]" @@ -117,6 +123,10 @@ function inferSelectedFileKind(file: File): AssetKind | null { } type LayerListItemProps = { + children?: ReactNode + dragPlacement?: LayerDropTarget["placement"] | undefined + isDragging: boolean + onDragStart: ReturnType["start"] asset: EditorAsset | null hasMissingAsset: boolean isFloatingPanelDragging: boolean @@ -143,35 +153,30 @@ const LAYER_ACTION_OPTIONS = [ function LayerListShell({ children, - isFloatingPanelDragging, - onReorder, - values, + nested = false, }: { children: ReactNode - isFloatingPanelDragging: boolean - onReorder: (nextLayers: EditorLayer[]) => void - values: EditorLayer[] + nested?: boolean }) { - const className = - "flex max-h-[min(52vh,480px)] flex-col gap-0.5 overflow-y-auto p-1" - - if (isFloatingPanelDragging) { - return
    {children}
- } - return ( - {children} - + ) } +function focusNameInput(node: HTMLInputElement | null) { + node?.focus() + node?.select() +} + const LayerListItem = memo(function LayerListItem({ asset, hasMissingAsset, @@ -183,68 +188,127 @@ const LayerListItem = memo(function LayerListItem({ onRelinkPick, onSelectLayer, onSetLayerVisibility, + children, + dragPlacement, + isDragging, + onDragStart, }: LayerListItemProps) { - const dragControls = useDragControls() - - function handlePointerDown(event: ReactPointerEvent) { - if (layer.locked || isFloatingPanelDragging) { - return - } + const [renaming, setRenaming] = useState(false) + const [name, setName] = useState(layer.name) + const cancelRename = useRef(false) + const renameLayer = useLayerStore((state) => state.renameLayer) + const setExpanded = useLayerStore((state) => state.setLayerExpanded) + const isGroup = layer.kind === "group" + + function startRename() { + cancelRename.current = false + setName(layer.name) + setRenaming(true) + } - dragControls.start(event) + function finishRename() { + if (!cancelRename.current) renameLayer(layer.id, name) + setRenaming(false) } - if (isFloatingPanelDragging) { - return ( -
  • +
    -
    - +
    + - - + )} + {renaming ? ( + 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/mask-handles-overlay.tsx b/src/components/editor/mask-handles-overlay.tsx new file mode 100644 index 00000000..cdb2990e --- /dev/null +++ b/src/components/editor/mask-handles-overlay.tsx @@ -0,0 +1,319 @@ +"use client" +import { + type PointerEvent as ReactPointerEvent, + useCallback, + useEffect, + useRef, + useState, +} from "react" +import { getCompositionFrame } from "@/lib/editor/composition" +import { useEditorStore } from "@/store/editor-store" +import { useLayerStore } from "@/store/layer-store" +import type { EditorLayer, LayerMask } from "@/types/editor" + +const GEOMETRIC = ["linear", "radial", "ellipse", "rectangle"] +type Handle = "center" | "x" | "y" | "start" | "end" +type Drag = { + pointer: number + handle: Handle + original: LayerMask + offset: [number, number] +} + +function editable(layers: EditorLayer[], layer: EditorLayer): boolean { + let current: EditorLayer | undefined = layer + const visited = new Set() + while (current) { + if (current.locked || !current.visible || visited.has(current.id)) + return false + visited.add(current.id) + current = layers.find((item) => item.id === current?.parentId) + } + return true +} + +function selectMaskLayer(state: { + layers: EditorLayer[] + selectedLayerId: string | null +}): EditorLayer | null { + const layer = state.layers.find((l) => l.id === state.selectedLayerId) + const mask = layer?.mask + if (!(layer && mask?.enabled && GEOMETRIC.includes(mask.shape))) return null + return editable(state.layers, layer) ? layer : null +} + +export function MaskHandlesOverlay({ + panning, + disabled, +}: { + panning: boolean + disabled: boolean +}) { + const layer = useLayerStore(selectMaskLayer) + if (!layer?.mask || disabled) return null + return ( + + ) +} + +function Handles({ + layerId, + mask, + panning, +}: { + layerId: string + mask: LayerMask + panning: boolean +}) { + const host = useRef(null) + const drag = useRef(null) + const [box, setBox] = useState({ width: 1, height: 1 }) + useEffect(() => { + const element = host.current + if (!element) return + const update = () => + setBox({ + width: Math.max(1, element.clientWidth), + height: Math.max(1, element.clientHeight), + }) + update() + const observer = new ResizeObserver(update) + observer.observe(element) + return () => observer.disconnect() + }, []) + const sceneConfig = useEditorStore((state) => state.sceneConfig) + const frame = getCompositionFrame(sceneConfig, box) + const shorter = Math.max(1, Math.min(frame.width, frame.height)) + const toPx = (x: number, y: number): [number, number] => [ + x * shorter + box.width / 2, + y * shorter + box.height / 2, + ] + const fromEvent = ( + event: ReactPointerEvent + ): [number, number] => { + const rect = host.current!.getBoundingClientRect() + const scale = rect.width / box.width + const s = Math.max(1e-6, shorter * scale) + return [ + (event.clientX - rect.left - rect.width / 2) / s, + (event.clientY - rect.top - rect.height / 2) / s, + ] + } + const angle = (mask.rotation * Math.PI) / 180 + const dir: [number, number] = [Math.cos(angle), Math.sin(angle)] + const perp: [number, number] = [-Math.sin(angle), Math.cos(angle)] + const [cx, cy] = mask.center + const half: [number, number] = [mask.size[0] / 2, mask.size[1] / 2] + const xHandle: [number, number] = [ + cx + dir[0] * half[0], + cy + dir[1] * half[0], + ] + const yHandle: [number, number] = [ + cx + perp[0] * half[1], + cy + perp[1] * half[1], + ] + const start: [number, number] = [ + cx - dir[0] * half[0], + cy - dir[1] * half[0], + ] + const end = xHandle + const finish = useCallback( + (commit: boolean) => { + const active = drag.current + if (!active) return + drag.current = null + if (!commit) + useLayerStore.getState().setLayerMask(layerId, active.original) + useEditorStore.getState().endInteractiveEdit() + }, + [layerId] + ) + useEffect(() => { + const cancel = () => finish(false) + const key = (event: KeyboardEvent) => { + if (event.key === "Escape" && drag.current) cancel() + } + window.addEventListener("blur", cancel) + window.addEventListener("keydown", key) + return () => { + if (drag.current) { + drag.current = null + useEditorStore.getState().endInteractiveEdit() + } + window.removeEventListener("blur", cancel) + window.removeEventListener("keydown", key) + } + }, [finish]) + const begin = (handle: Handle) => (event: ReactPointerEvent) => { + if (panning || event.button !== 0 || drag.current) return + event.preventDefault() + event.stopPropagation() + const point = fromEvent(event) + drag.current = { + pointer: event.pointerId, + handle, + original: structuredClone(mask), + offset: [point[0] - cx, point[1] - cy], + } + useEditorStore.getState().beginInteractiveEdit() + ;(event.currentTarget as Element).setPointerCapture(event.pointerId) + } + const move = (event: ReactPointerEvent) => { + const active = drag.current + if (!active || event.pointerId !== active.pointer) return + event.stopPropagation() + const point = fromEvent(event) + const current = + useLayerStore.getState().layers.find((l) => l.id === layerId)?.mask ?? + mask + const set = (updates: Partial) => + useLayerStore.getState().setLayerMask(layerId, updates) + const [ccx, ccy] = current.center + const dx = point[0] - ccx + const dy = point[1] - ccy + const clampSize = (v: number) => Math.min(8, Math.max(0.01, v)) + switch (active.handle) { + case "center": + set({ + center: [point[0] - active.offset[0], point[1] - active.offset[1]], + }) + break + case "x": { + const rotation = (Math.atan2(dy, dx) * 180) / Math.PI + set({ + rotation: Math.round(rotation * 10) / 10, + size: [clampSize(2 * Math.hypot(dx, dy)), current.size[1]], + }) + break + } + case "y": { + const a = (current.rotation * Math.PI) / 180 + const projection = -dx * Math.sin(a) + dy * Math.cos(a) + set({ size: [current.size[0], clampSize(2 * Math.abs(projection))] }) + break + } + case "start": + case "end": { + const a = (current.rotation * Math.PI) / 180 + const d: [number, number] = [Math.cos(a), Math.sin(a)] + const otherSign = active.handle === "end" ? -1 : 1 + const other: [number, number] = [ + ccx + otherSign * d[0] * (current.size[0] / 2), + ccy + otherSign * d[1] * (current.size[0] / 2), + ] + const vx = active.handle === "end" ? point[0] - other[0] : other[0] - point[0] + const vy = active.handle === "end" ? point[1] - other[1] : other[1] - point[1] + set({ + center: [(point[0] + other[0]) / 2, (point[1] + other[1]) / 2], + rotation: Math.round(((Math.atan2(vy, vx) * 180) / Math.PI) * 10) / 10, + size: [clampSize(Math.hypot(vx, vy)), current.size[1]], + }) + break + } + } + } + const up = (event: ReactPointerEvent) => { + if (event.pointerId !== drag.current?.pointer) return + move(event) + finish(true) + } + const cancelEvent = (event: ReactPointerEvent) => { + if (event.pointerId === drag.current?.pointer) finish(false) + } + const [pcx, pcy] = toPx(cx, cy) + const stroke = "rgba(255,255,255,0.9)" + const shadow = "drop-shadow(0 0 1px rgba(0,0,0,0.9))" + const handleProps = (handle: Handle) => ({ + r: 6, + fill: "white", + stroke: "black", + strokeWidth: 1, + style: { cursor: panning ? "inherit" : "grab", pointerEvents: "all" as const }, + onPointerDown: begin(handle), + onPointerMove: move, + onPointerUp: up, + onPointerCancel: cancelEvent, + onLostPointerCapture: cancelEvent, + }) + const isLinear = mask.shape === "linear" + const [sx, sy] = toPx(start[0], start[1]) + const [ex, ey] = toPx(end[0], end[1]) + const [xx, xy] = toPx(xHandle[0], xHandle[1]) + const [yx, yy] = toPx(yHandle[0], yHandle[1]) + return ( +
    + + {isLinear ? ( + <> + + + + + ) : ( + + {mask.shape === "rectangle" ? ( + + ) : ( + + )} + + + + )} + {isLinear ? ( + <> + + + + + ) : ( + <> + + + + + )} + +
    + ) +} diff --git a/src/components/editor/mobile-editor-dock.tsx b/src/components/editor/mobile-editor-dock.tsx index 3a99c3d2..12f0de45 100644 --- a/src/components/editor/mobile-editor-dock.tsx +++ b/src/components/editor/mobile-editor-dock.tsx @@ -10,6 +10,7 @@ import type { ElementType } from "react" import { GlassPanel } from "@/components/ui/glass-panel" import { Typography } from "@/components/ui/typography" import { playUISound } from "@/lib/audio/shader-lab-sounds" +import { hasSceneAdjustments } from "@/lib/editor/scene-adjustments" import { cn } from "@/lib/cn" import { useEditorStore } from "@/store/editor-store" import type { MobileEditorPanel } from "@/types/editor" @@ -28,6 +29,9 @@ const MOBILE_DOCK_ITEMS: readonly MobileDockItem[] = [ ] as const export function MobileEditorDock() { + const globalColorsActive = useEditorStore((state) => + hasSceneAdjustments(state.sceneConfig) + ) const immersiveCanvas = useEditorStore((state) => state.immersiveCanvas) const mobilePanel = useEditorStore((state) => state.mobilePanel) const setMobilePanel = useEditorStore((state) => state.setMobilePanel) @@ -48,7 +52,11 @@ export function MobileEditorDock() { return ( ) diff --git a/src/components/editor/paint-brush-controls.tsx b/src/components/editor/paint-brush-controls.tsx new file mode 100644 index 00000000..e20feef3 --- /dev/null +++ b/src/components/editor/paint-brush-controls.tsx @@ -0,0 +1,99 @@ +"use client" +import { Button } from "@/components/ui/button" +import { Select } from "@/components/ui/select" +import { Slider } from "@/components/ui/slider" +import { Typography } from "@/components/ui/typography" +import { + canPaintTarget, + type PaintTarget, + readPaint, + useCellPaintStore, + writePaint, +} from "@/store/cell-paint-store" +import { useLayerStore } from "@/store/layer-store" + +export function PaintBrushControls({ + layerId, + target, + editLabel, + doneLabel, + clearLabel, +}: { + layerId: string + target: PaintTarget + editLabel: string + doneLabel: string + clearLabel: string +}) { + const editing = useCellPaintStore( + (s) => s.layerId === layerId && s.target === target + ) + const tool = useCellPaintStore((s) => s.tool) + const size = useCellPaintStore((s) => s.brushSize) + const allowed = useLayerStore((s) => + canPaintTarget(s.layers, layerId, s.selectedLayerId, target) + ) + const hasPaint = useLayerStore( + (s) => + readPaint( + s.layers.find((l) => l.id === layerId), + target + ) !== "" + ) + return ( + <> +
    + + +
    + { - if (value) { - setLayerCompositeMode(layerId, value as LayerCompositeMode) - } - }} - options={compositeModeOptions} - triggerClassName="w-[132px]" - value={compositeMode} - /> -
    - - {compositeMode === "mask" && ( + {layerType !== "group" && ( <>
    - Source + Mode { - if (value) { - setLayerMaskConfig(layerId, { mode: value as MaskMode }) - } - }} - options={maskModeOptions} - triggerClassName="w-[132px]" - value={maskConfig.mode} - /> -
    + {compositeMode === "mask" && ( + <> +
    + + Source + + { + if (value) { + setLayerMaskConfig(layerId, { + mode: value as MaskMode, + }) + } + }} + options={maskModeOptions} + triggerClassName="w-[132px]" + value={maskConfig.mode} + /> +
    + +
    + + Invert + + + setLayerMaskConfig(layerId, { invert: nextValue }) + } + /> +
    + + )} + + setLayerHue(layerId, value)} + onValueCommitted={() => onInteractionEnd?.()} + value={hue} + /> + + setLayerSaturation(layerId, value)} + onValueCommitted={() => onInteractionEnd?.()} + step={0.01} + value={saturation} + valueFormatOptions={{ + maximumFractionDigits: 2, + minimumFractionDigits: 2, + }} + /> )} - - setLayerHue(layerId, value)} - onValueCommitted={() => onInteractionEnd?.()} - value={hue} - /> - - setLayerSaturation(layerId, value)} - onValueCommitted={() => onInteractionEnd?.()} - step={0.01} - value={saturation} - valueFormatOptions={{ - maximumFractionDigits: 2, - minimumFractionDigits: 2, - }} - />
    + + {layerType === "custom-shader" ? ( ) : null} + {layerType === "photographic-cells" && values.mode === "paint" && ( + + )} + + {layerType === "gradient-map" && ( + + )} + {layerType === "gradient" ? (
    state.setLayerCompositeMode ) const setLayerMaskConfig = useLayerStore((state) => state.setLayerMaskConfig) + const setLayerMask = useLayerStore((state) => state.setLayerMask) const setLayerHue = useLayerStore((state) => state.setLayerHue) const setLayerOpacity = useLayerStore((state) => state.setLayerOpacity) const randomizeGradientParams = useLayerStore( @@ -425,7 +427,8 @@ export function PropertiesSidebar() { upsertKeyframe({ binding, layerId: selectedLayer.id, - time: activeGestureTimeRef.current ?? + time: + activeGestureTimeRef.current ?? useTimelineStore.getState().currentTime, value, }) @@ -465,7 +468,8 @@ export function PropertiesSidebar() { upsertKeyframe({ binding, layerId: selectedLayer.id, - time: activeGestureTimeRef.current ?? + time: + activeGestureTimeRef.current ?? useTimelineStore.getState().currentTime, value, }) @@ -473,6 +477,23 @@ export function PropertiesSidebar() { } updateLayerParam(selectedLayer.id, key, value) + + if (selectedLayer.type === "photographic-cells" && key === "mode") { + const state = useLayerStore.getState() + const paint = useCellPaintStore.getState() + if ( + value === "paint" && + canPaintCellLayer( + state.layers, + selectedLayer.id, + state.selectedLayerId + ) + ) { + paint.edit(selectedLayer.id) + } else if (paint.layerId === selectedLayer.id) { + paint.edit(null) + } + } }, [ selectedLayer, @@ -581,6 +602,10 @@ export function PropertiesSidebar() { compositeMode: selectedLayer.compositeMode, maskConfig: selectedLayer.maskConfig, setLayerMaskConfig, + mask: selectedLayer.mask ?? null, + maskInGroup: !!selectedLayer.parentId, + maskLayerKind: selectedLayer.kind, + setLayerMask, definitionName: selectedDefinition?.defaultName ?? selectedLayer.type, expandedParamGroups, hue: displayedLayerState?.hue ?? selectedLayer.hue, @@ -664,7 +689,7 @@ export function PropertiesSidebar() { ) } - if (!hasLayers) { + if (!hasLayers && sidebarView !== "scene") { return null } @@ -684,15 +709,6 @@ export function PropertiesSidebar() { !mobilePanelVisible && "translate-y-3 opacity-0" )} > - -
    @@ -738,14 +756,14 @@ export function PropertiesSidebar() { initial={false} {...(panelHeight === null ? {} - : { animate: { height: panelHeight } })} + : { animate: { height: panelHeight + 40 } })} transition={heightTransition} > -
    +
    > = { - screen: "Screen", + screen: "Screen (adaptive)", custom: "Custom", } @@ -83,6 +89,9 @@ export function SceneConfigContent() { const updateSceneConfig = useEditorStore((state) => state.updateSceneConfig) const renderScale = useEditorStore((state) => state.renderScale) const setRenderScale = useEditorStore((state) => state.setRenderScale) + const setComposition = useEditorStore((state) => state.setComposition) + const outputSize = useEditorStore((state) => state.outputSize) + const documentSize = getDocumentSize(sceneConfig, outputSize) const handleUpdate = useCallback( (key: K, value: SceneConfig[K]) => { @@ -100,50 +109,80 @@ export function SceneConfigContent() { return (
    +
    + + {hasSceneAdjustments(sceneConfig) + ? "Global colors active" + : "Global colors neutral"} + + + These adjustments affect the entire composition. + + +
    {/* Composition */}
    diff --git a/src/components/editor/use-bottom-offset-above-timeline.ts b/src/components/editor/use-bottom-offset-above-timeline.ts index 57f58791..8bc37f33 100644 --- a/src/components/editor/use-bottom-offset-above-timeline.ts +++ b/src/components/editor/use-bottom-offset-above-timeline.ts @@ -16,18 +16,30 @@ export function useBottomOffsetAboveTimeline(active: boolean): number { const shell = document.querySelector("[data-timeline-shell]") const measure = () => { + const fallback = window.innerWidth < 900 ? 104 : PILL_FALLBACK_BOTTOM_PX if (!shell) { - setOffset(PILL_FALLBACK_BOTTOM_PX) + setOffset(fallback) return } const rect = shell.getBoundingClientRect() + if ( + rect.width === 0 || + rect.height === 0 || + getComputedStyle(shell).visibility === "hidden" + ) { + setOffset(fallback) + return + } setOffset( Math.max( PILL_GAP_PX, - Math.round(window.innerHeight - rect.top + PILL_GAP_PX) + Math.min( + window.innerHeight - 96, + Math.round(window.innerHeight - rect.top + PILL_GAP_PX) + ) ) ) } @@ -40,10 +52,21 @@ export function useBottomOffsetAboveTimeline(active: boolean): number { observer.observe(shell) } + // FloatingDesktopPanel positions/reveals its wrapper after measuring size. + // That move does not resize the shell, so ResizeObserver alone misses it. + const positionObserver = new MutationObserver(measure) + if (shell?.parentElement) { + positionObserver.observe(shell.parentElement, { + attributes: true, + attributeFilter: ["style"], + }) + } + window.addEventListener("resize", measure) return () => { observer?.disconnect() + positionObserver.disconnect() window.removeEventListener("resize", measure) } }, [active]) diff --git a/src/components/editor/use-layer-drag.ts b/src/components/editor/use-layer-drag.ts new file mode 100644 index 00000000..2bdc7534 --- /dev/null +++ b/src/components/editor/use-layer-drag.ts @@ -0,0 +1,209 @@ +"use client" + +import { + type PointerEvent as ReactPointerEvent, + useEffect, + useRef, + useState, +} from "react" +import { dropLayer, type LayerDropTarget } from "@/lib/editor/layer-groups" +import { useLayerStore } from "@/store/layer-store" +import type { EditorLayer } from "@/types/editor" + +type DragPreview = { + id: string + target: LayerDropTarget | null + message: string +} + +function findTarget( + root: HTMLElement, + layers: EditorLayer[], + x: number, + y: number +): LayerDropTarget | null { + const bounds = root.getBoundingClientRect() + if ( + x < bounds.left || + x > bounds.right || + y < bounds.top || + y > bounds.bottom + ) + return null + const rows = Array.from( + root.querySelectorAll("[data-layer-row]") + ) + const row = + rows.find((entry) => y <= entry.getBoundingClientRect().bottom) ?? + rows.at(-1) + if (!row) return null + const rect = row.getBoundingClientRect() + let anchor = layers.find((layer) => layer.id === row.dataset.layerRow) + if (!anchor) return null + let placement: LayerDropTarget["placement"] = + y < rect.top + rect.height / 2 ? "before" : "after" + if ( + anchor.kind === "group" && + x >= rect.left && + y > rect.top + rect.height * 0.25 && + y < rect.bottom - rect.height * 0.25 + ) + placement = "inside" + // The left gutter selects an ancestor's level, allowing a child to leave + // even a group that is the scene's only root item. + if (placement !== "inside") { + let left = rect.left + while (anchor.parentId && x < left - 4) { + const parent = layers.find((layer) => layer.id === anchor?.parentId) + const parentRow = rows.find( + (entry) => entry.dataset.layerRow === parent?.id + ) + if (!(parent && parentRow)) break + anchor = parent + left = parentRow.getBoundingClientRect().left + } + } + return { id: anchor.id, placement } +} + +/** Keep the tree still during a drag; the indicator is the proposed destination. */ +export function useLayerDrag() { + const [preview, setPreview] = useState(null) + const cleanup = useRef<(() => void) | null>(null) + useEffect(() => () => cleanup.current?.(), []) + + function start(id: string, event: ReactPointerEvent) { + if (event.button !== 0 || !event.isPrimary || cleanup.current) return + const handle = event.currentTarget + const root = handle.closest("[data-layer-tree-root]") + const layers = useLayerStore.getState().layers + const source = layers.find((layer) => layer.id === id) + if (!(root && source) || source.locked) return + event.preventDefault() + event.stopPropagation() + const pointerId = event.pointerId + const startX = event.clientX + const startY = event.clientY + let x = startX + let y = startY + let active = false + let target: LayerDropTarget | null = null + let frame = 0 + let lastTime = 0 + handle.setPointerCapture(pointerId) + + const update = () => { + if ( + useLayerStore.getState().layers !== layers || + !root.isConnected || + root.getBoundingClientRect().width === 0 + ) { + cancel() + return + } + const candidate = findTarget(root, layers, x, y) + target = candidate && dropLayer(layers, id, candidate) ? candidate : null + const anchor = layers.find((layer) => layer.id === target?.id) + const parent = layers.find((layer) => layer.id === anchor?.parentId) + let message = `Cannot drop ${source.name} here. Escape to cancel.` + if (target && anchor) + message = + target.placement === "inside" + ? `Move ${source.name} into ${anchor.name}` + : `Move ${source.name} ${target.placement} ${anchor.name} · ${parent?.name ?? "Scene"}` + const destination = target + setPreview((previous) => + previous?.id === id && + previous.message === message && + previous.target?.id === destination?.id && + previous.target?.placement === destination?.placement + ? previous + : { id, target: destination, message } + ) + } + const tick = (time: number) => { + const bounds = root.getBoundingClientRect() + const elapsed = Math.min(time - (lastTime || time), 32) + lastTime = time + if ( + x >= bounds.left && + x <= bounds.right && + y >= bounds.top && + y <= bounds.bottom + ) { + const edge = 32 + const speed = Math.max( + -1, + Math.min(1, (y - (bounds.bottom - edge)) / edge) + ) + const upwards = Math.max( + -1, + Math.min(0, (y - bounds.top - edge) / edge) + ) + root.scrollTop += + (upwards < 0 ? upwards : Math.max(0, speed)) * elapsed * 0.45 + } + update() + if (cleanup.current) frame = requestAnimationFrame(tick) + } + const move = (next: PointerEvent) => { + if (next.pointerId !== pointerId) return + x = next.clientX + y = next.clientY + if (!active && Math.hypot(x - startX, y - startY) < 5) return + if (!active) { + active = true + frame = requestAnimationFrame(tick) + } + next.preventDefault() + update() + } + const finish = () => { + cancelAnimationFrame(frame) + window.removeEventListener("pointermove", move) + window.removeEventListener("pointerup", end) + window.removeEventListener("pointercancel", cancelPointer) + window.removeEventListener("keydown", key, true) + window.removeEventListener("blur", cancel) + handle.removeEventListener("lostpointercapture", cancel) + cleanup.current = null + if (handle.hasPointerCapture(pointerId)) + handle.releasePointerCapture(pointerId) + setPreview(null) + } + const cancel = () => { + target = null + active = false + finish() + } + const cancelPointer = (next: PointerEvent) => { + if (next.pointerId === pointerId) cancel() + } + const key = (next: KeyboardEvent) => { + if (next.key === "Escape") { + next.preventDefault() + next.stopPropagation() + cancel() + } + } + const end = (next: PointerEvent) => { + if (next.pointerId !== pointerId) return + x = next.clientX + y = next.clientY + if (active) update() + const destination = target + finish() + if (active && destination && useLayerStore.getState().layers === layers) + useLayerStore.getState().dropLayer(id, destination) + } + cleanup.current = finish + window.addEventListener("pointermove", move, { passive: false }) + window.addEventListener("pointerup", end) + window.addEventListener("pointercancel", cancelPointer) + window.addEventListener("keydown", key, true) + window.addEventListener("blur", cancel) + handle.addEventListener("lostpointercapture", cancel) + } + + return { preview, start } +} diff --git a/src/components/pages/shader-lab-page.tsx b/src/components/pages/shader-lab-page.tsx index 03d22f59..c906f366 100644 --- a/src/components/pages/shader-lab-page.tsx +++ b/src/components/pages/shader-lab-page.tsx @@ -13,7 +13,6 @@ import { SCENE_LOADING_OVERLAY_ID, SceneDeepLinkMount, } from "@/components/editor/scene-deep-link-mount" -import { getDefaultProjectPreloadUrls } from "@/lib/editor/default-project" /* Runs while the static HTML is still parsing, long before hydration: * reveals the scene-loading overlay so a `?scene=` deep link never @@ -22,21 +21,6 @@ const SCENE_OVERLAY_BOOT_SCRIPT = `if(new URLSearchParams(location.search).has(" SCENE_LOADING_OVERLAY_ID )});if(o)o.dataset.active="true"}` -/* Also parse-time: starts the video fetch before the bundle runs. Skipped for - * `?scene=` deep links, which replace the starter scene. */ -const STARTER_MEDIA_PRELOAD_SCRIPT = `if(!new URLSearchParams(location.search).has("scene")){${JSON.stringify( - getDefaultProjectPreloadUrls() -)}.forEach(function(u){var l=document.createElement("link");l.rel="preload";l.as="video";l.href=u;l.crossOrigin="anonymous";document.head.appendChild(l)})}` - -function StarterMediaPreload() { - return ( - ', { + headers: { "Content-Type": "text/html" }, + }) + }, +}) +const browser = await chromium.launch({ + headless: true, + args: ["--enable-unsafe-webgpu"], +}) +try { + const page = await browser.newPage() + page.on("pageerror", console.error) + await page.goto(server.url.href) + await page.waitForFunction(() => window.run) + const scatterOnly = process.argv.includes("--scatter") + const result = await page.evaluate( + (scatterOnly) => window.run({ scatterOnly }), + scatterOnly + ) + console.log(JSON.stringify(result, null, 2)) + await Bun.write( + scatterOnly + ? ".context/scatter-video-performance.json" + : ".context/effect-video-performance.json", + JSON.stringify(result, null, 2) + ) +} finally { + await browser.close() + server.stop(true) +} diff --git a/tests/composition/fixtures/alpha-sample.svg b/tests/composition/fixtures/alpha-sample.svg new file mode 100644 index 00000000..7be9db5a --- /dev/null +++ b/tests/composition/fixtures/alpha-sample.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/tests/composition/fixtures/contained-image-halftone.json b/tests/composition/fixtures/contained-image-halftone.json new file mode 100644 index 00000000..5b66ba46 --- /dev/null +++ b/tests/composition/fixtures/contained-image-halftone.json @@ -0,0 +1,204 @@ +{ + "format": "shader-lab", + "version": 6, + "composition": { + "width": 256, + "height": 192 + }, + "assets": [ + { + "id": "sample", + "kind": "image", + "fileName": "alpha-sample.svg", + "url": "/scenes/default/alpha-sample.svg", + "mimeType": "image/svg+xml", + "width": 160, + "height": 160, + "duration": null, + "sizeBytes": 570 + } + ], + "layers": [ + { + "assetId": null, + "blendMode": "normal", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "halftone", + "kind": "effect", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Halftone", + "opacity": 0.65, + "params": { + "colorMode": "cmyk", + "shape": "circle", + "spacing": 5, + "dotSize": 1, + "dotMin": 0, + "angle": 28, + "contrast": 1, + "softness": 0.25, + "dotGain": 0, + "dotMorph": 0, + "invertLuma": false, + "customColorCount": 4, + "customLuminanceBias": 0, + "bloomEnabled": false, + "bloomIntensity": 1.25, + "bloomThreshold": 0.6, + "bloomRadius": 6, + "bloomSoftness": 0.35, + "customBgColor": "#F5F5F0", + "customColor1": "#0d1014", + "customColor2": "#4d5057", + "customColor3": "#969aa2", + "customColor4": "#e1e2de", + "ink": "#0d1014", + "duotoneLight": "#F5F5F0", + "duotoneDark": "#1d1d1c", + "preset": "process", + "gcr": 0.5, + "cmykBlend": "subtractive", + "cyanAngle": 15, + "magentaAngle": 75, + "yellowAngle": 0, + "keyAngle": 45, + "paperColor": "#F5F5F0", + "paperGrain": 0.15, + "registration": 0, + "inkCyan": "#00AEEF", + "inkMagenta": "#EC008C", + "inkYellow": "#FFF200", + "inkKey": "#1a1a1a" + }, + "runtimeError": null, + "saturation": 1, + "type": "halftone", + "visible": true + }, + { + "assetId": "sample", + "blendMode": "normal", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "image", + "kind": "source", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Image", + "opacity": 1, + "params": { + "fitMode": "contain", + "scale": 1, + "offset": [ + 0, + 0 + ], + "svgRasterResolution": "2048" + }, + "runtimeError": null, + "saturation": 1, + "type": "image", + "visible": true + } + ], + "selectedLayerId": "halftone", + "sceneConfig": { + "backgroundColor": "#080808", + "compositionAspect": "screen", + "compositionWidth": 1920, + "compositionHeight": 1080, + "exposure": 0, + "brightness": 0, + "contrast": 0, + "saturation": 1, + "vibrance": 0, + "hue": 0, + "temperature": 0, + "tint": 0, + "invert": false, + "channelMixer": { + "rr": 1, + "rg": 0, + "rb": 0, + "gr": 0, + "gg": 1, + "gb": 0, + "br": 0, + "bg": 0, + "bb": 1 + }, + "colorCurves": { + "blue": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + }, + "green": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + }, + "red": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + }, + "rgb": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + } + }, + "clampMin": 0, + "clampGamma": 1, + "clampMax": 1, + "quantizeEnabled": false, + "quantizeLevels": 256, + "colorMap": null + }, + "timeline": { + "duration": 5, + "loop": true, + "tracks": [] + } +} diff --git a/tests/composition/fixtures/existing-default-project.json b/tests/composition/fixtures/existing-default-project.json new file mode 100644 index 00000000..33dfad0a --- /dev/null +++ b/tests/composition/fixtures/existing-default-project.json @@ -0,0 +1,473 @@ +{ + "assets": [ + { + "duration": 5.041667, + "fileName": "aura.mp4", + "height": 720, + "id": "efb4eebe-d991-44c8-8f08-8fac18281a9b", + "kind": "video", + "mimeType": "video/mp4", + "sizeBytes": 629868, + "url": "/scenes/default/aura.mp4", + "width": 578 + }, + { + "duration": 59.996, + "fileName": "aura.ogg", + "height": null, + "id": "f4a5f106-8d90-46a2-b86f-b68a7a1cd5fb", + "kind": "audio", + "mimeType": "audio/ogg", + "sizeBytes": 779577, + "url": "/scenes/default/aura.ogg", + "width": null + } + ], + "audio": { + "bands": { + "bass": { + "attackMs": 219, + "gainDb": 0, + "highHz": 140, + "lowHz": 20, + "releaseMs": 560 + }, + "mid": { + "attackMs": 246, + "gainDb": 0, + "highHz": 2000, + "lowHz": 140, + "releaseMs": 493 + }, + "high": { + "attackMs": 202, + "gainDb": 0, + "highHz": 16000, + "lowHz": 2000, + "releaseMs": 424 + }, + "level": { + "attackMs": 264, + "gainDb": 0, + "highHz": 20000, + "lowHz": 20, + "releaseMs": 577 + } + }, + "links": [], + "offsetSeconds": 0, + "source": { + "assetId": "f4a5f106-8d90-46a2-b86f-b68a7a1cd5fb", + "kind": "asset" + } + }, + "composition": { + "height": 949, + "width": 1512 + }, + "exportedAt": "2026-08-14T15:16:56.006Z", + "format": "shader-lab", + "layers": [ + { + "assetId": null, + "blendMode": "normal", + "compositeMode": "filter", + "expanded": true, + "hue": 4, + "id": "eb6dda3c-1632-44c5-9a29-d4f382a09b8d", + "kind": "effect", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Displacement Map", + "opacity": 1, + "params": { + "channel": "luminance", + "direction": "both", + "midpoint": 0.23, + "strength": 82 + }, + "runtimeError": null, + "saturation": 2, + "type": "displacement-map", + "visible": true + }, + { + "assetId": null, + "blendMode": "screen", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "83cd9b9f-7584-4779-96e1-c387bfbf55e6", + "kind": "effect", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Bloom", + "opacity": 1, + "params": { + "bloomIntensity": 1.66, + "bloomKnee": 0.22, + "bloomRadius": 13.25, + "bloomSoftness": 0.92, + "bloomThreshold": 0.29, + "highlightDrive": 1.95 + }, + "runtimeError": null, + "saturation": 1, + "type": "bloom", + "visible": true + }, + { + "assetId": null, + "blendMode": "normal", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "a349a6a1-31db-45bc-bc48-f11a696eb206", + "kind": "effect", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Progressive Blur", + "opacity": 1, + "params": { + "angle": 0, + "end": 0.73, + "samples": 9, + "start": 0.59, + "strength": 20 + }, + "runtimeError": null, + "saturation": 1, + "type": "smear", + "visible": true + }, + { + "assetId": null, + "blendMode": "normal", + "compositeMode": "filter", + "expanded": true, + "hue": 2, + "id": "40c39149-a3e6-4abc-a5c2-f0f4c7e2e67b", + "kind": "effect", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "ASCII", + "opacity": 1, + "params": { + "bgOpacity": 0.18, + "boldness": 0.26, + "breakGrid": "off", + "breakThreshold": 0.06, + "charset": "light", + "colorMode": "source", + "columns": 358, + "customChars": " .:-=+*#%@", + "fontFamily": "mono", + "fontWeight": 400, + "invert": false, + "monoColor": "#f5f5f0", + "rowWarp": 0, + "signalBlackPoint": 0, + "signalWhitePoint": 1 + }, + "runtimeError": null, + "saturation": 1.35, + "type": "ascii", + "visible": true + }, + { + "assetId": null, + "blendMode": "lighten", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "d6b5aa25-7379-4ce9-9b1b-048aff0a0f81", + "kind": "source", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Pixel Trail", + "opacity": 1, + "params": { + "cellSize": 24, + "decay": 0.862, + "displaceAmount": 0.183, + "intensity": 1.9, + "radius": 0.07 + }, + "runtimeError": null, + "saturation": 1, + "type": "pixel-trail", + "visible": true + }, + { + "assetId": null, + "blendMode": "normal", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "89ce5095-45f8-46c7-86cb-bc6bb3ad7fff", + "kind": "effect", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Blob Tracking", + "opacity": 1, + "params": { + "blobAmount": 4, + "centerShape": "cross", + "connectLines": true, + "connectorArrows": false, + "connectorDashed": false, + "curvedLines": false, + "detectionMode": "luminance", + "innerEffectParams": "", + "innerEffectType": "none", + "invert": false, + "minBlobSize": 6, + "motionMaskThreshold": 0.08, + "motionPersistence": 0.82, + "motionThreshold": 0.36, + "outputMode": "decorated", + "persistentTracking": true, + "sensitivity": 0.72, + "shapeScale": 1, + "shapeType": "square", + "showLabels": true, + "showOutline": true, + "smoothing": 0.52, + "strokeColor": "#E6E6E6", + "strokeWidth": 2, + "trailDecay": 0.35 + }, + "runtimeError": null, + "saturation": 1, + "type": "blob-tracking", + "visible": true + }, + { + "assetId": null, + "blendMode": "normal", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "6bfa2084-cf57-4f8a-bb49-57cfa1c74b1c", + "kind": "effect", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Dithering", + "opacity": 1, + "params": { + "algorithm": "bayer-4x4", + "animateDither": false, + "chromaticSplit": false, + "colorMode": "source", + "ditherSpeed": 1, + "dotScale": 1, + "highlightColor": "#f5f2e8", + "levels": 3, + "monoColor": "#f5f5f0", + "pixelSize": 1, + "preset": "custom", + "shadowColor": "#101010", + "spread": 0.5 + }, + "runtimeError": null, + "saturation": 1, + "type": "dithering", + "visible": true + }, + { + "assetId": null, + "blendMode": "normal", + "compositeMode": "mask", + "expanded": true, + "hue": 0, + "id": "0e3136ed-a783-4527-888d-269caeb49a9f", + "kind": "source", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Text", + "opacity": 1, + "params": { + "anchor": "center", + "backgroundAlpha": 1, + "backgroundColor": "#000000", + "fontFamily": "sans", + "fontSize": 201, + "fontWeight": 800, + "letterSpacing": -0.1, + "offset": [ + 0, + 0 + ], + "text": "ShaderLab", + "textColor": "#ffffff" + }, + "runtimeError": null, + "saturation": 1, + "type": "text", + "visible": true + }, + { + "assetId": "efb4eebe-d991-44c8-8f08-8fac18281a9b", + "blendMode": "normal", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "cd60ee67-6fbe-41f2-9281-547a12823cc1", + "kind": "source", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Video", + "opacity": 1, + "params": { + "fitMode": "cover", + "offset": [ + 0, + 0 + ], + "playbackRate": 1, + "scale": 1.23 + }, + "runtimeError": null, + "saturation": 1, + "type": "video", + "visible": true + } + ], + "sceneConfig": { + "backgroundColor": "#080808", + "compositionAspect": "screen", + "compositionWidth": 1920, + "compositionHeight": 1080, + "exposure": 2.42, + "brightness": 0, + "contrast": 0.05, + "saturation": 1.12, + "vibrance": 0.1, + "hue": 9, + "temperature": 0, + "tint": 0, + "invert": false, + "channelMixer": { + "rr": 1, + "rg": 0, + "rb": 0, + "gr": 0, + "gg": 1, + "gb": 0, + "br": 0, + "bg": 0, + "bb": 1 + }, + "colorCurves": { + "blue": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + }, + "green": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + }, + "red": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + }, + "rgb": { + "points": [ + { + "x": 0, + "y": 0.01213968211206895 + }, + { + "x": 0.2680158943965517, + "y": 0.23698477909482762 + }, + { + "x": 0.4523504849137931, + "y": 0.5597218480603448 + }, + { + "x": 0.7020979256465517, + "y": 0.798760775862069 + }, + { + "x": 1, + "y": 1 + } + ] + } + }, + "clampMin": 0, + "clampGamma": 1, + "clampMax": 1, + "quantizeEnabled": false, + "quantizeLevels": 256, + "colorMap": null + }, + "selectedLayerId": "cd60ee67-6fbe-41f2-9281-547a12823cc1", + "timeline": { + "duration": 59.996, + "loop": true, + "tracks": [] + }, + "version": 6 +} diff --git a/tests/composition/fixtures/legacy-text-mask.json b/tests/composition/fixtures/legacy-text-mask.json new file mode 100644 index 00000000..d4134be9 --- /dev/null +++ b/tests/composition/fixtures/legacy-text-mask.json @@ -0,0 +1,177 @@ +{ + "format": "shader-lab", + "version": 6, + "composition": { + "width": 256, + "height": 192 + }, + "assets": [ + { + "id": "sample", + "kind": "image", + "fileName": "alpha-sample.svg", + "url": "/scenes/default/alpha-sample.svg", + "mimeType": "image/svg+xml", + "width": 160, + "height": 160, + "duration": null, + "sizeBytes": 570 + } + ], + "layers": [ + { + "assetId": null, + "blendMode": "normal", + "compositeMode": "mask", + "expanded": true, + "hue": 0, + "id": "text", + "kind": "source", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Text", + "opacity": 1, + "params": { + "text": "V3", + "anchor": "center", + "offset": [ + 0, + 0 + ], + "fontSize": 96, + "fontFamily": "mono", + "fontWeight": 400, + "letterSpacing": 0, + "textColor": "#ffffff", + "backgroundColor": "#000000", + "backgroundAlpha": 1 + }, + "runtimeError": null, + "saturation": 1, + "type": "text", + "visible": true + }, + { + "assetId": "sample", + "blendMode": "normal", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "image", + "kind": "source", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Image", + "opacity": 1, + "params": { + "fitMode": "contain", + "scale": 1, + "offset": [ + 0, + 0 + ], + "svgRasterResolution": "2048" + }, + "runtimeError": null, + "saturation": 1, + "type": "image", + "visible": true + } + ], + "selectedLayerId": "text", + "sceneConfig": { + "backgroundColor": "#080808", + "compositionAspect": "screen", + "compositionWidth": 1920, + "compositionHeight": 1080, + "exposure": 0, + "brightness": 0, + "contrast": 0, + "saturation": 1, + "vibrance": 0, + "hue": 0, + "temperature": 0, + "tint": 0, + "invert": false, + "channelMixer": { + "rr": 1, + "rg": 0, + "rb": 0, + "gr": 0, + "gg": 1, + "gb": 0, + "br": 0, + "bg": 0, + "bb": 1 + }, + "colorCurves": { + "blue": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + }, + "green": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + }, + "red": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + }, + "rgb": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + } + }, + "clampMin": 0, + "clampGamma": 1, + "clampMax": 1, + "quantizeEnabled": false, + "quantizeLevels": 256, + "colorMap": null + }, + "timeline": { + "duration": 5, + "loop": true, + "tracks": [] + } +} diff --git a/tests/composition/fixtures/solid-text-background.json b/tests/composition/fixtures/solid-text-background.json new file mode 100644 index 00000000..2cf18469 --- /dev/null +++ b/tests/composition/fixtures/solid-text-background.json @@ -0,0 +1,177 @@ +{ + "format": "shader-lab", + "version": 6, + "composition": { + "width": 256, + "height": 192 + }, + "assets": [ + { + "id": "sample", + "kind": "image", + "fileName": "alpha-sample.svg", + "url": "/scenes/default/alpha-sample.svg", + "mimeType": "image/svg+xml", + "width": 160, + "height": 160, + "duration": null, + "sizeBytes": 570 + } + ], + "layers": [ + { + "assetId": null, + "blendMode": "normal", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "text", + "kind": "source", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Text", + "opacity": 0.75, + "params": { + "text": "V3", + "anchor": "center", + "offset": [ + 0, + 0 + ], + "fontSize": 96, + "fontFamily": "mono", + "fontWeight": 400, + "letterSpacing": 0, + "textColor": "#ffffff", + "backgroundColor": "#000000", + "backgroundAlpha": 1 + }, + "runtimeError": null, + "saturation": 1, + "type": "text", + "visible": true + }, + { + "assetId": "sample", + "blendMode": "normal", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "image", + "kind": "source", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Image", + "opacity": 1, + "params": { + "fitMode": "contain", + "scale": 1, + "offset": [ + 0, + 0 + ], + "svgRasterResolution": "2048" + }, + "runtimeError": null, + "saturation": 1, + "type": "image", + "visible": true + } + ], + "selectedLayerId": "text", + "sceneConfig": { + "backgroundColor": "#080808", + "compositionAspect": "screen", + "compositionWidth": 1920, + "compositionHeight": 1080, + "exposure": 0, + "brightness": 0, + "contrast": 0, + "saturation": 1, + "vibrance": 0, + "hue": 0, + "temperature": 0, + "tint": 0, + "invert": false, + "channelMixer": { + "rr": 1, + "rg": 0, + "rb": 0, + "gr": 0, + "gg": 1, + "gb": 0, + "br": 0, + "bg": 0, + "bb": 1 + }, + "colorCurves": { + "blue": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + }, + "green": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + }, + "red": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + }, + "rgb": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + } + }, + "clampMin": 0, + "clampGamma": 1, + "clampMax": 1, + "quantizeEnabled": false, + "quantizeLevels": 256, + "colorMap": null + }, + "timeline": { + "duration": 5, + "loop": true, + "tracks": [] + } +} diff --git a/tests/composition/fixtures/transparent-text-screen.json b/tests/composition/fixtures/transparent-text-screen.json new file mode 100644 index 00000000..96d0c2de --- /dev/null +++ b/tests/composition/fixtures/transparent-text-screen.json @@ -0,0 +1,177 @@ +{ + "format": "shader-lab", + "version": 6, + "composition": { + "width": 256, + "height": 192 + }, + "assets": [ + { + "id": "sample", + "kind": "image", + "fileName": "alpha-sample.svg", + "url": "/scenes/default/alpha-sample.svg", + "mimeType": "image/svg+xml", + "width": 160, + "height": 160, + "duration": null, + "sizeBytes": 570 + } + ], + "layers": [ + { + "assetId": null, + "blendMode": "screen", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "text", + "kind": "source", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Text", + "opacity": 1, + "params": { + "text": "V3", + "anchor": "center", + "offset": [ + 0, + 0 + ], + "fontSize": 96, + "fontFamily": "mono", + "fontWeight": 400, + "letterSpacing": 0, + "textColor": "#ff713d", + "backgroundColor": "#000000", + "backgroundAlpha": 0 + }, + "runtimeError": null, + "saturation": 1, + "type": "text", + "visible": true + }, + { + "assetId": "sample", + "blendMode": "normal", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "image", + "kind": "source", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Image", + "opacity": 1, + "params": { + "fitMode": "contain", + "scale": 1, + "offset": [ + 0, + 0 + ], + "svgRasterResolution": "2048" + }, + "runtimeError": null, + "saturation": 1, + "type": "image", + "visible": true + } + ], + "selectedLayerId": "text", + "sceneConfig": { + "backgroundColor": "#080808", + "compositionAspect": "screen", + "compositionWidth": 1920, + "compositionHeight": 1080, + "exposure": 0, + "brightness": 0, + "contrast": 0, + "saturation": 1, + "vibrance": 0, + "hue": 0, + "temperature": 0, + "tint": 0, + "invert": false, + "channelMixer": { + "rr": 1, + "rg": 0, + "rb": 0, + "gr": 0, + "gg": 1, + "gb": 0, + "br": 0, + "bg": 0, + "bb": 1 + }, + "colorCurves": { + "blue": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + }, + "green": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + }, + "red": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + }, + "rgb": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + } + }, + "clampMin": 0, + "clampGamma": 1, + "clampMax": 1, + "quantizeEnabled": false, + "quantizeLevels": 256, + "colorMap": null + }, + "timeline": { + "duration": 5, + "loop": true, + "tracks": [] + } +} diff --git a/tests/composition/gradient-map-ui.mjs b/tests/composition/gradient-map-ui.mjs new file mode 100644 index 00000000..c42c9029 --- /dev/null +++ b/tests/composition/gradient-map-ui.mjs @@ -0,0 +1,199 @@ +// Start the dev server, then bun tests/composition/gradient-map-ui.mjs. +import assert from "node:assert/strict" +import { mkdir } from "node:fs/promises" +import { chromium } from "playwright" +import { createLayer } from "@/lib/editor/layers" +import { + GRADIENT_MAP_PRESETS, + parseGradientMapStops, + serializeGradientMapStops, +} from "@/renderer/color-map-lut" +import { DEFAULT_SCENE_CONFIG } from "@/types/editor" +await mkdir(".context", { recursive: true }) +const base = createLayer("gradient") +await Bun.write( + ".context/gradient-map-ui-fixture.lab", + JSON.stringify({ + format: "shader-lab", + version: 7, + composition: { width: 720, height: 960 }, + selectedLayerId: "map", + assets: [], + layers: [ + { ...createLayer("gradient-map"), id: "map", name: "Gradient Map" }, + { + ...base, + id: "field", + name: "Field", + params: { ...base.params, animate: false }, + }, + ], + sceneConfig: { ...DEFAULT_SCENE_CONFIG, backgroundColor: "#f5f2ee" }, + timeline: { duration: 5, loop: true, tracks: [] }, + }) +) +const browser = await chromium.launch({ + headless: true, + args: [ + "--enable-unsafe-webgpu", + "--use-webgpu-adapter=swiftshader", + "--use-angle=swiftshader", + "--enable-unsafe-swiftshader", + ], +}) +try { + const page = await browser.newPage({ viewport: { width: 1440, height: 960 } }) + const errors = [] + page.on("pageerror", (error) => errors.push(error.message)) + await page.goto(process.env.SHADER_LAB_URL ?? "http://localhost:55000", { + waitUntil: "domcontentloaded", + timeout: 120000, + }) + const panel = page.locator('[data-layer-sidebar-panel="true"]:visible') + await panel.waitFor({ timeout: 120000 }) + async function ready() { + await page.waitForFunction( + () => + [...document.querySelectorAll('[class*="loader-bounce"]')].every( + (loader) => { + for (let node = loader; node; node = node.parentElement) { + const style = getComputedStyle(node) + if ( + style.display === "none" || + style.visibility === "hidden" || + Number(style.opacity) === 0 + ) + return true + } + return false + } + ), + null, + { timeout: 120000 } + ) + } + await ready() + async function importFile(path) { + await page + .getByRole("button", { name: "Export", exact: true }) + .filter({ visible: true }) + .click() + await page.getByRole("button", { name: "project", exact: true }).click() + await page + .locator('input[accept=".lab,application/json"]') + .last() + .setInputFiles(path) + await page.getByRole("dialog").waitFor({ state: "hidden" }) + await panel.locator('[data-layer-row="map"]').waitFor() + await ready() + } + async function save(name) { + await page + .getByRole("button", { name: "Export", exact: true }) + .filter({ visible: true }) + .click() + await page.getByRole("button", { name: "project", exact: true }).click() + const downloaded = page.waitForEvent("download") + await page + .getByRole("button", { name: "Export .lab file", exact: true }) + .click() + const download = await downloaded + const path = `.context/gradient-map-${name}.lab` + await download.saveAs(path) + await page.keyboard.press("Escape") + await page.getByRole("dialog").waitFor({ state: "hidden" }) + return await Bun.file(path).json() + } + const stopsOf = (project, id = "map") => + serializeGradientMapStops( + parseGradientMapStops(project.layers.find((l) => l.id === id).params.stops) + ) + const presetStops = (id) => + serializeGradientMapStops(GRADIENT_MAP_PRESETS.find((p) => p.id === id).stops) + + await importFile(".context/gradient-map-ui-fixture.lab") + await panel + .locator('[data-layer-row="map"]') + .getByText("Gradient Map", { exact: true }) + .click() + const section = page + .locator('[data-gradient-map-section="true"]') + .filter({ visible: true }) + const preset = section.getByRole("combobox", { name: "Gradient map preset" }) + await preset.filter({ hasText: /^Thermal$/ }).waitFor() + assert.equal(await section.count(), 1) + await page.getByRole("slider", { name: /^Amount/ }).filter({ visible: true }).waitFor() + assert.equal( + await page.getByRole("textbox", { name: /^Ramp/ }).count(), + 0, + "Raw stops text field must stay hidden" + ) + assert.equal(stopsOf(await save("initial")), presetStops("thermal")) + + await preset.click() + await page.getByRole("option", { name: "Sepia", exact: true }).click() + await page.waitForTimeout(400) + assert.equal(stopsOf(await save("sepia")), presetStops("sepia")) + await preset.filter({ hasText: /^Sepia$/ }).waitFor() + + const bar = section.locator(".cursor-crosshair") + const box = await bar.boundingBox() + const middle = bar.locator("button").nth(1) + const mb = await middle.boundingBox() + await page.mouse.move(mb.x + mb.width / 2, mb.y + mb.height / 2) + await page.mouse.down() + await page.mouse.move(box.x + box.width * 0.7, mb.y + mb.height / 2, { steps: 6 }) + await page.mouse.move(box.x + box.width * 0.9, mb.y + mb.height / 2, { steps: 6 }) + await page.mouse.up() + await page.waitForTimeout(400) + const dragged = parseGradientMapStops( + (await save("dragged")).layers[0].params.stops + ) + const moved = dragged.find((s) => s.color === "#8c6a3f") + const light = dragged.find((s) => s.color === "#f1e4c6") + assert.ok(moved.position > 0.85, `Dragged stop must pass the light stop (${moved.position})`) + assert.equal(light.position, 1, "The stop being passed must not move") + assert.equal(dragged.find((s) => s.color === "#2b1d0e").position, 0) + await page.keyboard.press("Meta+z") + await page.waitForTimeout(400) + assert.equal(stopsOf(await save("drag-undo")), presetStops("sepia")) + await page.mouse.click(box.x + box.width * 0.8, box.y + box.height / 2) + await page.waitForTimeout(400) + await preset.filter({ hasText: /^Custom$/ }).waitFor() + const custom = await save("custom") + assert.equal(parseGradientMapStops(custom.layers[0].params.stops).length, 4) + await page.keyboard.press("Meta+z") + await page.waitForTimeout(400) + assert.equal(stopsOf(await save("undo")), presetStops("sepia"), "Undo removes the added stop") + + const add = panel.getByRole("button", { name: "Add layer", exact: true }) + await add.click() + const menu = await add.getAttribute("aria-controls") + await page + .locator(`[id="${menu}"]`) + .getByRole("button", { name: /Gradient Map/ }) + .first() + .click() + await page.waitForFunction( + () => + document.querySelectorAll( + '[data-layer-sidebar-panel="true"] [data-layer-row]' + ).length >= 3 + ) + await ready() + const added = await save("added") + assert.equal( + added.layers.filter((l) => l.type === "gradient-map").length, + 2, + "Picker adds a Gradient Map" + ) + await page.screenshot({ path: ".context/gradient-map-ui.png" }) + await importFile(".context/gradient-map-undo.lab") + assert.equal(stopsOf(await save("reopened")), presetStops("sepia")) + assert.deepEqual(errors, []) + console.log( + "PASS gradient map section, presets, ramp stop add/undo, picker entry, actual save/reopen" + ) +} finally { + await browser.close() +} diff --git a/tests/composition/gradient-map.mjs b/tests/composition/gradient-map.mjs new file mode 100644 index 00000000..4963844c --- /dev/null +++ b/tests/composition/gradient-map.mjs @@ -0,0 +1,359 @@ +import { GradientMapPass as RuntimeGradientMap } from "@runtime/renderer/gradient-map-pass" +import { buildRendererFrame as runtimeFrame } from "@runtime/renderer/contracts" +import { createHeadlessRenderer } from "@runtime/renderer/create-headless-renderer" +import { float, texture, uv, vec2 } from "three/tsl" +import * as THREE from "three/webgpu" +import { GradientMapPass } from "@/renderer/gradient-map-pass" +import { + buildColorMapBytes, + canonicalGradientMapStops, + DEFAULT_GRADIENT_MAP_STOPS, + evaluateGradientMapStops, + GRADIENT_MAP_PRESETS, + hexToRgb, + parseGradientMapStops, + serializeGradientMapStops, +} from "@/renderer/color-map-lut" +import { createLayer } from "@/lib/editor/layers" +import { + applyLabProjectFile, + buildLabProjectFile, + parseLabProjectFileValue, +} from "@/lib/editor/project-file" +import { buildShaderExportConfig } from "@/lib/editor/shader-export" +import { + applyEditorHistorySnapshot, + buildEditorHistorySnapshot, +} from "@/lib/editor/history" +import { getLayerCatalogEntry } from "@/lib/editor/config/layer-catalog" +import { useLayerStore } from "@/store/layer-store" +import { DEFAULT_LAYER_MASK, DEFAULT_SCENE_CONFIG } from "@/types/editor" + +function assert(value, label) { + if (!value) throw new Error(label) +} +function close(actual, expected, label, tolerance = 0.01) { + assert(actual.length === expected.length, `${label}: wrong size`) + actual.forEach((value, i) => { + assert( + Number.isFinite(value) && Math.abs(value - expected[i]) <= tolerance, + `${label}: ${i} got ${value}, expected ${expected[i]}` + ) + }) +} +const linear = (v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4) +const encode = (v) => (v <= 0.0031308 ? v * 12.92 : 1.055 * v ** (1 / 2.4) - 0.055) +const linearHex = (hex) => hexToRgb(hex).map(linear) +const N = 64 +const index = (v) => Math.min(N - 1, Math.max(0, Math.round((v + 0.5) * N - 0.5))) + +function unitChecks() { + const thermal = serializeGradientMapStops(DEFAULT_GRADIENT_MAP_STOPS) + for (const invalid of [undefined, "", "not json", "[]", '[{"color":"#fff","position":0}]', 42]) + assert( + serializeGradientMapStops(parseGradientMapStops(invalid)) === thermal, + `Invalid stops must fall back to Thermal: ${String(invalid)}` + ) + const parsed = parseGradientMapStops( + '[{"position":2,"color":"#FF0000"},{"position":-1,"color":"#0000ff"},{"position":0.5,"color":"bad"}]' + ) + assert( + parsed.length === 2 && + parsed[0].color === "#ff0000" && + parsed[0].position === 1 && + parsed[1].position === 0, + "Stops must clamp and drop invalid colors while keeping authored order" + ) + close( + evaluateGradientMapStops(parsed, 0.25), + [0.25, 0, 0.75], + "Evaluation sorts unsorted stops" + ) + assert( + canonicalGradientMapStops(parsed) === canonicalGradientMapStops([...parsed].reverse()), + "Canonical form ignores authored order" + ) + const duotone = GRADIENT_MAP_PRESETS.find((p) => p.id === "duotone").stops + assert( + serializeGradientMapStops(parseGradientMapStops(serializeGradientMapStops(duotone))) === + serializeGradientMapStops(duotone), + "Serialize/parse roundtrip" + ) + close(evaluateGradientMapStops(duotone, 0), hexToRgb("#1a1040"), "Start stop") + close(evaluateGradientMapStops(duotone, 1), hexToRgb("#ff7a59"), "End stop") + const bytes = buildColorMapBytes([ + { position: 0, color: "#000000" }, + { position: 1, color: "#ffffff" }, + ]) + assert(bytes.length === 1024 && bytes[0] === 0 && bytes[1020] === 255 && bytes[3] === 255, "Byte LUT") + const identity = buildColorMapBytes([]) + assert(identity[4 * 128] === 128 && identity[4 * 128 + 1] === 128, "Empty stops keep identity bytes") + assert( + getLayerCatalogEntry("gradient-map").label === "Gradient Map" && + getLayerCatalogEntry("gradient-map").category === "core", + "Catalog entry" + ) + assert(createLayer("gradient-map").params.stops === thermal, "New layers start with Thermal") + return 10 +} + +async function passChecks() { + const renderer = new THREE.WebGPURenderer({ antialias: false }) + await renderer.init() + renderer.toneMapping = THREE.NoToneMapping + const ramp = new Float32Array(N * 4) + for (let x = 0; x < N; x++) ramp.set([x / (N - 1), x / (N - 1), x / (N - 1), 0.6], x * 4) + const input = new THREE.DataTexture(ramp, N, 1, THREE.RGBAFormat, THREE.FloatType) + input.needsUpdate = true + const target = new THREE.RenderTarget(N, 1, { type: THREE.FloatType, depthBuffer: false }) + const results = {} + let samples = 0 + const thermal = DEFAULT_GRADIENT_MAP_STOPS + try { + for (const [name, Pass] of [ + ["editor", GradientMapPass], + ["runtime", RuntimeGradientMap], + ]) { + const pass = new Pass(`gradient-map-${name}`) + pass.updateCompositionRole("effect") + pass.flushColorNode() + const render = async (params) => { + pass.resize(N, 1) + pass.updateParams(params) + pass.render(renderer, input, target, 0, 0) + const pixels = Array.from(await renderer.readRenderTargetPixelsAsync(target, 0, 0, N, 1)) + return (t) => { + const i = Math.round(t * (N - 1)) * 4 + return pixels.slice(i, i + 4) + } + } + const check = (label, actual, expected, tolerance) => { + close(actual, expected, `${name}: ${label}`, tolerance) + results[label] ??= {} + results[label][name] = actual + samples++ + } + const sampled = (t) => Math.round(t * (N - 1)) / (N - 1) + const expected = (t, stops, amount = 1) => { + const v = sampled(t) + return evaluateGradientMapStops(stops, encode(v)) + .map(linear) + .map((c) => v + (c - v) * amount) + } + let at = await render({}) + check("thermal dark end", at(0), [...linearHex("#08083a"), 0.6], 0.02) + check("thermal light end", at(1), [...linearHex("#ff2e63"), 0.6], 0.02) + check("thermal middle", at(0.5), [...expected(0.5, thermal), 0.6], 0.03) + const midGray = linear(0.5) + check( + "perceptual mid gray hits ramp center", + at(midGray), + [...expected(midGray, thermal), 0.6], + 0.03 + ) + assert( + Math.abs(encode(sampled(midGray)) - 0.5) < 0.02, + "Mid gray must map near the ramp center" + ) + at = await render({ amount: 0 }) + check("amount zero passthrough", at(0.5), [sampled(0.5), sampled(0.5), sampled(0.5), 0.6], 0.01) + at = await render({ amount: 0.5 }) + check("amount half", at(0.5).slice(0, 3), expected(0.5, thermal, 0.5), 0.03) + at = await render({ invert: true }) + check("invert dark end", at(0), [...linearHex("#ff2e63"), 0.6], 0.02) + const duotone = GRADIENT_MAP_PRESETS.find((p) => p.id === "duotone").stops + at = await render({ stops: serializeGradientMapStops(duotone) }) + check("custom stops start", at(0), [...linearHex("#1a1040"), 0.6], 0.02) + check("custom stops end", at(1), [...linearHex("#ff7a59"), 0.6], 0.02) + at = await render({ stops: "garbage" }) + check("invalid stops fall back", at(1), [...linearHex("#ff2e63"), 0.6], 0.02) + pass.dispose() + } + for (const [label, entry] of Object.entries(results)) + close(entry.editor, entry.runtime, `parity: ${label}`, 0.002) + samples += Object.keys(results).length + } finally { + target.dispose() + input.dispose() + renderer.dispose() + } + return samples +} + +function solid(id, color, opacity = 1) { + const layer = createLayer("gradient") + return { + ...layer, + id, + opacity, + params: { + ...layer.params, + animate: false, + tonemapMode: "none", + grainAmount: 0, + glowStrength: 0, + vignetteStrength: 0, + ...Object.fromEntries([1, 2, 3, 4, 5].map((i) => [`point${i}Color`, color])), + }, + } +} + +async function runtimeRender(config) { + const renderer = new THREE.WebGPURenderer({ antialias: false }) + await renderer.init() + const compiling = [] + const compile = renderer.compileAsync.bind(renderer) + renderer.compileAsync = (...args) => { + const pending = compile(...args) + compiling.push(pending) + return pending + } + const headless = createHeadlessRenderer({ renderer, size: config.composition }) + const target = new THREE.RenderTarget(N, N, { type: THREE.FloatType, depthBuffer: false }) + const material = new THREE.MeshBasicNodeMaterial({ blending: THREE.NoBlending }) + const scene = new THREE.Scene() + scene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material)) + try { + await headless.initialize() + const frame = runtimeFrame(config, 0, 0, 1, config.composition) + headless.render(frame) + while (compiling.length) await Promise.all(compiling.splice(0)) + material.colorNode = texture(headless.render(frame), vec2(uv().x, float(1).sub(uv().y))) + renderer.setRenderTarget(target) + renderer.render(scene, new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1)) + return Array.from(await renderer.readRenderTargetPixelsAsync(target, 0, 0, N, N)) + } finally { + headless.dispose() + target.dispose() + renderer.dispose() + } +} + +export async function checkGradientMap(renderProject) { + let samples = unitChecks() + samples += await passChecks() + + const group = { ...createLayer("group"), id: "portrait" } + const map = { + ...createLayer("gradient-map"), + id: "map", + parentId: group.id, + mask: { ...DEFAULT_LAYER_MASK, shape: "ellipse", size: [0.5, 0.5], feather: 0 }, + } + const project = { + format: "shader-lab", + version: 7, + assets: [], + layers: [ + { ...solid("blue", "#0000ff", 0.5), mask: { ...DEFAULT_LAYER_MASK, shape: "rectangle", center: [0.35, -0.35], size: [0.3, 0.3], feather: 0 } }, + group, + map, + { ...solid("gray", "#808080"), parentId: group.id }, + ], + selectedLayerId: map.id, + composition: { width: N, height: N }, + sceneConfig: DEFAULT_SCENE_CONFIG, + timeline: { duration: 1, loop: true, tracks: [] }, + } + applyLabProjectFile(parseLabProjectFileValue(project), []) + const store = () => useLayerStore.getState() + const before = buildEditorHistorySnapshot() + const sepia = GRADIENT_MAP_PRESETS.find((p) => p.id === "sepia").stops + store().updateLayerParam(map.id, "stops", serializeGradientMapStops(sepia)) + store().updateLayerParam(map.id, "amount", 0.4) + assert( + parseGradientMapStops(store().getLayerById(map.id).params.stops)[1].color === "#8c6a3f", + "Ramp edits must reach the store" + ) + applyEditorHistorySnapshot(before) + assert( + store().getLayerById(map.id).params.stops === serializeGradientMapStops(DEFAULT_GRADIENT_MAP_STOPS) && + store().getLayerById(map.id).params.amount === 1, + "History lost gradient map settings" + ) + const duplicateId = store().duplicateLayer(map.id) + store().updateLayerParam(duplicateId, "invert", true) + assert(store().getLayerById(map.id).params.invert === false, "Duplicate must be independent") + applyEditorHistorySnapshot(before) + const saved = buildLabProjectFile() + store().replaceState([]) + applyLabProjectFile(parseLabProjectFileValue(JSON.parse(JSON.stringify(saved))), []) + const reopened = buildLabProjectFile() + const reopenedMap = reopened.layers.find((l) => l.id === map.id) + assert( + reopenedMap.type === "gradient-map" && + reopenedMap.params.stops === map.params.stops && + reopenedMap.mask.shape === "ellipse", + "Save/reopen changed the gradient map" + ) + const config = buildShaderExportConfig(reopened) + assert(config.layers.find((l) => l.id === map.id).type === "gradient-map", "Shader export type") + samples += 4 + + const first = await renderProject(saved) + const at = (image, x, y) => { + const i = (index(y) * N + index(x)) * 4 + return Array.from(image.data.slice(i, i + 4)) + } + const center = at(first.image, 0, 0) + assert( + center[2] > center[0] + 40 && center[3] === 255, + `Mapped gray must turn blue-ish inside the mask, got ${center}` + ) + close(at(first.image, 0.4, 0.4), [128, 128, 128, 255], "Unmasked gray keeps its color", 2) + const hiddenMap = { + ...saved, + layers: saved.layers.map((l) => (l.id === map.id ? { ...l, visible: false } : l)), + } + const unmapped = await renderProject(hiddenMap) + close(at(first.image, -0.45, -0.45), at(unmapped.image, -0.45, -0.45), "Outside the mask nothing changes", 1) + close(at(first.image, 0.45, -0.4), at(unmapped.image, 0.45, -0.4), "External top layer stays identical", 1) + const changed = at(first.image, 0, 0) + .slice(0, 3) + .reduce((sum, v, i) => sum + Math.abs(v - at(unmapped.image, 0, 0)[i]), 0) + assert(changed > 60, `Inside the mask the map must change the image (${changed})`) + const restored = await renderProject(reopened) + close(Array.from(restored.image.data), Array.from(first.image.data), "Reopened pixels", 0) + const runtimePixels = await runtimeRender(config) + close( + runtimePixels, + Array.from(first.image.data, (value, i) => { + const v = value / 255 + return i % 4 === 3 ? v : linear(v) + }), + "Exported runtime gradient map parity", + 0.02 + ) + samples += 4 + + const asset = { + id: "photo-asset", + kind: "image", + url: "/scenes/default/rings-photo.webp", + fileName: "slice.webp", + width: 1512, + height: 908, + } + const art = { + ...saved, + composition: { width: 480, height: 600 }, + assets: [asset], + layers: [ + { ...createLayer("gradient-map"), id: "preview-map" }, + { ...createLayer("image"), id: "photo", assetId: asset.id }, + ], + selectedLayerId: "preview-map", + sceneConfig: { ...DEFAULT_SCENE_CONFIG, backgroundColor: "#08083a" }, + } + const preview = await renderProject(art) + const colors = new Set() + for (let i = 0; i < preview.image.data.length; i += 4) + colors.add(preview.image.data.slice(i, i + 3).join(",")) + assert(colors.size > 100, "Gradient map preview is blank") + const thumbnail = document.createElement("canvas") + thumbnail.width = preview.image.width + thumbnail.height = preview.image.height + thumbnail.getContext("2d").putImageData(preview.image, 0, 0) + samples++ + return { samples, png: first.png, previewWebp: thumbnail.toDataURL("image/webp", 0.85) } +} diff --git a/tests/composition/group-drag-ui.mjs b/tests/composition/group-drag-ui.mjs new file mode 100644 index 00000000..a52cb87e --- /dev/null +++ b/tests/composition/group-drag-ui.mjs @@ -0,0 +1,296 @@ +// Run against the local editor: SHADER_LAB_URL=http://localhost:55000 bun tests/composition/group-drag-ui.mjs +import assert from "node:assert/strict" +import { mkdir, readFile } from "node:fs/promises" +import { chromium } from "playwright" + +const fixture = JSON.parse( + await readFile( + new URL("./fixtures/solid-text-background.json", import.meta.url), + "utf8" + ) +) +const template = fixture.layers[0] +function layer(id, parentId = null, group = false, expanded = true) { + return { + ...template, + id, + name: id, + parentId, + kind: group ? "group" : "source", + type: group ? "group" : "text", + expanded, + opacity: 1, + params: group ? {} : { ...template.params, text: id, backgroundAlpha: 0 }, + } +} +Object.assign(fixture, { + version: 7, + assets: [], + selectedLayerId: "Child", + layers: [ + layer("Group A", null, true), + layer("Child", "Group A"), + layer("Group B", null, true, false), + layer("Outside"), + ], + timeline: { duration: 1, loop: true, tracks: [] }, +}) +const browser = await chromium.launch({ + headless: true, + args: [ + "--enable-unsafe-webgpu", + "--use-webgpu-adapter=swiftshader", + "--use-angle=swiftshader", + "--enable-unsafe-swiftshader", + ], +}) +await mkdir(".context/group-drag-test", { recursive: true }) +try { + const page = await browser.newPage({ viewport: { width: 1440, height: 960 } }) + const errors = [] + page.on("pageerror", (error) => errors.push(error.message)) + await page.goto(process.env.SHADER_LAB_URL ?? "http://localhost:55000", { + waitUntil: "domcontentloaded", + timeout: 120000, + }) + const panel = page.locator('[data-layer-sidebar-panel="true"]:visible') + await panel.waitFor({ timeout: 120000 }) + async function waitForCanvas() { + await page.waitForFunction( + () => + [...document.querySelectorAll('[class*="loader-bounce"]')].every( + (loader) => { + for (let node = loader; node; node = node.parentElement) { + const style = getComputedStyle(node) + if ( + style.display === "none" || + style.visibility === "hidden" || + Number(style.opacity) === 0 + ) + return true + } + return false + } + ), + null, + { timeout: 120000 } + ) + } + await waitForCanvas() + async function projectDialog() { + await page + .getByRole("button", { name: "Export", exact: true }) + .filter({ visible: true }) + .click() + await page.getByRole("button", { name: "project", exact: true }).click() + } + async function loadProject(project) { + await projectDialog() + await page + .locator('input[accept=".lab,application/json"]') + .last() + .setInputFiles({ + name: "group-drag.lab", + mimeType: "application/json", + buffer: Buffer.from(JSON.stringify(project)), + }) + await panel.locator('[data-layer-row="Child"]').waitFor() + await waitForCanvas() + await page.waitForTimeout(400) + } + const row = (id) => panel.locator(`[data-layer-row="${id}"]`) + const item = (id) => panel.locator(`[data-layer-item="${id}"]`) + const parent = (id) => + item(id).evaluate( + (node) => + node.parentElement.closest("[data-layer-item]")?.dataset.layerItem ?? + null + ) + async function drag(id, targetId, placement, check, left = false) { + const handle = panel.getByRole("button", { + name: `Reorder ${id}`, + exact: true, + }) + await handle.scrollIntoViewIfNeeded() + await handle.hover() + const from = await handle.boundingBox() + const to = await row(targetId).boundingBox() + const x = left ? to.x - 8 : to.x + to.width / 2 + let y = to.y + to.height / 2 + if (placement === "before") y = to.y + 2 + if (placement === "after") y = to.y + to.height - 2 + await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2) + await page.mouse.down() + await page.mouse.move(x, y, { steps: 10 }) + if (check) await check() + await page.mouse.up() + } + await loadProject(fixture) + console.log("Loaded group drag fixture") + await drag("Child", "Group B", "inside", async () => { + assert.equal( + await parent("Child"), + "Group A", + "Membership changed before drop" + ) + assert.equal(await row("Group B").getAttribute("data-drop-inside"), "true") + await page.screenshot({ + path: ".context/group-drag-test/inside-preview.png", + }) + }) + assert.equal(await parent("Child"), "Group B") + await panel + .getByRole("button", { name: "Collapse Group B", exact: true }) + .waitFor() + await page.keyboard.press("Meta+z") + assert.equal(await parent("Child"), "Group A", "UI undo failed") + await page.keyboard.press("Meta+Shift+z") + assert.equal(await parent("Child"), "Group B", "UI redo failed") + await drag("Child", "Outside", "before") + assert.equal( + await parent("Child"), + null, + "Child remained in group after root drop" + ) + await drag("Outside", "Group A", "inside") + assert.equal(await parent("Outside"), "Group A") + await drag("Child", "Outside", "after") + assert.equal( + await parent("Child"), + "Group A", + "Drop beside child did not enter group" + ) + await drag("Group A", "Group B", "inside") + assert.equal(await parent("Group A"), "Group B") + assert.equal(await parent("Child"), "Group A", "Moving group lost child") + await drag("Group B", "Group A", "inside", async () => { + assert.equal( + await row("Group A").getAttribute("data-drop-inside"), + null, + "Cycle was advertised as valid" + ) + }) + assert.equal(await parent("Group B"), null, "Cycle committed") + await drag( + "Child", + "Outside", + "after", + async () => { + await page.keyboard.press("Escape") + }, + true + ) + assert.equal(await parent("Child"), "Group A", "Escape committed a drop") + await drag("Child", "Outside", "after", null, true) + assert.equal( + await parent("Child"), + "Group B", + "Left gutter failed to outdent one level" + ) + await page.screenshot({ path: ".context/group-drag-test/nested-result.png" }) + + await projectDialog() + const downloaded = page.waitForEvent("download") + await page + .getByRole("button", { name: "Export .lab file", exact: true }) + .click() + const saved = JSON.parse( + await readFile(await (await downloaded).path(), "utf8") + ) + assert.equal( + saved.layers.find((entry) => entry.id === "Child").parentId, + "Group B", + "Saved membership disagrees with UI" + ) + await page + .getByRole("button", { name: "Close export dialog", exact: true }) + .filter({ visible: true }) + .last() + .click() + await loadProject(saved) + assert.equal( + await parent("Child"), + "Group B", + "Reopened membership disagrees with saved file" + ) + await page.setViewportSize({ width: 390, height: 844 }) + await page.getByRole("button", { name: "Layers", exact: true }).click() + await drag("Child", "Outside", "after", async () => { + await page.screenshot({ + path: ".context/group-drag-test/mobile-preview.png", + }) + }) + assert.equal( + await parent("Child"), + "Group A", + "Mobile drop into group failed" + ) + await page.screenshot({ path: ".context/group-drag-test/mobile-result.png" }) + // Real touch pointer capture, including the release that commits the move. + const cdp = await page.context().newCDPSession(page) + await cdp.send("Emulation.setTouchEmulationEnabled", { enabled: true }) + const touchFrom = await panel + .getByRole("button", { name: "Reorder Child", exact: true }) + .boundingBox() + const touchTo = await row("Group B").boundingBox() + await cdp.send("Input.dispatchTouchEvent", { + type: "touchStart", + touchPoints: [{ x: touchFrom.x + 8, y: touchFrom.y + 8, id: 1 }], + }) + await cdp.send("Input.dispatchTouchEvent", { + type: "touchMove", + touchPoints: [ + { x: touchTo.x + 100, y: touchTo.y + touchTo.height - 2, id: 1 }, + ], + }) + await cdp.send("Input.dispatchTouchEvent", { + type: "touchEnd", + touchPoints: [], + }) + assert.equal(await parent("Child"), null, "Touch drop failed to leave group") + await cdp.send("Emulation.setTouchEmulationEnabled", { enabled: false }) + + await page.setViewportSize({ width: 1440, height: 960 }) + const longProject = { + ...fixture, + layers: [ + fixture.layers[0], + fixture.layers[1], + ...Array.from({ length: 18 }, (_, i) => layer(`Extra ${i}`)), + fixture.layers[2], + ], + } + await loadProject(longProject) + const scrollRoot = panel.locator("[data-layer-tree-root]") + const scrollHandle = panel.getByRole("button", { + name: "Reorder Child", + exact: true, + }) + await scrollHandle.hover() + const start = await scrollHandle.boundingBox() + const list = await scrollRoot.boundingBox() + await page.mouse.move(start.x + 8, start.y + 8) + await page.mouse.down() + await page.mouse.move(list.x + list.width / 2, list.y + list.height - 3, { + steps: 10, + }) + await page.waitForFunction(() => { + const roots = [...document.querySelectorAll("[data-layer-tree-root]")] + const root = roots.find((node) => node.getBoundingClientRect().width > 0) + return root && root.scrollTop > 450 + }) + await page.mouse.move(800, 400) + await page.mouse.up() + assert.equal( + await parent("Child"), + "Group A", + "Release outside scrolling list committed a drop" + ) + console.log("PASS touch drop and long-list edge scroll/outside cancellation") + assert.deepEqual(errors, [], "Browser errors") + console.log( + "PASS desktop/mobile group drops, hierarchy preview, cycles, outdent, Escape, undo/redo, real .lab save/reopen" + ) +} finally { + await browser.close() +} diff --git a/tests/composition/groups.mjs b/tests/composition/groups.mjs new file mode 100644 index 00000000..4c69d3fd --- /dev/null +++ b/tests/composition/groups.mjs @@ -0,0 +1,524 @@ +import { PipelineManager as RuntimePipeline } from "@runtime/renderer/pipeline-manager" +import { createHeadlessRenderer } from "@runtime/renderer/create-headless-renderer" +import { + flattenComposition as flattenRuntime, + reverseComposition as reverseRuntime, +} from "@runtime/renderer/composition-tree" +import { texture } from "three/tsl" +import * as THREE from "three/webgpu" +import { createLayer } from "@/lib/editor/layers" +import { + flattenComposition, + reverseComposition, +} from "@/renderer/composition-tree" +import { createWebGPURenderer } from "@/renderer/create-webgpu-renderer" +import { PipelineManager as EditorPipeline } from "@/renderer/pipeline-manager" +import { DEFAULT_SCENE_CONFIG } from "@/types/editor" + +function assert(condition, message) { + if (!condition) throw new Error(message) +} + +function close(actual, expected, label, tolerance = 0.002) { + assert( + actual.length === expected.length && + actual.every( + (value, i) => + Number.isFinite(value) && Math.abs(value - expected[i]) <= tolerance + ), + `${label}: expected ${expected}, received ${actual}` + ) +} + +function solid(id, color, opacity = 1) { + const layer = createLayer("gradient") + return { + ...layer, + id, + opacity, + params: { + ...layer.params, + animate: false, + tonemapMode: "none", + grainAmount: 0, + glowStrength: 0, + vignetteStrength: 0, + ...Object.fromEntries( + [1, 2, 3, 4, 5].map((i) => [`point${i}Color`, color]) + ), + }, + } +} + +function group(id, children, options = {}) { + return { + id, + kind: "group", + children, + blendMode: "normal", + opacity: 1, + visible: true, + ...options, + } +} + +function editorNodes(nodes) { + return nodes.map((node) => + node.kind === "group" + ? { ...node, children: editorNodes(node.children) } + : { layer: node, params: node.params, asset: node.testAsset ?? null } + ) +} + +const red = solid("red", "#ff0000", 0.5) +const green = solid("green", "#00ff00", 0.5) +const blue = solid("blue", "#0000ff") +const effect = { + ...createLayer("threshold"), + id: "threshold", + params: { threshold: 0.1, softness: 0.01, noise: 0 }, +} + +// All arrays below use sidebar order (top first), including group children. +const cases = [ + ["isolated effect", [group("g", [effect, red]), blue], [0.5, 0.5, 1, 1]], + ["empty group", [group("g", []), blue], [0, 0, 1, 1]], + [ + "effect-only group cannot see the scene", + [group("g", [effect]), blue], + [0, 0, 1, 1], + ], + [ + "hidden group", + [group("g", [effect, red], { visible: false }), blue], + [0, 0, 1, 1], + ], + [ + "zero group opacity", + [group("g", [effect, red], { opacity: 0 }), blue], + [0, 0, 1, 1], + ], + [ + "opacity applies after children combine", + [group("g", [green, red], { opacity: 0.5 }), blue], + [0.125, 0.25, 0.625, 1], + ], + [ + "child order", + [group("g", [red, green], { opacity: 0.5 }), blue], + [0.25, 0.125, 0.625, 1], + ], + [ + "hidden child", + [group("g", [{ ...green, visible: false }, red]), blue], + [0.5, 0, 0.5, 1], + ], + [ + "nested group opacity", + [ + group("outer", [group("g", [red], { opacity: 0.5 })], { opacity: 0.5 }), + blue, + ], + [0.125, 0, 0.875, 1], + ], + [ + "sibling effects stay isolated", + [group("g2", [green]), group("g", [effect, red]), blue], + [0.25, 0.75, 0.5, 1], + ], + [ + "external top layer stays unaffected", + [green, group("g", [effect, red]), blue], + [0.25, 0.75, 0.5, 1], + ], + [ + "parent effect processes combined child", + [group("outer", [effect, group("g", [red])]), blue], + [0.5, 0.5, 1, 1], + ], + [ + "group blend mode", + [group("g", [red], { blendMode: "multiply" }), blue], + [0, 0, 0.5, 1], + ], + [ + "group reorder below backdrop", + [blue, group("g", [effect, red])], + [0, 0, 1, 1], + ], + [ + "move effect outside group", + [effect, group("g", [red]), blue], + [1, 1, 1, 1], + ], + ["ungroup", [green, red, blue], [0.25, 0.5, 0.25, 1]], +] + +export async function checkGroups() { + const renderer = new THREE.WebGPURenderer({ antialias: false }) + await renderer.init() + renderer.toneMapping = THREE.NoToneMapping + renderer.setSize(4, 4, false) + renderer.setClearColor(0x123456, 0.37) + const compilations = [] + const compileAsync = renderer.compileAsync.bind(renderer) + renderer.compileAsync = (...args) => { + const promise = compileAsync(...args) + compilations.push(promise) + return promise + } + const settle = async () => { + while (compilations.length) await Promise.all(compilations.splice(0)) + } + const output = new THREE.RenderTarget(4, 4, { + type: THREE.FloatType, + depthBuffer: false, + }) + const material = new THREE.MeshBasicNodeMaterial({ + blending: THREE.NoBlending, + }) + const geometry = new THREE.PlaneGeometry(2, 2) + const scene = new THREE.Scene() + scene.add(new THREE.Mesh(geometry, material)) + const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1) + const read = async (input) => { + material.colorNode = texture(input) + material.needsUpdate = true + renderer.setRenderTarget(output) + renderer.render(scene, camera) + // Read rows separately: this Three backend includes GPU row padding in + // multi-row readbacks whose width is not aligned to 256 bytes. + const rows = await Promise.all( + Array.from({ length: output.height }, async (_, y) => + Array.from( + await renderer.readRenderTargetPixelsAsync( + output, + 0, + y, + output.width, + 1 + ) + ) + ) + ) + return rows.flat() + } + let samples = 0 + try { + for (const [name, Pipeline, reverse, flatten] of [ + ["editor", EditorPipeline, reverseComposition, flattenComposition], + ["runtime", RuntimePipeline, reverseRuntime, flattenRuntime], + ]) { + const pipeline = new Pipeline(renderer, { width: 4, height: 4 }) + const nodesFor = (nodes) => + name === "editor" ? editorNodes(nodes) : nodes + const paint = async (nodes) => { + pipeline.syncLayers(reverse(nodesFor(nodes))) + await settle() + let result + if (name === "runtime") result = pipeline.renderToTexture(0, 0) + else { + pipeline.render(0, 0, 2) + // Inspect the same texture the editor blits to its canvas. + result = pipeline.blitInputNode.value + } + return read(result) + } + try { + for (const [label, nodes, expected] of cases) { + const actual = await paint(nodes) + for (let i = 0; i < actual.length; i += 4) + close(actual.slice(i, i + 4), expected, `${name}: ${label}`) + samples++ + } + const restoredColor = renderer.getClearColor(new THREE.Color()) + assert( + restoredColor.getHex() === 0x123456 && + renderer.getClearAlpha() === 0.37, + `${name}: group changed shared renderer clear settings` + ) + samples++ + + // Reparent existing passes, rather than restarting their media or simulation. + await paint([red, blue]) + const redPass = pipeline.passMap.get("red") + await paint([group("g", [red]), blue]) + assert( + pipeline.passMap.get("red") === redPass, + `${name}: reparent recreated child pass` + ) + const groupPass = pipeline.passMap.get("g") + const disposed = new Set() + for (const [label, resource] of [ + ["a", groupPass.rtA], + ["b", groupPass.rtB], + ["material", groupPass.material], + ]) { + resource.addEventListener("dispose", () => disposed.add(label)) + } + + pipeline.resize({ width: 8, height: 6 }) + output.setSize(8, 6) + close( + (await paint([group("g", [red]), blue])).slice(0, 4), + [0.5, 0, 0.5, 1], + `${name}: resized group` + ) + assert( + groupPass.rtA.width === 8 && groupPass.rtB.height === 6, + `${name}: group targets did not resize` + ) + disposed.clear() + await paint([red, blue]) + assert( + disposed.size === 3, + `${name}: removed group leaked GPU resources` + ) + assert( + pipeline.passMap.get("red") === redPass, + `${name}: ungroup disposed child` + ) + samples += 3 + + // Animation is discovered through nested groups, but hidden parents stop it. + await paint([group("g", [red]), blue]) + const child = pipeline.passMap.get("red") + child.needsContinuousRender = () => true + assert( + pipeline.passMap.get("g").needsContinuousRender(), + `${name}: animation did not propagate` + ) + if (name === "editor") { + const times = [] + child.prepareForExportFrame = async (...args) => { + times.push(args) + } + await pipeline.prepareForExportFrame(1.25, true) + assert( + times[0]?.[0] === 1.25 && times[0]?.[1] === true, + "Nested export preparation lost time/loop" + ) + await paint([group("g", [red], { visible: false }), blue]) + await pipeline.prepareForExportFrame(2, true) + assert(times.length === 1, "Hidden group prepared media for export") + } + await paint([group("g", [red], { visible: false }), blue]) + assert( + !pipeline.render(0, 0), + `${name}: hidden group kept animation running` + ) + samples++ + + const duplicates = [group("g", [red]), red] + let rejected = false + try { + pipeline.syncLayers(reverse(nodesFor(duplicates))) + } catch { + rejected = true + } + assert(rejected, `${name}: duplicate IDs were accepted`) + // Validation must happen before disposing any current passes. + assert( + pipeline.passMap.get("red") === child, + `${name}: invalid tree mutated live passes` + ) + let nested = red + for (let i = 0; i < 8; i++) nested = group(`depth-${i}`, [nested]) + flatten(nodesFor([nested]), (entry) => + name === "editor" ? entry.layer.id : entry.id + ) + rejected = false + try { + reverse(nodesFor([group("too-deep", [nested])])) + } catch { + rejected = true + } + assert(rejected, `${name}: excessive depth was accepted`) + samples += 2 + + if (name === "runtime") { + const input = new THREE.DataTexture( + new Float32Array([0, 0, 1, 0.5]), + 1, + 1, + THREE.RGBAFormat, + THREE.FloatType + ) + input.needsUpdate = true + try { + pipeline.syncLayers(reverse([group("g", [red])])) + await settle() + const pixel = ( + await read(pipeline.renderToTexture(0, 0, input)) + ).slice(0, 4) + close( + pixel, + [2 / 3, 0, 1 / 3, 0.75], + "Runtime group over transparent external input" + ) + samples++ + } finally { + input.dispose() + } + } + } finally { + pipeline.dispose() + output.setSize(4, 4) + } + } + + const headless = createHeadlessRenderer({ + renderer, + size: { width: 4, height: 4 }, + }) + try { + await headless.initialize() + const frame = { + clock: { time: 0, delta: 0, duration: 1, loop: true }, + layers: [ + group("outer", [group("inner", [green, red], { opacity: 0.5 })]), + blue, + ], + viewportSize: { width: 4, height: 4 }, + outputSize: { width: 4, height: 4 }, + logicalSize: { width: 4, height: 4 }, + pixelRatio: 1, + } + headless.render(frame) + await settle() + close( + (await read(headless.render(frame))).slice(0, 4), + [0.125, 0.25, 0.625, 1], + "Headless entry point reverses nested children" + ) + samples++ + } finally { + headless.dispose() + } + } finally { + output.dispose() + material.dispose() + geometry.dispose() + renderer.dispose() + } + + // The real editor entry point must preserve ordering at every depth and match + // its PNG export. The photograph fixture includes clear and soft-alpha regions. + const size = { width: 64, height: 48 } + const image = { ...createLayer("image"), id: "photo" } + image.params = { + ...image.params, + scale: 0.5, + transparentBounds: true, + fitMode: "contain", + } + image.testAsset = { + id: "fixture", + kind: "image", + url: "/scenes/default/alpha-sample.svg", + width: 160, + height: 160, + fileName: "alpha-sample.svg", + } + const canvas = document.createElement("canvas") + const editor = await createWebGPURenderer(canvas, { + strictPassFailures: true, + }) + let png + try { + await editor.initialize() + editor.resize(size, 1) + const frame = { + clock: { + time: 0, + timelineTime: 0, + delta: 0, + duration: 1, + loop: true, + isPlaying: false, + }, + cropAspectRatio: null, + logicalSize: size, + outputSize: size, + pixelRatio: 1, + viewportSize: size, + sceneConfig: DEFAULT_SCENE_CONFIG, + layers: editorNodes([ + group("outer", [group("portrait", [effect, image])]), + blue, + ]), + } + editor.render(frame) + const deadline = performance.now() + 30_000 + while (editor.hasPendingResources()) { + assert( + performance.now() < deadline, + "Grouped editor render did not settle" + ) + await new Promise((resolve) => setTimeout(resolve, 20)) + } + await editor.prepareForExportFrame(0, true) + editor.render(frame) + await editor.waitForGpuIdle() + const snapshot = document.createElement("canvas") + snapshot.width = size.width + snapshot.height = size.height + const ctx = snapshot.getContext("2d", { willReadFrequently: true }) + ctx.drawImage(canvas, 0, 0) + const preview = ctx.getImageData(0, 0, size.width, size.height).data + close( + Array.from(preview.slice(0, 4)), + [0, 0, 255, 255], + "Group holes reveal blue background", + 1 + ) + const center = (24 * size.width + 32) * 4 + close( + Array.from(preview.slice(center, center + 4)), + [255, 255, 255, 255], + "Group effect modifies photograph", + 1 + ) + const exported = editor.exportFrame(frame, size) + ctx.clearRect(0, 0, size.width, size.height) + ctx.drawImage(exported, 0, 0) + close( + Array.from(ctx.getImageData(0, 0, size.width, size.height).data), + Array.from(preview), + "Grouped preview/PNG export", + 0 + ) + png = exported.toDataURL("image/png") + samples += 3 + + const snapshotFrame = async (nodes) => { + const nextFrame = { ...frame, layers: editorNodes(nodes) } + editor.render(nextFrame) + while (editor.hasPendingResources()) { + assert(performance.now() < deadline, "Regrouped image did not settle") + await new Promise((resolve) => setTimeout(resolve, 20)) + } + editor.render(nextFrame) + await editor.waitForGpuIdle() + ctx.clearRect(0, 0, size.width, size.height) + ctx.drawImage(canvas, 0, 0) + return Array.from(ctx.getImageData(0, 0, size.width, size.height).data) + } + const flatImage = await snapshotFrame([image, blue]) + const groupedImage = await snapshotFrame([ + group("outer", [group("portrait", [image])]), + blue, + ]) + close( + groupedImage, + flatImage, + "Nested group retains image orientation and soft alpha edges", + 1 + ) + samples++ + } finally { + editor.dispose() + await editor.destroyDevice() + } + return { samples, png } +} diff --git a/tests/composition/layer-drops.mjs b/tests/composition/layer-drops.mjs new file mode 100644 index 00000000..ba21a875 --- /dev/null +++ b/tests/composition/layer-drops.mjs @@ -0,0 +1,155 @@ +import { + applyEditorHistorySnapshot, + buildEditorHistorySnapshot, +} from "@/lib/editor/history" +import { dropLayer } from "@/lib/editor/layer-groups" +import { createLayer } from "@/lib/editor/layers" +import { + applyLabProjectFile, + buildLabProjectFile, + parseLabProjectFileValue, +} from "@/lib/editor/project-file" +import { useHistoryStore } from "@/store/history-store" +import { useLayerStore } from "@/store/layer-store" + +function assert(value, message) { + if (!value) throw new Error(message) +} +function layer(id, type = "text", parentId = null) { + return { ...createLayer(type), id, name: id, parentId } +} +function order(layers) { + return layers + .map((entry) => `${entry.id}:${entry.parentId ?? "scene"}`) + .join(",") +} + +export function checkLayerDrops() { + const a = layer("A", "group") + const b = { ...layer("B", "group"), expanded: false } + const child = layer("child", "text", a.id) + const outside = layer("outside") + const initial = [a, child, b, outside] + const into = dropLayer(initial, child.id, { id: b.id, placement: "inside" }) + assert( + order(into) === "A:scene,B:scene,child:B,outside:scene", + "Drop into collapsed group failed" + ) + assert( + into.find((entry) => entry.id === b.id).expanded, + "Destination stayed collapsed" + ) + const out = dropLayer(into, child.id, { id: a.id, placement: "after" }) + assert( + order(out) === "A:scene,child:scene,B:scene,outside:scene", + "Drop out did not update parent/order" + ) + const before = dropLayer(initial, outside.id, { + id: child.id, + placement: "before", + }) + assert( + order(before) === "A:scene,outside:A,child:A,B:scene", + "Drop before a child did not enter its group" + ) + const after = dropLayer(before, outside.id, { + id: child.id, + placement: "after", + }) + assert( + order(after) === "A:scene,child:A,outside:A,B:scene", + "Sibling reorder failed" + ) + const movedGroup = dropLayer(initial, a.id, { id: b.id, placement: "inside" }) + assert( + order(movedGroup) === "B:scene,A:B,child:A,outside:scene", + "Group drop lost subtree" + ) + assert( + dropLayer(initial, a.id, { id: child.id, placement: "inside" }) === null, + "Cycle accepted" + ) + assert( + dropLayer(initial, a.id, { id: child.id, placement: "after" }) === null, + "Drop beside own descendant accepted" + ) + assert( + dropLayer(initial, child.id, { id: outside.id, placement: "inside" }) === + null, + "Source accepted children" + ) + assert( + dropLayer(initial, b.id, { id: a.id, placement: "after" }) === initial, + "No-op created a new state" + ) + assert( + dropLayer([{ ...a, locked: true }, child, b], a.id, { + id: b.id, + placement: "inside", + }) === null, + "Locked source moved" + ) + assert( + dropLayer([a, child, { ...b, locked: true }], child.id, { + id: b.id, + placement: "inside", + }) === null, + "Locked destination accepted a drop" + ) + const chain = Array.from({ length: 8 }, (_, i) => + layer(`depth-${i}`, "group", i ? `depth-${i - 1}` : null) + ) + assert( + dropLayer([...chain, a, child], a.id, { + id: "depth-7", + placement: "inside", + }) === null, + "Drop exceeded group depth limit" + ) + + const store = () => useLayerStore.getState() + store().replaceState(initial, child.id) + const beforeDrop = buildEditorHistorySnapshot() + let mutations = 0 + const unsubscribe = useLayerStore.subscribe((next, previous) => { + if (next.layers !== previous.layers) mutations++ + }) + store().dropLayer(child.id, { id: b.id, placement: "inside" }) + unsubscribe() + assert( + mutations === 1, + "Drop must commit hierarchy/order in one store mutation" + ) + assert( + order(store().layers) === order(into), + "Store did not commit intended drop" + ) + assert(store().selectedLayerId === child.id, "Drop changed the selection") + useHistoryStore.getState().clearHistory() + useHistoryStore.getState().pushSnapshot("Move layer", beforeDrop) + applyEditorHistorySnapshot( + useHistoryStore.getState().undo(buildEditorHistorySnapshot()) + ) + assert( + order(store().layers) === order(initial), + "Undo did not restore membership/order" + ) + applyEditorHistorySnapshot( + useHistoryStore.getState().redo(buildEditorHistorySnapshot()) + ) + assert(order(store().layers) === order(into), "Redo did not restore drop") + const saved = buildLabProjectFile() + store().replaceState([]) + applyLabProjectFile( + parseLabProjectFileValue(JSON.parse(JSON.stringify(saved))), + [] + ) + assert( + order(store().layers) === order(into), + "Real hydration lost dropped membership/order" + ) + assert( + store().getLayerById(b.id).expanded, + "Real hydration lost destination expansion" + ) +} diff --git a/tests/composition/layer-masks-ui.mjs b/tests/composition/layer-masks-ui.mjs new file mode 100644 index 00000000..604d4ba7 --- /dev/null +++ b/tests/composition/layer-masks-ui.mjs @@ -0,0 +1,265 @@ +// Start the dev server, then bun tests/composition/layer-masks-ui.mjs. +import assert from "node:assert/strict" +import { mkdir } from "node:fs/promises" +import { chromium } from "playwright" +import { createLayer } from "@/lib/editor/layers" +import { DEFAULT_SCENE_CONFIG } from "@/types/editor" +await mkdir(".context", { recursive: true }) +function solid(id, color, name) { + const layer = createLayer("gradient") + return { + ...layer, + id, + name, + params: { + ...layer.params, + animate: false, + tonemapMode: "none", + grainAmount: 0, + glowStrength: 0, + vignetteStrength: 0, + ...Object.fromEntries( + [1, 2, 3, 4, 5].map((i) => [`point${i}Color`, color]) + ), + }, + } +} +const group = { ...createLayer("group"), id: "portrait", name: "Portrait" } +const threshold = { + ...createLayer("threshold"), + id: "threshold", + name: "Threshold", + parentId: group.id, + params: { threshold: 0.1, softness: 0.01, noise: 0 }, +} +await Bun.write( + ".context/mask-ui-fixture.lab", + JSON.stringify({ + format: "shader-lab", + version: 7, + composition: { width: 720, height: 960 }, + selectedLayerId: threshold.id, + assets: [], + layers: [ + group, + threshold, + { ...solid("red", "#ff0000", "Red"), parentId: group.id }, + solid("blue", "#0000ff", "Blue"), + ], + sceneConfig: { ...DEFAULT_SCENE_CONFIG, backgroundColor: "#f5f2ee" }, + timeline: { duration: 5, loop: true, tracks: [] }, + }) +) +const browser = await chromium.launch({ + headless: true, + args: [ + "--enable-unsafe-webgpu", + "--use-webgpu-adapter=swiftshader", + "--use-angle=swiftshader", + "--enable-unsafe-swiftshader", + ], +}) +try { + const page = await browser.newPage({ viewport: { width: 1440, height: 960 } }) + const errors = [] + page.on("pageerror", (error) => errors.push(error.message)) + await page.goto(process.env.SHADER_LAB_URL ?? "http://localhost:55000", { + waitUntil: "domcontentloaded", + timeout: 120000, + }) + const panel = page.locator('[data-layer-sidebar-panel="true"]:visible') + await panel.waitFor({ timeout: 120000 }) + async function ready() { + await page.waitForFunction( + () => + [...document.querySelectorAll('[class*="loader-bounce"]')].every( + (loader) => { + for (let node = loader; node; node = node.parentElement) { + const style = getComputedStyle(node) + if ( + style.display === "none" || + style.visibility === "hidden" || + Number(style.opacity) === 0 + ) + return true + } + return false + } + ), + null, + { timeout: 120000 } + ) + } + await ready() + async function importFile(path) { + await page + .getByRole("button", { name: "Export", exact: true }) + .filter({ visible: true }) + .click() + await page.getByRole("button", { name: "project", exact: true }).click() + await page + .locator('input[accept=".lab,application/json"]') + .last() + .setInputFiles(path) + await page.getByRole("dialog").waitFor({ state: "hidden" }) + await panel.locator('[data-layer-row="threshold"]').waitFor() + await ready() + } + await importFile(".context/mask-ui-fixture.lab") + async function save(name) { + await page + .getByRole("button", { name: "Export", exact: true }) + .filter({ visible: true }) + .click() + await page.getByRole("button", { name: "project", exact: true }).click() + const downloaded = page.waitForEvent("download") + await page + .getByRole("button", { name: "Export .lab file", exact: true }) + .click() + const download = await downloaded + const path = `.context/mask-${name}.lab` + await download.saveAs(path) + await page.keyboard.press("Escape") + await page.getByRole("dialog").waitFor({ state: "hidden" }) + return await Bun.file(path).json() + } + const maskOf = (project, id) => project.layers.find((l) => l.id === id).mask + const section = () => + page.locator('[data-layer-mask-section="true"]').filter({ visible: true }) + async function chooseShape(label) { + await section().getByRole("combobox", { name: "Mask shape" }).click() + await page.getByRole("option", { name: label, exact: true }).click() + await page.waitForTimeout(350) + } + async function selectRow(id, text, shape) { + await panel + .locator(`[data-layer-row="${id}"]`) + .getByText(text, { exact: true }) + .click() + await section() + .getByRole("combobox", { name: "Mask shape" }) + .filter({ hasText: new RegExp(`^${shape}$`) }) + .waitFor() + } + + await selectRow("threshold", "Threshold", "None") + assert.equal(await section().count(), 1, "One visible mask section") + assert.equal(maskOf(await save("initial"), "threshold"), undefined) + await chooseShape("Ellipse") + const handles = page.locator('[data-mask-handles="true"]') + await handles.waitFor() + await section().getByRole("combobox", { name: "Mask scope" }).waitFor() + const ellipse = maskOf(await save("ellipse"), "threshold") + assert.equal(ellipse.shape, "ellipse") + assert.deepEqual(ellipse.center, [0, 0]) + + const center = page.locator('[data-mask-handle="center"]') + const box = await center.boundingBox() + const cx = box.x + box.width / 2 + const cy = box.y + box.height / 2 + await page.mouse.move(cx, cy) + await page.mouse.down() + await page.mouse.move(cx + 40, cy + 20, { steps: 6 }) + await page.mouse.move(cx + 80, cy + 40, { steps: 6 }) + await page.mouse.up() + await page.waitForTimeout(400) + const moved = maskOf(await save("moved"), "threshold") + assert.ok(moved.center[0] > 0.05 && moved.center[1] > 0.02, "Center drag") + await page.keyboard.press("Meta+z") + await page.waitForTimeout(400) + assert.deepEqual( + maskOf(await save("moved-undo"), "threshold").center, + [0, 0], + "One drag is one undo step" + ) + await page.keyboard.press("Meta+Shift+z") + await page.waitForTimeout(400) + assert.deepEqual(maskOf(await save("moved-redo"), "threshold").center, moved.center) + + const xHandle = page.locator('[data-mask-handle="x"]') + const xb = await xHandle.boundingBox() + await page.mouse.move(xb.x + xb.width / 2, xb.y + xb.height / 2) + await page.mouse.down() + await page.mouse.move(xb.x + 30, xb.y + 60, { steps: 8 }) + await page.mouse.up() + await page.waitForTimeout(400) + const rotated = maskOf(await save("rotated"), "threshold") + assert.ok(Math.abs(rotated.rotation) > 5, "X handle rotates") + assert.notEqual(rotated.size[0], moved.size[0], "X handle resizes width") + assert.equal(rotated.size[1], moved.size[1], "X handle keeps height") + + const yHandle = page.locator('[data-mask-handle="y"]') + const yb = await yHandle.boundingBox() + await page.mouse.move(yb.x + yb.width / 2, yb.y + yb.height / 2) + await page.mouse.down() + await page.mouse.move(yb.x + 40, yb.y + 90, { steps: 6 }) + await page.keyboard.press("Escape") + await page.mouse.up() + await page.waitForTimeout(400) + assert.deepEqual(maskOf(await save("cancelled"), "threshold"), rotated) + + await section().getByRole("combobox", { name: "Mask scope" }).click() + await page.getByRole("option", { name: "Cut content", exact: true }).click() + await page.waitForTimeout(300) + assert.equal(maskOf(await save("scope"), "threshold").scope, "content") + + await selectRow("portrait", "Portrait", "None") + assert.equal( + await section().getByRole("combobox", { name: "Mask scope" }).count(), + 0, + "Groups do not expose a scope" + ) + await chooseShape("Rectangle") + await section().getByRole("slider", { name: /^Feather/ }).waitFor() + assert.equal(maskOf(await save("group"), "portrait").shape, "rectangle") + + await selectRow("threshold", "Threshold", "Ellipse") + await chooseShape("Brush") + const overlay = page.locator('[data-paint-target="mask"]') + await overlay.waitFor() + await handles.waitFor({ state: "hidden" }) + await page.getByRole("button", { name: "Done", exact: true }).waitFor() + const ob = await overlay.boundingBox() + const px = ob.x + ob.width * 0.4 + const py = ob.y + ob.height * 0.4 + await page.mouse.move(px, py) + await page.mouse.down() + await page.mouse.move(px + 120, py + 60, { steps: 12 }) + await page.mouse.up() + await page.waitForTimeout(400) + const painted = maskOf(await save("painted"), "threshold") + assert.ok(painted.paint.startsWith("pc1:"), "Brush stroke persisted") + assert.equal(painted.shape, "brush") + await page.keyboard.press("Meta+z") + await page.waitForTimeout(400) + assert.equal(maskOf(await save("paint-undo"), "threshold").paint, "") + await page.keyboard.press("Meta+Shift+z") + await page.waitForTimeout(400) + assert.equal(maskOf(await save("paint-redo"), "threshold").paint, painted.paint) + await page.getByRole("button", { name: "Done", exact: true }).click() + await overlay.waitFor({ state: "hidden" }) + await page.getByRole("button", { name: "Edit Mask", exact: true }).click() + await overlay.waitFor() + await page.getByRole("button", { name: "Clear Mask", exact: true }).click() + await page.waitForTimeout(400) + assert.equal(maskOf(await save("cleared"), "threshold").paint, "") + await page.keyboard.press("Meta+z") + await page.waitForTimeout(400) + assert.equal(maskOf(await save("clear-undo"), "threshold").paint, painted.paint) + await page.getByRole("button", { name: "Done", exact: true }).click() + await overlay.waitFor({ state: "hidden" }) + await page.waitForTimeout(400) + await page.screenshot({ path: ".context/layer-masks-ui.png" }) + + await importFile(".context/mask-clear-undo.lab") + const reopened = await save("reopened") + assert.equal(maskOf(reopened, "threshold").paint, painted.paint) + assert.equal(maskOf(reopened, "portrait").shape, "rectangle") + assert.equal(maskOf(reopened, "blue"), undefined) + assert.deepEqual(errors, []) + console.log( + "PASS mask section, ellipse handles drag/rotate/cancel, undo granularity, scope, group mask, brush strokes, clear, actual save/reopen" + ) +} finally { + await browser.close() +} diff --git a/tests/composition/layer-masks.mjs b/tests/composition/layer-masks.mjs new file mode 100644 index 00000000..4db117c4 --- /dev/null +++ b/tests/composition/layer-masks.mjs @@ -0,0 +1,696 @@ +import { PassNode as RuntimePassNode } from "@runtime/renderer/pass-node" +import { PhotographicCellsPass as RuntimeCells } from "@runtime/renderer/photographic-cells-pass" +import { PipelineManager as RuntimePipeline } from "@runtime/renderer/pipeline-manager" +import { reverseComposition as reverseRuntime } from "@runtime/renderer/composition-tree" +import { buildRendererFrame as runtimeFrame } from "@runtime/renderer/contracts" +import { createHeadlessRenderer } from "@runtime/renderer/create-headless-renderer" +import { float, texture, uv, vec2, vec4 } from "three/tsl" +import * as THREE from "three/webgpu" +import { PassNode } from "@/renderer/pass-node" +import { PhotographicCellsPass } from "@/renderer/photographic-cells-pass" +import { PipelineManager as EditorPipeline } from "@/renderer/pipeline-manager" +import { reverseComposition } from "@/renderer/composition-tree" +import { + layerMaskSignature, + normalizeLayerMask, +} from "@/renderer/layer-mask" +import { + emptyCellPaintMask, + encodeCellPaintMask, +} from "@/renderer/cell-paint-mask" +import { paintCellSegment } from "@/lib/editor/paint/cell-paint-brush" +import { createLayer } from "@/lib/editor/layers" +import { + applyLabProjectFile, + buildLabProjectFile, + parseLabProjectFileValue, +} from "@/lib/editor/project-file" +import { buildShaderExportConfig } from "@/lib/editor/shader-export" +import { + applyEditorHistorySnapshot, + buildEditorHistorySnapshot, +} from "@/lib/editor/history" +import { + canPaintMaskLayer, + useCellPaintStore, + withCellPaintPreview, +} from "@/store/cell-paint-store" +import { useLayerStore } from "@/store/layer-store" +import { DEFAULT_LAYER_MASK, DEFAULT_SCENE_CONFIG } from "@/types/editor" + +function assert(value, label) { + if (!value) throw new Error(label) +} +function close(actual, expected, label, tolerance = 0.01) { + assert(actual.length === expected.length, `${label}: wrong size`) + actual.forEach((value, i) => { + assert( + Number.isFinite(value) && Math.abs(value - expected[i]) <= tolerance, + `${label}: ${i} got ${value}, expected ${expected[i]}` + ) + }) +} +const mix = (a, b, m) => a.map((v, i) => v + (b[i] - v) * m) +const N = 64 +const index = (v) => Math.min(N - 1, Math.max(0, Math.round((v + 0.5) * N - 0.5))) + +class SolidPass extends PassNode { + constructor(id, color) { + super(id) + this.color = color + this.rebuildEffectNode() + } + buildEffectNode() { + return this.color ? vec4(...this.color) : this.inputNode + } +} +class RuntimeSolidPass extends RuntimePassNode { + constructor(id, color) { + super(id) + this.color = color + this.rebuildEffectNode() + } + buildEffectNode() { + return this.color ? vec4(...this.color) : this.inputNode + } +} + +const mask = (overrides) => ({ ...DEFAULT_LAYER_MASK, ...overrides }) + +async function passChecks() { + const renderer = new THREE.WebGPURenderer({ antialias: false }) + await renderer.init() + renderer.toneMapping = THREE.NoToneMapping + const makeInput = (color) => { + const tex = new THREE.DataTexture( + new Float32Array(color), + 1, + 1, + THREE.RGBAFormat, + THREE.FloatType + ) + tex.needsUpdate = true + return tex + } + const input = [0.2, 0.4, 0.6, 1] + const opaque = makeInput(input) + const clear = makeInput([0, 0, 0, 0]) + const red = [1, 0, 0, 1] + const target = new THREE.RenderTarget(N, N, { + type: THREE.FloatType, + depthBuffer: false, + }) + const brush = emptyCellPaintMask() + paintCellSegment(brush, { x: -0.4, y: -0.3 }, { x: 0.4, y: -0.3 }, 0.05, false) + const painted = encodeCellPaintMask(brush) + let samples = 0 + const results = {} + try { + for (const [name, Solid, Cells] of [ + ["editor", SolidPass, PhotographicCellsPass], + ["runtime", RuntimeSolidPass, RuntimeCells], + ]) { + const pass = new Solid(`solid-${name}`, red) + const render = async (role, layerMask, source = opaque) => { + pass.updateCompositionRole(role) + pass.updateLayerMask(layerMask ? normalizeLayerMask(layerMask) : null) + pass.resize(N, N) + pass.updateMaskLogicalSize(N, N) + pass.flushColorNode() + pass.render(renderer, source, target, 0, 0) + const pixels = Array.from( + await renderer.readRenderTargetPixelsAsync(target, 0, 0, N, N) + ) + return (x, y) => { + const i = (index(y) * N + index(x)) * 4 + return pixels.slice(i, i + 4) + } + } + const check = (label, actual, expected, tolerance) => { + close(actual, expected, `${name}: ${label}`, tolerance) + results[label] ??= {} + results[label][name] = actual + samples++ + } + + let at = await render("effect", null) + check("control", at(0, 0), red) + check("control corner", at(-0.45, 0.45), red) + + at = await render("effect", mask({ shape: "ellipse", size: [0.5, 0.5] })) + check("ellipse center", at(0, 0), red) + check("ellipse inside", at(0.18, 0), red) + check("ellipse outside", at(0.4, 0), input) + check("ellipse corner", at(-0.45, -0.45), input) + + at = await render( + "effect", + mask({ shape: "ellipse", size: [0.5, 0.5], invert: true }) + ) + check("inverted ellipse center", at(0, 0), input) + check("inverted ellipse corner", at(-0.45, -0.45), red) + + at = await render( + "effect", + mask({ shape: "rectangle", size: [0.5, 0.25], feather: 0 }) + ) + check("rectangle along width", at(0.2, 0), red) + check("rectangle beyond height", at(0, 0.2), input) + check("rectangle beyond width", at(0.3, 0), input) + + at = await render( + "effect", + mask({ shape: "rectangle", size: [0.5, 0.25], rotation: 90, feather: 0 }) + ) + check("rotated rectangle along height", at(0, 0.2), red) + check("rotated rectangle beyond", at(0.2, 0), input) + + at = await render( + "effect", + mask({ shape: "rectangle", size: [0.5, 0.5], feather: 0.1 }) + ) + check("feather edge", at(0.25, 0), mix(input, red, 0.5), 0.08) + check("feather inside", at(0.1, 0), red, 0.02) + check("feather outside", at(0.4, 0), input, 0.02) + + at = await render("effect", mask({ shape: "linear", size: [1, 1] })) + check("linear start", at(-0.4, 0), mix(input, red, 0.9), 0.03) + check("linear middle", at(0, 0), mix(input, red, 0.5), 0.03) + check("linear end", at(0.4, 0), mix(input, red, 0.1), 0.03) + check("linear ignores y", at(0, 0.4), mix(input, red, 0.5), 0.03) + + at = await render( + "effect", + mask({ shape: "linear", size: [1, 1], rotation: 90 }) + ) + const rows = [at(0, -0.4), at(0, 0.4)] + check( + "vertical linear spans", + [rows[0][0] + rows[1][0]], + [mix(input, red, 0.9)[0] + mix(input, red, 0.1)[0]], + 0.05 + ) + check("vertical linear ignores x", at(0.4, 0), mix(input, red, 0.5), 0.03) + + at = await render("effect", mask({ shape: "radial", size: [1, 1] })) + check("radial center", at(0, 0), red, 0.03) + check("radial half", at(0.25, 0), mix(input, red, 0.5), 0.04) + check("radial outside", at(0.45, 0.45), input, 0.02) + + at = await render( + "effect", + mask({ shape: "ellipse", size: [0.5, 0.5], enabled: false }) + ) + check("disabled mask", at(-0.45, -0.45), red) + at = await render("effect", mask({ shape: "none" })) + check("shape none", at(-0.45, -0.45), red) + + at = await render( + "effect", + mask({ shape: "ellipse", size: [0.5, 0.5], scope: "content" }) + ) + check("cut content inside", at(0, 0), red) + check("cut content outside alpha", [at(-0.45, -0.45)[3]], [0]) + at = await render( + "source", + mask({ shape: "ellipse", size: [0.5, 0.5], scope: "content" }) + ) + check("source content equals limit", at(-0.45, -0.45), input) + + at = await render( + "source", + mask({ shape: "ellipse", size: [0.5, 0.5], feather: 0.1 }), + clear + ) + check("transparent input center", at(0, 0), red) + const edge = at(0.25, 0) + check("transparent input edge alpha", [edge[3]], [0.5], 0.08) + check("no black halo", edge.slice(0, 3), [1, 0, 0], 0.02) + check("transparent input outside", at(-0.45, -0.45), [0, 0, 0, 0]) + + at = await render("effect", mask({ shape: "brush", paint: painted })) + check("brush unpainted center", at(0, 0), input) + const strokeRows = [at(0, -0.3), at(0, 0.3)] + const paintedRow = strokeRows.findIndex((p) => p[0] > 0.9) + assert(paintedRow !== -1, `${name}: brush stroke missing`) + check( + "brush other row untouched", + strokeRows[1 - paintedRow], + input + ) + check("brush outside stroke x", at(0.47, -0.3), input) + results["brush row"] ??= {} + results["brush row"][name] = paintedRow + + const cells = new Cells(`cells-${name}`) + cells.updateCompositionRole("transform") + cells.resize(N, N) + cells.updateLogicalSize(N, N) + cells.updateParams({ + threshold: 0, + gap: 0, + outline: 0, + irregularity: 0, + size: 0.03, + mode: "paint", + paintMask: painted, + output: "cutout", + }) + cells.updateOpacity(1) + cells.flushColorNode() + cells.render(renderer, opaque, target, 0, 0) + const cellPixels = Array.from( + await renderer.readRenderTargetPixelsAsync(target, 0, 0, N, N) + ) + const cellAlpha = (y) => + cellPixels[(index(y) * N + index(0)) * 4 + 3] + const cellRow = [cellAlpha(-0.3), cellAlpha(0.3)].findIndex((a) => a > 0.5) + assert( + cellRow === paintedRow, + `${name}: brush mask row ${paintedRow} differs from Cells paint row ${cellRow}` + ) + samples++ + cells.dispose() + pass.dispose() + } + for (const [label, entry] of Object.entries(results)) { + if (Array.isArray(entry.editor)) + close(entry.editor, entry.runtime, `parity: ${label}`, 0.002) + else assert(entry.editor === entry.runtime, `parity: ${label}`) + } + samples += Object.keys(results).length + } finally { + target.dispose() + opaque.dispose() + clear.dispose() + renderer.dispose() + } + return samples +} + +function solid(id, color, opacity = 1) { + const layer = createLayer("gradient") + return { + ...layer, + id, + opacity, + params: { + ...layer.params, + animate: false, + tonemapMode: "none", + grainAmount: 0, + glowStrength: 0, + vignetteStrength: 0, + ...Object.fromEntries( + [1, 2, 3, 4, 5].map((i) => [`point${i}Color`, color]) + ), + }, + } +} +function group(id, children, options = {}) { + return { + id, + kind: "group", + children, + blendMode: "normal", + opacity: 1, + visible: true, + ...options, + } +} +function editorNodes(nodes) { + return nodes.map((node) => + node.kind === "group" + ? { ...node, children: editorNodes(node.children) } + : { layer: node, params: node.params, asset: null } + ) +} + +async function pipelineChecks() { + const renderer = new THREE.WebGPURenderer({ antialias: false }) + await renderer.init() + renderer.toneMapping = THREE.NoToneMapping + renderer.setSize(N, N, false) + const compilations = [] + const compileAsync = renderer.compileAsync.bind(renderer) + renderer.compileAsync = (...args) => { + const promise = compileAsync(...args) + compilations.push(promise) + return promise + } + const settle = async () => { + while (compilations.length) await Promise.all(compilations.splice(0)) + } + const output = new THREE.RenderTarget(N, N, { + type: THREE.FloatType, + depthBuffer: false, + }) + const material = new THREE.MeshBasicNodeMaterial({ + blending: THREE.NoBlending, + }) + const scene = new THREE.Scene() + scene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material)) + const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1) + const read = async (input) => { + material.colorNode = texture(input) + material.needsUpdate = true + renderer.setRenderTarget(output) + renderer.render(scene, camera) + return Array.from( + await renderer.readRenderTargetPixelsAsync(output, 0, 0, N, N) + ) + } + const red = solid("red", "#ff0000") + const green = solid("green", "#00ff00", 0.5) + const blue = solid("blue", "#0000ff") + const ellipse = mask({ shape: "ellipse", size: [0.5, 0.5], feather: 0 }) + const threshold = { + ...createLayer("threshold"), + id: "threshold", + params: { threshold: 0.1, softness: 0.01, noise: 0 }, + } + const cases = [ + [ + "ellipse cuts the group only", + [green, group("g", [red], { mask: ellipse }), blue], + { center: [0.5, 0.5, 0, 1], corner: [0, 0.5, 0.5, 1] }, + ], + [ + "inverted group mask", + [group("g", [red], { mask: { ...ellipse, invert: true } }), blue], + { center: [0, 0, 1, 1], corner: [1, 0, 0, 1] }, + ], + [ + "effect limited inside a group", + [group("g", [{ ...threshold, mask: ellipse }, red]), blue], + { center: [1, 1, 1, 1], corner: [1, 0, 0, 1] }, + ], + [ + "effect cuts group content", + [ + group("g", [{ ...threshold, mask: { ...ellipse, scope: "content" } }, red]), + blue, + ], + { center: [1, 1, 1, 1], corner: [0, 0, 1, 1] }, + ], + [ + "masked source layer at root", + [{ ...red, mask: ellipse }, blue], + { center: [1, 0, 0, 1], corner: [0, 0, 1, 1] }, + ], + [ + "disabled mask restores", + [{ ...red, mask: { ...ellipse, enabled: false } }, blue], + { center: [1, 0, 0, 1], corner: [1, 0, 0, 1] }, + ], + ] + let samples = 0 + try { + for (const [name, Pipeline, reverse] of [ + ["editor", EditorPipeline, reverseComposition], + ["runtime", RuntimePipeline, reverseRuntime], + ]) { + const pipeline = new Pipeline(renderer, { width: N, height: N }) + pipeline.updateLogicalSize({ width: N, height: N }) + const nodesFor = (nodes) => (name === "editor" ? editorNodes(nodes) : nodes) + const paint = async (nodes) => { + pipeline.syncLayers(reverse(nodesFor(nodes))) + await settle() + let result + if (name === "runtime") result = pipeline.renderToTexture(0, 0) + else { + pipeline.render(0, 0, 2) + result = pipeline.blitInputNode.value + } + return read(result) + } + try { + for (const [label, nodes, expected] of cases) { + const pixels = await paint(nodes) + const at = (x, y) => { + const i = (index(y) * N + index(x)) * 4 + return pixels.slice(i, i + 4) + } + close(at(0, 0), expected.center, `${name}: ${label} (center)`, 0.02) + close( + at(-0.45, -0.45), + expected.corner, + `${name}: ${label} (corner)`, + 0.02 + ) + samples++ + } + await paint([{ ...red, mask: ellipse }, blue]) + const pass = pipeline.passMap.get("red") + const version = pass.getMaterialVersion() + const moved = await paint([ + { ...red, mask: { ...ellipse, center: [0.3, 0] } }, + blue, + ]) + assert( + pipeline.passMap.get("red") === pass && + pass.getMaterialVersion() === version, + `${name}: moving a mask rebuilt the material` + ) + const i = (index(0) * N + index(0.3)) * 4 + close(moved.slice(i, i + 4), [1, 0, 0, 1], `${name}: moved mask`, 0.02) + const j = (index(0) * N + index(-0.3)) * 4 + close(moved.slice(j, j + 4), [0, 0, 1, 1], `${name}: moved mask hole`, 0.02) + await paint([{ ...red, mask: { ...ellipse, shape: "rectangle" } }, blue]) + assert( + pass.getMaterialVersion() !== version, + `${name}: shape change did not rebuild the material` + ) + samples += 2 + } finally { + pipeline.dispose() + } + } + } finally { + output.dispose() + renderer.dispose() + } + return samples +} + +async function runtimeRender(config) { + const renderer = new THREE.WebGPURenderer({ antialias: false }) + await renderer.init() + const compiling = [] + const compile = renderer.compileAsync.bind(renderer) + renderer.compileAsync = (...args) => { + const pending = compile(...args) + compiling.push(pending) + return pending + } + const headless = createHeadlessRenderer({ + renderer, + size: config.composition, + }) + const target = new THREE.RenderTarget(N, N, { + type: THREE.FloatType, + depthBuffer: false, + }) + const material = new THREE.MeshBasicNodeMaterial({ + blending: THREE.NoBlending, + }) + const scene = new THREE.Scene() + scene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material)) + try { + await headless.initialize() + const frame = runtimeFrame(config, 0, 0, 1, config.composition) + headless.render(frame) + while (compiling.length) await Promise.all(compiling.splice(0)) + material.colorNode = texture( + headless.render(frame), + vec2(uv().x, float(1).sub(uv().y)) + ) + renderer.setRenderTarget(target) + renderer.render(scene, new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1)) + return Array.from( + await renderer.readRenderTargetPixelsAsync(target, 0, 0, N, N) + ) + } finally { + headless.dispose() + target.dispose() + renderer.dispose() + } +} + +export async function checkLayerMasks(renderProject) { + assert(layerMaskSignature(null) === "mask:off", "Missing mask signature") + assert( + layerMaskSignature(mask({ shape: "ellipse", enabled: false })) === "mask:off", + "Disabled mask signature" + ) + assert( + layerMaskSignature(mask({ shape: "ellipse" })) !== + layerMaskSignature(mask({ shape: "ellipse", center: [0.1, 0] })), + "Center change must change the signature" + ) + assert(normalizeLayerMask(undefined) === null, "Missing mask normalizes") + assert(normalizeLayerMask("x") === null, "Invalid mask normalizes") + const partial = normalizeLayerMask({ shape: "rectangle", size: [2] }) + assert( + partial.shape === "rectangle" && + partial.scope === "effect" && + partial.enabled && + partial.size[0] === 2 && + partial.size[1] === 0.5, + "Partial runtime mask keeps defaults" + ) + assert( + normalizeLayerMask({ shape: "pen" }).shape === "none", + "Unknown shapes fall back to none" + ) + let samples = 4 + samples += await passChecks() + samples += await pipelineChecks() + + const brush = emptyCellPaintMask() + paintCellSegment(brush, { x: -0.3, y: 0.2 }, { x: 0.3, y: 0.2 }, 0.1, false) + const painted = encodeCellPaintMask(brush) + const g = { + ...createLayer("group"), + id: "portrait", + mask: mask({ shape: "ellipse", size: [0.6, 0.6], feather: 0 }), + } + const halftone = { + ...createLayer("threshold"), + id: "threshold", + parentId: g.id, + params: { threshold: 0.1, softness: 0.01, noise: 0 }, + mask: mask({ shape: "brush", paint: painted }), + } + const project = { + format: "shader-lab", + version: 7, + assets: [], + layers: [g, halftone, { ...solid("red", "#ff0000"), parentId: g.id }, solid("blue", "#0000ff")], + selectedLayerId: halftone.id, + composition: { width: N, height: N }, + sceneConfig: DEFAULT_SCENE_CONFIG, + timeline: { duration: 1, loop: true, tracks: [] }, + } + applyLabProjectFile(parseLabProjectFileValue(project), []) + const store = () => useLayerStore.getState() + assert( + store().getLayerById(g.id).mask.shape === "ellipse" && + store().getLayerById(halftone.id).mask.paint === painted, + "Hydration lost layer masks" + ) + assert( + store().getLayerById("blue").mask === undefined, + "Unmasked layers must not gain a mask on hydration" + ) + const before = buildEditorHistorySnapshot() + store().setLayerMask(g.id, { shape: "rectangle", center: [0.2, 0.1] }) + store().setLayerMask(halftone.id, { paint: "" }) + assert( + store().getLayerById(g.id).mask.shape === "rectangle" && + store().getLayerById(g.id).mask.size[0] === 0.6, + "setLayerMask must merge with existing values" + ) + applyEditorHistorySnapshot(before) + assert( + store().getLayerById(g.id).mask.shape === "ellipse" && + store().getLayerById(halftone.id).mask.paint === painted, + "History lost masks" + ) + store().setLayerMask("blue", { shape: "radial" }) + const blueMask = store().getLayerById("blue").mask + assert( + blueMask.shape === "radial" && blueMask.scope === "effect" && blueMask.enabled, + "New masks start from defaults" + ) + applyEditorHistorySnapshot(before) + const duplicateId = store().duplicateLayer(halftone.id) + const duplicate = store().getLayerById(duplicateId) + assert( + duplicate.mask.paint === painted && duplicate.mask !== store().getLayerById(halftone.id).mask, + "Duplicate must copy the mask independently" + ) + applyEditorHistorySnapshot(before) + + const paint = useCellPaintStore.getState() + assert( + !canPaintMaskLayer(store().layers, g.id, g.id), + "Ellipse masks are not brush-editable" + ) + assert( + canPaintMaskLayer(store().layers, halftone.id, halftone.id), + "Brush mask on the selected layer is editable" + ) + paint.edit(halftone.id, "mask") + useCellPaintStore.getState().setDraft("") + const preview = withCellPaintPreview(store().layers, halftone.id) + assert( + preview.find((l) => l.id === halftone.id).mask.paint === "" && + store().getLayerById(halftone.id).mask.paint === painted && + preview.find((l) => l.id === halftone.id).params._paintGuide === undefined, + "Mask draft preview must not touch the store or Cells guide" + ) + paint.edit(null) + samples += 6 + + const saved = buildLabProjectFile() + store().replaceState([]) + applyLabProjectFile( + parseLabProjectFileValue(JSON.parse(JSON.stringify(saved))), + [] + ) + const reopened = buildLabProjectFile() + assert( + JSON.stringify(reopened.layers.map((l) => l.mask ?? null)) === + JSON.stringify(saved.layers.map((l) => l.mask ?? null)), + "Save/reopen changed masks" + ) + const config = buildShaderExportConfig(reopened) + assert( + config.layers.find((l) => l.id === g.id).mask.shape === "ellipse" && + config.layers.find((l) => l.id === halftone.id).mask.paint === painted && + !("mask" in config.layers.find((l) => l.id === "blue")), + "Shader export lost masks" + ) + const runtimeTree = runtimeFrame(config, 0, 0, 1, config.composition) + assert( + runtimeTree.layers.find((n) => n.id === g.id)?.mask?.shape === "ellipse", + "Runtime group node lost its mask" + ) + samples += 3 + + const first = await renderProject(saved) + const at = (image, x, y) => { + const i = (index(y) * N + index(x)) * 4 + return Array.from(image.data.slice(i, i + 4)) + } + close(at(first.image, 0, 0), [255, 0, 0, 255], "Editor group interior", 2) + close(at(first.image, -0.45, -0.45), [0, 0, 255, 255], "Editor group cutout", 2) + const rowA = at(first.image, 0, 0.2) + const rowB = at(first.image, 0, -0.2) + assert( + [rowA, rowB].some((p) => p[0] > 250 && p[1] > 250) && + [rowA, rowB].some((p) => p[0] > 250 && p[1] < 5), + "Brush-limited threshold must whiten one stroke row only" + ) + const restored = await renderProject(reopened) + close( + Array.from(restored.image.data), + Array.from(first.image.data), + "Reopened mask pixels", + 0 + ) + const runtimePixels = await runtimeRender(config) + close( + runtimePixels, + Array.from(first.image.data, (value, i) => { + const v = value / 255 + if (i % 4 === 3) return v + return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4 + }), + "Exported runtime mask parity", + 0.02 + ) + samples += 4 + return { samples, png: first.png } +} diff --git a/tests/composition/media-bounds.mjs b/tests/composition/media-bounds.mjs new file mode 100644 index 00000000..21bbab2b --- /dev/null +++ b/tests/composition/media-bounds.mjs @@ -0,0 +1,222 @@ +import { MediaPass as RuntimeMediaPass } from "@runtime/renderer/media-pass" +import * as THREE from "three/webgpu" +import { createLayer } from "@/lib/editor/layers" +import { + applyLabProjectFile, + buildLabProjectFile, + buildViewerProjectState, + migrateLayerParams, + parseLabProjectFileValue, +} from "@/lib/editor/project-file" +import { buildShaderExportConfig } from "@/lib/editor/shader-export" +import { MediaPass } from "@/renderer/media-pass" + +function assertPixel(actual, expected, label) { + if ( + actual.length !== expected.length || + actual.some( + (value, index) => + !Number.isFinite(value) || Math.abs(value - expected[index]) > 0.0001 + ) + ) { + throw new Error(`${label}: expected ${expected}, received ${actual}`) + } +} + +async function checkMediaPasses() { + const renderer = new THREE.WebGPURenderer({ antialias: false }) + await renderer.init() + renderer.toneMapping = THREE.NoToneMapping + const backdrop = [0.2, 0.4, 0.8, 1] + const input = new THREE.DataTexture( + new Float32Array(backdrop), + 1, + 1, + THREE.RGBAFormat, + THREE.FloatType + ) + input.needsUpdate = true + const target = new THREE.RenderTarget(64, 48, { + type: THREE.FloatType, + depthBuffer: false, + }) + let count = 0 + try { + for (const [name, Pass] of [ + ["editor", MediaPass], + ["runtime", RuntimeMediaPass], + ]) { + for (const kind of ["image", "video"]) { + const pass = new Pass(`${name}-${kind}`) + try { + pass.resize(64, 48) + const url = + kind === "image" + ? "/scenes/default/alpha-sample.svg" + : "/scenes/default/aura.mp4" + if (name === "editor") { + await pass.setMedia({ + url, + kind, + isSvg: kind === "image", + width: kind === "image" ? 160 : 578, + height: kind === "image" ? 160 : 720, + }) + pass.setPreviewFrozen(true) + await pass.prepareForExportFrame(0) + } else { + await pass.setMedia(url, kind) + } + const render = async (params, opacity = 1) => { + pass.updateParams(params) + pass.updateOpacity(opacity) + pass.render(renderer, input, target, 0, 0) + return Array.from( + await renderer.readRenderTargetPixelsAsync(target, 0, 24, 64, 1) + ) + } + for (const placement of [ + { scale: 1, offset: [0, 0] }, + { scale: 0.5, offset: [0.2, 0] }, + ]) { + const params = { ...placement, fitMode: "contain" } + const legacy = await render(params) + const solid = await render({ ...params, transparentBounds: false }) + const transparent = await render({ + ...params, + transparentBounds: true, + }) + const faded = await render( + { ...params, transparentBounds: true }, + 0.35 + ) + assertPixel( + legacy.slice(0, 4), + [0, 0, 0, 1], + `${name}/${kind}: absent flag keeps black borders` + ) + assertPixel( + solid.slice(0, 4), + [0, 0, 0, 1], + `${name}/${kind}: explicit solid borders` + ) + assertPixel( + transparent.slice(0, 4), + backdrop, + `${name}/${kind}: empty border reveals lower layer` + ) + assertPixel( + faded.slice(0, 4), + backdrop, + `${name}/${kind}: empty border stays clear at partial opacity` + ) + if (kind === "image") { + // The square SVG center remains opaque; its hue/coverage must + // not change when only the out-of-bounds behavior changes. + const center = placement.scale === 1 ? 32 : 37 + assertPixel( + transparent.slice(center * 4, center * 4 + 4), + solid.slice(center * 4, center * 4 + 4), + `${name}: in-bounds content is unchanged` + ) + if (transparent[center * 4] < 0.5) + throw new Error(`${name}: opaque center lost its color`) + } + count += 4 + } + if (kind === "image") { + const params = { fitMode: "cover", scale: 0.5, offset: [0.2, 0] } + assertPixel( + await render({ ...params, transparentBounds: true }), + await render({ ...params, transparentBounds: false }), + `${name}: cover mode is unchanged` + ) + count++ + } + } finally { + pass.dispose() + } + } + } + return count + } finally { + input.dispose() + target.dispose() + renderer.dispose() + } +} + +export async function checkMediaBounds(renderProject) { + for (const type of ["image", "video"]) { + const layer = createLayer(type) + if (layer.params.transparentBounds !== true) + throw new Error(`New ${type} layer has opaque borders`) + for (const value of [undefined, false, true]) { + const params = { ...layer.params } + if (value === undefined) delete params.transparentBounds + else params.transparentBounds = value + for (const version of [1, 5, 6]) { + if ( + migrateLayerParams({ ...layer, params }, version) + .transparentBounds !== (value ?? false) + ) { + throw new Error( + `${type} migration changed the explicit or legacy border setting` + ) + } + } + } + } + + const project = parseLabProjectFileValue( + await (await fetch("/fixtures/contained-image-halftone.json")).json() + ) + project.layers = project.layers.filter((layer) => layer.type === "image") + project.selectedLayerId = project.layers[0].id + project.layers[0].params.transparentBounds = true + project.sceneConfig.backgroundColor = "#2468cc" + applyLabProjectFile(project, []) + const saved = buildLabProjectFile() + const state = buildViewerProjectState( + parseLabProjectFileValue(JSON.parse(JSON.stringify(saved))) + ) + if (state.layers[0].params.transparentBounds !== true) + throw new Error("Save/reopen lost transparent borders") + const config = buildShaderExportConfig({ + ...state, + composition: project.composition, + }) + if (config.layers[0].params.transparentBounds !== true) + throw new Error("Shader export lost transparent borders") + + const transparent = await renderProject(saved) + project.layers[0].params.transparentBounds = false + const solid = await renderProject(project) + const pixel = (result, x, y) => + Array.from( + result.image.data.slice( + (y * result.image.width + x) * 4, + (y * result.image.width + x) * 4 + 4 + ) + ) + assertPixel( + pixel(transparent, 4, 96), + [36, 104, 204, 255], + "PNG export reveals scene background" + ) + assertPixel( + pixel(solid, 4, 96), + [0, 0, 0, 255], + "PNG export preserves solid borders" + ) + assertPixel( + pixel(transparent, 128, 96), + pixel(solid, 128, 96), + "PNG export preserves opaque image content" + ) + return { + samples: await checkMediaPasses(), + transparentPng: transparent.png, + solidPng: solid.png, + } +} diff --git a/tests/composition/photographic-cells.mjs b/tests/composition/photographic-cells.mjs new file mode 100644 index 00000000..2606c06c --- /dev/null +++ b/tests/composition/photographic-cells.mjs @@ -0,0 +1,1157 @@ +import { checkCellEdgeScatter } from "./cell-edge-scatter.mjs" +import { + decodeCellPaintMask, + encodeCellPaintMask, + emptyCellPaintMask, +} from "@/renderer/cell-paint-mask" +import { + expandCellPaintMask, + paintCellSegment, +} from "@/lib/editor/paint/cell-paint-brush" +import { + useCellPaintStore, + withCellPaintPreview, +} from "@/store/cell-paint-store" +import { PhotographicCellsPass as RuntimeCells } from "@runtime/renderer/photographic-cells-pass" +import { buildRendererFrame as runtimeFrame } from "@runtime/renderer/contracts" +import { createHeadlessRenderer } from "@runtime/renderer/create-headless-renderer" +import * as THREE from "three/webgpu" +import { float, texture, uv, vec2 } from "three/tsl" +import { PhotographicCellsPass } from "@/renderer/photographic-cells-pass" +import { createLayer } from "@/lib/editor/layers" +import { + applyLabProjectFile, + buildLabProjectFile, + parseLabProjectFileValue, +} from "@/lib/editor/project-file" +import { buildShaderExportConfig } from "@/lib/editor/shader-export" +import { + applyEditorHistorySnapshot, + buildEditorHistorySnapshot, +} from "@/lib/editor/history" +import { useLayerStore } from "@/store/layer-store" +import { DEFAULT_SCENE_CONFIG } from "@/types/editor" + +function assert(value, label) { + if (!value) throw new Error(label) +} +function close(actual, expected, label, tolerance = 0.003) { + assert(actual.length === expected.length, `${label}: wrong size`) + actual.forEach((value, i) => { + assert( + Number.isFinite(value) && Math.abs(value - expected[i]) <= tolerance, + `${label}: ${i} got ${value}, expected ${expected[i]}` + ) + }) +} +const identity = { + threshold: 0, + gap: 0, + outline: 0, + irregularity: 0, + size: 0.25, +} + +async function gpuChecks() { + const renderer = new THREE.WebGPURenderer({ antialias: false }) + await renderer.init() + renderer.toneMapping = THREE.NoToneMapping + const color = [0.8, 0.3, 0.1, 0.6] + const solid = new THREE.DataTexture( + new Float32Array(color), + 1, + 1, + THREE.RGBAFormat, + THREE.FloatType + ) + solid.needsUpdate = true + const data = new Float32Array(64 * 64 * 4) + for (let y = 0; y < 64; y++) + for (let x = 0; x < 64; x++) + data.set( + [x / 63, y / 63, 0.4, x < 32 && y < 32 ? 0 : 0.6], + (y * 64 + x) * 4 + ) + const photo = new THREE.DataTexture( + data, + 64, + 64, + THREE.RGBAFormat, + THREE.FloatType + ) + photo.needsUpdate = true + const target = new THREE.RenderTarget(64, 64, { + type: THREE.FloatType, + depthBuffer: false, + }) + const expected = new Map() + let samples = 0 + try { + for (const [name, Pass] of [ + ["editor", PhotographicCellsPass], + ["runtime", RuntimeCells], + ]) { + const pass = new Pass(`cells-${name}`) + pass.updateCompositionRole("transform") + pass.flushColorNode() + const at = (pixels, x, y) => + pixels.slice((y * target.width + x) * 4, (y * target.width + x) * 4 + 4) + const paint = async (label, params, input = solid, opacity = 1) => { + pass.resize(target.width, target.height) + pass.updateLogicalSize(target.width, target.height) + pass.updateParams(params) + pass.updateOpacity(opacity) + pass.render(renderer, input, target, 0, 0) + const pixels = Array.from( + await renderer.readRenderTargetPixelsAsync( + target, + 0, + 0, + target.width, + target.height + ) + ) + assert(pixels.every(Number.isFinite), `${name}: nonfinite ${label}`) + if (name === "editor") expected.set(label, pixels) + else close(pixels, expected.get(label), `Runtime parity ${label}`, 0) + samples++ + return pixels + } + try { + const paintedMask = emptyCellPaintMask() + for (let y = 0; y < 256; y++) + paintedMask.data.fill(255, y * 512, y * 512 + 256) + const paintMask = encodeCellPaintMask(paintedMask) + const paintParams = { + ...identity, + mode: "paint", + paintMask, + size: 0.125, + threshold: 1, + } + const painted = await paint("paint quadrant", paintParams) + for (let y = 0; y < 64; y++) + for (let x = 0; x < 64; x++) { + close( + at(painted, x, y), + x < 32 && y < 32 ? color : [0, 0, 0, 0], + "Paint coordinates" + ) + } + close( + await paint("paint ignores automatic selection", { + ...paintParams, + selection: "random", + threshold: 0, + regionSize: 1, + }), + painted, + "Automatic controls affected paint" + ) + const inverted = await paint("paint inverted", { + ...paintParams, + invert: true, + }) + close(at(inverted, 8, 8), [0, 0, 0, 0], "Invert painted selection") + close(at(inverted, 48, 48), color, "Invert painted complement") + const perimeter = await paint("paint perimeter", { + ...paintParams, + outlineMode: "perimeter", + outline: 0.25, + outlineColor: "#ffffff", + }) + close(at(perimeter, 8, 12), color, "Paint has internal perimeter seams") + assert( + at(perimeter, 31, 12)[1] > color[1] + 0.2, + "Paint perimeter missing" + ) + const detailed = await paint("paint source detail", paintParams, photo) + close( + at(detailed, 8, 8), + [0, 0, 0, 0], + "Paint manufactured source alpha" + ) + const guided = await paint("paint guide", { + ...paintParams, + _paintGuide: true, + }) + assert( + at(guided, 48, 48)[3] > 0.05 && at(guided, 48, 48)[3] < color[3], + "Empty area guide missing" + ) + close( + await paint("paint guide removed", paintParams), + painted, + "Guide persisted" + ) + assert( + (await paint("paint clear", { ...paintParams, paintMask: "" })).every( + (v) => v === 0 + ), + "Clearing left coverage" + ) + assert( + ( + await paint("paint invalid mask", { + ...paintParams, + paintMask: "pc1:bad", + }) + ).every((v) => v === 0), + "Invalid mask did not clear" + ) + const changing = new THREE.DataTexture( + new Float32Array([0.2, 0.7, 0.4, 0.8]), + 1, + 1, + THREE.RGBAFormat, + THREE.FloatType + ) + changing.needsUpdate = true + const frameOne = await paint( + "paint moving source one", + paintParams, + changing + ) + const uploaded = pass.paintTexture.version + changing.image.data.set([0.7, 0.2, 0.8, 0.8]) + changing.needsUpdate = true + const frameTwo = await paint( + "paint moving source two", + paintParams, + changing + ) + close( + at(frameOne, 8, 8), + [0.2, 0.7, 0.4, 0.8], + "Paint first source frame" + ) + close( + at(frameTwo, 8, 8), + [0.7, 0.2, 0.8, 0.8], + "Paint stale source frame" + ) + assert( + pass.paintTexture.version === uploaded, + "Video change re-uploaded mask" + ) + changing.dispose() + await paint("paint invalid reset", { + ...paintParams, + paintMask: "pc1:bad", + }) + const version = pass.paintTexture.version + pass.updateParams({ + ...paintParams, + paintMask: "pc1:bad", + threshold: 0.2, + }) + assert( + pass.paintTexture.version === version, + "Unchanged mask re-uploaded" + ) + await checkCellEdgeScatter({ + paint, + at, + close, + assert, + color, + solid, + photo, + pass, + }) + const all = await paint("all", identity) + for (let i = 0; i < all.length; i += 4) + close(all.slice(i, i + 4), color, "Identity coverage") + const empty = await paint("none", { ...identity, threshold: 1 }) + assert( + empty.every((value) => value === 0), + "Threshold 1 must clear every cell" + ) + close( + await paint("invert", { ...identity, threshold: 1, invert: true }), + all, + "Invert selection" + ) + assert( + ( + await paint("full-gap", { ...identity, gap: 1, outline: 0.5 }) + ).every((value) => value === 0), + "Full gaps retained outlines" + ) + assert( + ( + await paint("light", { + ...identity, + threshold: 0.5, + selection: "light", + }) + ).every((value) => value === 0), + "Light selection ignored tone" + ) + close( + await paint("dark", { + ...identity, + threshold: 0.5, + selection: "dark", + }), + all, + "Dark selection" + ) + const gap = await paint("gap", { ...identity, gap: 0.3 }) + close(at(gap, 8, 8), color, "Cell interior") + close(at(gap, 16, 8), [0, 0, 0, 0], "Gap transparency") + const soft = await paint("soft", { + ...identity, + gap: 0.3, + softness: 0.02, + }) + let edges = 0 + for (let i = 0; i < soft.length; i += 4) + if (soft[i + 3] > 0.01 && soft[i + 3] < 0.59) { + close(soft.slice(i, i + 3), color.slice(0, 3), "Undimmed soft edge") + edges++ + } + assert(edges > 0, "Missing soft edges") + const outlined = await paint("outline", { + ...identity, + gap: 0.2, + outline: 0.15, + outlineColor: "#00ff00", + }) + close(at(outlined, 2, 8), [0, 1, 0, 0.6], "Outline color") + close(at(outlined, 8, 8), color, "Outline replaced photo interior") + // Measure visible outline ink, including fractional edge coverage, rather + // than counting bright pixels (which misses subpixel stroke inflation). + target.setSize(256, 256) + for (const output of ["cutout", "keep-image"]) { + for (const outline of [0.01, 0.08]) { + for (const softness of [0, 0.02]) { + for (const gap of [0, 0.125, 0.25, 0.5]) { + const pixels = await paint( + `stroke-width-${output}-${outline}-${softness}-${gap}`, + { + ...identity, + output, + outline, + softness, + gap, + outlineColor: "#00ff00", + } + ) + let ink = 0 + // Left edge of one cell, away from corners and adjacent strokes. + for (let x = 0; x < 32; x++) { + const pixel = at(pixels, x, 32) + ink += + ((pixel[1] - color[1]) / (1 - color[1])) * + (pixel[3] / color[3]) + } + // Softness can clip a fade against the zero-gap partition; the + // separated edges must still carry only the requested ink width. + if (softness === 0 || gap >= 0.25) + close( + [ink], + [64 * outline], + "Gap inflated outline width", + 0.1 + ) + } + } + } + } + target.setSize(64, 64) + close( + await paint("disabled", { ...identity, threshold: 1 }, solid, 0), + all, + "Zero opacity restores source" + ) + close( + at( + await paint( + "half-opacity", + { ...identity, threshold: 1 }, + solid, + 0.5 + ), + 8, + 8 + ), + [0.8, 0.3, 0.1, 0.3], + "Opacity interpolates coverage" + ) + close( + await paint("keep-image", { + ...identity, + threshold: 1, + output: "keep-image", + }), + all, + "Keep Image coverage" + ) + const random = { + ...identity, + selection: "random", + threshold: 0.5, + seed: 31, + irregularity: 0.7, + } + const first = await paint("random", random) + assert( + first.some((value, i) => i % 4 === 3 && value > 0.59) && + first.some((value, i) => i % 4 === 3 && value === 0), + "Random selection is empty or full" + ) + close( + await paint("random-repeat", random), + first, + "Deterministic cells", + 0 + ) + assert( + (await paint("random-seed", { ...random, seed: 73 })).some( + (value, i) => Math.abs(value - first[i]) > 0.1 + ), + "Seed did not change cells" + ) + const detail = await paint("photo-detail", identity, photo) + assert( + Math.abs(at(detail, 40, 40)[0] - at(detail, 44, 40)[0]) > 0.04, + "Photo detail was flattened to cell color" + ) + const photoOutline = await paint( + "photo-outline", + { ...identity, outline: 0.3, outlineColor: "#00ff00" }, + photo + ) + for (let i = 0; i < detail.length; i += 4) + if (detail[i + 3] === 0) + close( + photoOutline.slice(i, i + 4), + [0, 0, 0, 0], + "Outline created opaque pixels outside the photo" + ) + await paint("extremes", { + size: 0.015, + cellAspect: 4, + irregularity: 1, + gap: 0.99, + softness: 0.025, + seed: 1000, + outline: 0.5, + }) + await paint("invalid-numbers", { + size: Number.NaN, + gap: Number.POSITIVE_INFINITY, + threshold: -4, + }) + target.setSize(128, 64) + const wide = await paint("wide", { ...identity, gap: 0.5 }) + let bands = 0 + let previous = false + for (let x = 0; x < 128; x++) { + const inside = at(wide, x, 8)[3] > 0.3 + if (inside && !previous) bands++ + previous = inside + } + assert(bands === 8, `Rectangular canvas stretched cells: ${bands}`) + target.setSize(128, 128) + const shapeData = new Float32Array(128 * 128 * 4) + for (let y = 0; y < 128; y++) + for (let x = 0; x < 128; x++) { + const col = Math.floor(x / 16) + const row = Math.floor(y / 16) + const selected = + (row >= 1 && + row <= 4 && + col >= 1 && + col <= 6 && + !(row >= 2 && row <= 3 && col >= 3 && col <= 4)) || + (row >= 5 && row <= 6 && col >= 1 && col <= 2) + const tone = selected ? 0.8 : 0.1 + shapeData.set([tone, tone, tone, 0.6], (y * 128 + x) * 4) + } + const shape = new THREE.DataTexture( + shapeData, + 128, + 128, + THREE.RGBAFormat, + THREE.FloatType + ) + shape.needsUpdate = true + try { + const contour = { + ...identity, + size: 0.125, + threshold: 0.5, + outline: 0.15, + outlineColor: "#00ff00", + outlineMode: "perimeter", + } + const silhouette = await paint("perimeter-hole", contour, shape) + close( + at(silhouette, 32, 24), + [0.8, 0.8, 0.8, 0.6], + "Shared edge must disappear" + ) + close(at(silhouette, 17, 24), [0, 1, 0, 0.6], "Outer perimeter") + close(at(silhouette, 46, 40), [0, 1, 0, 0.6], "Inner hole perimeter") + close( + at(silhouette, 64, 40), + [0, 0, 0, 0], + "Hole remains transparent" + ) + close( + at(silhouette, 80, 80), + [0, 0, 0, 0], + "Concave silhouette remains cut out" + ) + const individual = await paint( + "perimeter-every-cell", + { ...contour, outlineMode: "every-cell" }, + shape + ) + assert( + at(individual, 32, 24)[1] > 0.95, + "Every Cell lost internal edges" + ) + const noOutline = await paint( + "perimeter-none", + { ...contour, outlineMode: "none" }, + shape + ) + close( + at(noOutline, 17, 24), + [0.8, 0.8, 0.8, 0.6], + "None retained an outline" + ) + const full = await paint( + "perimeter-all-irregular", + { ...contour, threshold: 0, irregularity: 1, cellAspect: 0.25 }, + solid + ) + for (let y = 12; y < 116; y++) + for (let x = 12; x < 116; x++) + close(at(full, x, y), color, "Irregular row introduced a seam") + // Compare irregular contours against the independently rendered coverage: + // any pixel sufficiently far from a cutout must retain its source color. + const staggered = { ...contour, irregularity: 1, cellAspect: 0.65 } + const staggeredFill = await paint( + "perimeter-irregular-fill", + { ...staggered, outlineMode: "none" }, + shape + ) + const staggeredOutline = await paint( + "perimeter-irregular", + staggered, + shape + ) + let inspected = 0 + let edgePixels = 0 + for (let y = 6; y < 122; y++) + for (let x = 6; x < 122; x++) { + const before = at(staggeredFill, x, y) + const after = at(staggeredOutline, x, y) + close( + [after[3]], + [before[3]], + "Outline changed irregular coverage" + ) + if (before[3] < 0.5) continue + if (after[1] - before[1] > 0.1) edgePixels++ + let boundary = false + for (let dy = -5; dy <= 5 && !boundary; dy++) + for (let dx = -5; dx <= 5; dx++) + if (at(staggeredFill, x + dx, y + dy)[3] < 0.5) { + boundary = true + break + } + if (!boundary) { + close(after, before, "Irregular internal edge survived") + inspected++ + } + } + assert( + inspected > 100 && edgePixels > 100, + "Irregular contour fixture is inconclusive" + ) + close( + await paint("perimeter-gaps", { ...contour, gap: 0.25 }, shape), + await paint( + "cell-gaps", + { ...contour, gap: 0.25, outlineMode: "every-cell" }, + shape + ), + "Separated cells must outline actual gaps" + ) + const kept = await paint( + "perimeter-keep", + { ...contour, output: "keep-image" }, + shape + ) + close( + at(kept, 64, 40), + [0.1, 0.1, 0.1, 0.6], + "Keep Image lost hole interior" + ) + close( + at(kept, 32, 24), + [0.8, 0.8, 0.8, 0.6], + "Keep Image added shared edge" + ) + const transparent = await paint( + "perimeter-transparent", + { ...contour, threshold: 0 }, + photo + ) + close( + at(transparent, 1, 1), + [0, 0, 0, 0], + "Perimeter manufactured photo coverage" + ) + // A soft contour on tiny cells can reach beyond the immediately + // neighboring row/column. Compare with a straight-boundary oracle. + for (let y = 0; y < 128; y++) + for (let x = 0; x < 128; x++) { + const tone = x < 64 ? 0.8 : 0.1 + shapeData.set([tone, tone, tone, 0.6], (y * 128 + x) * 4) + } + shape.needsUpdate = true + const broad = await paint( + "perimeter-soft-small-cells", + { ...contour, size: 0.015, outline: 0.5, softness: 0.025 }, + shape + ) + const t = (0.015 * 128 * 0.5 - 2.5 + 0.025 * 128) / (2 * 0.025 * 128) + const ink = t * t * (3 - 2 * t) + close( + at(broad, 61, 64), + [0.8 * (1 - ink), 0.8 + 0.2 * ink, 0.8 * (1 - ink), 0.6], + "Soft contour missed a boundary beyond its immediate neighbors" + ) + } finally { + shape.dispose() + } + const regions = { + ...identity, + mode: "regions", + selection: "random", + threshold: 0.5, + regionSize: 0.4, + size: 0.03125, + seed: 31, + outlineMode: "none", + } + const patches = await paint("regions", regions) + const changes = (pixels) => { + let count = 0 + for (let y = 1; y < 128; y++) + for (let x = 1; x < 128; x++) { + const a = at(pixels, x, y)[3] > 0.3 + if (a !== at(pixels, x - 1, y)[3] > 0.3) count++ + if (a !== at(pixels, x, y - 1)[3] > 0.3) count++ + } + return count + } + const independent = await paint("regions-independent", { + ...regions, + mode: "cells", + }) + assert( + changes(patches) < changes(independent) * 0.3 && changes(patches) > 0, + "Regions did not join cells into broad patches" + ) + close( + await paint("regions-repeat", regions), + patches, + "Region seed must remain stable", + 0 + ) + const smaller = await paint("regions-size", { + ...regions, + regionSize: 0.08, + }) + assert( + changes(smaller) > changes(patches) * 1.5, + "Region Size did not control patch scale" + ) + const coarse = await paint("regions-cell-size", { + ...regions, + size: 0.0625, + }) + let changed = 0 + for (let i = 3; i < patches.length; i += 4) + if (Math.abs(coarse[i] - patches[i]) > 0.1) changed++ + assert( + changed > 0 && changed < 128 * 128 * 0.12, + "Cell Size replaced the region field" + ) + const inverse = await paint("regions-invert", { + ...regions, + invert: true, + }) + for (let i = 3; i < patches.length; i += 4) + close([patches[i] + inverse[i]], [0.6], "Region complement") + assert( + (await paint("regions-seed", { ...regions, seed: 4 })).some( + (v, i) => Math.abs(v - patches[i]) > 0.1 + ), + "Region seed did not change patches" + ) + const lightRegions = await paint( + "regions-light", + { ...regions, selection: "light", threshold: 0.35 }, + photo + ) + const darkRegions = await paint( + "regions-dark", + { ...regions, selection: "dark", threshold: 0.65 }, + photo + ) + assert( + lightRegions.some((v, i) => i % 4 === 3 && v > 0.5) && + darkRegions.some((v, i) => i % 4 === 3 && v > 0.5), + "Image-guided regions lost source tones" + ) + close( + await paint("regions-all", { ...regions, threshold: 0 }), + await paint("regions-identity", identity), + "Region threshold zero" + ) + assert( + ( + await paint("regions-empty", { + ...regions, + threshold: 1, + outlineMode: "perimeter", + outline: 0.1, + }) + ).every((v) => v === 0), + "Empty region retained contour" + ) + await paint( + "regions-extremes", + { + ...regions, + size: 0.015, + regionSize: 2, + irregularity: 1, + cellAspect: 4, + softness: 0.025, + outlineMode: "perimeter", + outline: 0.5, + }, + photo + ) + await paint("regions-invalid", { + ...regions, + regionSize: Number.NaN, + size: Number.POSITIVE_INFINITY, + }) + } finally { + pass.dispose() + target.setSize(64, 64) + } + } + } finally { + solid.dispose() + photo.dispose() + target.dispose() + renderer.dispose() + renderer.backend.device.destroy() + } + return samples +} + +function solid(id, color) { + const layer = createLayer("gradient") + return { + ...layer, + id, + params: { + ...layer.params, + animate: false, + tonemapMode: "none", + grainAmount: 0, + glowStrength: 0, + vignetteStrength: 0, + ...Object.fromEntries( + [1, 2, 3, 4, 5].map((i) => [`point${i}Color`, color]) + ), + }, + } +} + +async function runtimeCheck(config, expectedImage) { + const renderer = new THREE.WebGPURenderer({ antialias: false }) + await renderer.init() + const compiling = [] + const compile = renderer.compileAsync.bind(renderer) + renderer.compileAsync = (...args) => { + const pending = compile(...args) + compiling.push(pending) + return pending + } + const headless = createHeadlessRenderer({ + renderer, + size: config.composition, + }) + const target = new THREE.RenderTarget(128, 96, { + type: THREE.FloatType, + depthBuffer: false, + }) + const material = new THREE.MeshBasicNodeMaterial({ + blending: THREE.NoBlending, + }) + const geometry = new THREE.PlaneGeometry(2, 2) + const scene = new THREE.Scene() + scene.add(new THREE.Mesh(geometry, material)) + try { + await headless.initialize() + const frame = runtimeFrame(config, 0, 0, 1, config.composition) + headless.render(frame) + while (compiling.length) await Promise.all(compiling.splice(0)) + material.colorNode = texture( + headless.render(frame), + vec2(uv().x, float(1).sub(uv().y)) + ) + renderer.setRenderTarget(target) + renderer.render(scene, new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1)) + const pixels = Array.from( + await renderer.readRenderTargetPixelsAsync(target, 0, 0, 128, 96) + ) + if (expectedImage) { + close( + pixels, + Array.from(expectedImage.data, (value, index) => { + const v = value / 255 + if (index % 4 === 3) return v + return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4 + }), + "Exported runtime connected-region coverage", + 0.005 + ) + } else { + close( + pixels.slice((48 * 128 + 64) * 4, (48 * 128 + 64) * 4 + 4), + [0, 0, 1, 1], + "Runtime group gaps" + ) + close( + pixels.slice((72 * 128 + 88) * 4, (72 * 128 + 88) * 4 + 4), + [1, 0, 0, 1], + "Runtime photo cells" + ) + } + } finally { + headless.dispose() + target.dispose() + material.dispose() + geometry.dispose() + renderer.dispose() + renderer.backend.device.destroy() + } +} + +export async function checkPhotographicCells(renderProject) { + const brush = emptyCellPaintMask() + paintCellSegment( + brush, + { x: -0.35, y: -0.2 }, + { x: 0.35, y: -0.2 }, + 0.08, + false + ) + const encoded = encodeCellPaintMask(brush) + close( + Array.from(decodeCellPaintMask(encoded).data), + Array.from(brush.data), + "Mask roundtrip", + 0 + ) + assert(encoded.length < 44000, "Unbounded mask") + for (let x = 100; x < 410; x++) + assert(brush.data[154 * 512 + x] === 255, "Fast stroke has gaps") + paintCellSegment(brush, { x: 0, y: -0.2 }, { x: 0, y: -0.2 }, 0.04, true) + assert( + brush.data[154 * 512 + 256] === 0 && brush.data[154 * 512 + 200] === 255, + "Erase affected wrong area" + ) + const expanded = expandCellPaintMask(brush, 2, 1) + assert( + expanded.data[154 * 512 + 228] === 255 && + expanded.data[154 * 512 + 256] === 0, + "Resize moved coverage" + ) + for (const invalid of ["", "pc1:1:1:no", "x".repeat(50000)]) + assert( + decodeCellPaintMask(invalid).data.every((v) => v === 0), + "Invalid mask failed closed" + ) + const samples = await gpuChecks() + const group = { ...createLayer("group"), id: "portrait" } + const cells = { + ...createLayer("photographic-cells"), + id: "cells", + parentId: group.id, + params: { + ...identity, + size: 0.5, + gap: 0.5, + mode: "regions", + regionSize: 0.35, + edgeScatter: 0.65, + outlineMode: "perimeter", + outline: 0.025, + }, + } + const project = { + format: "shader-lab", + version: 7, + assets: [], + layers: [ + group, + cells, + { ...solid("red", "#ff0000"), parentId: group.id }, + solid("blue", "#0000ff"), + ], + selectedLayerId: cells.id, + composition: { width: 128, height: 96 }, + sceneConfig: DEFAULT_SCENE_CONFIG, + timeline: { duration: 1, loop: true, tracks: [] }, + } + applyLabProjectFile(parseLabProjectFileValue(project), []) + assert( + useLayerStore.getState().getLayerById(cells.id).params.mode === "regions", + "Hydration lost Regions mode" + ) + const before = buildEditorHistorySnapshot() + useLayerStore.getState().updateLayerParam(cells.id, "edgeScatter", 0) + useLayerStore.getState().updateLayerParam(cells.id, "threshold", 1) + useLayerStore.getState().updateLayerParam(cells.id, "mode", "cells") + useLayerStore.getState().updateLayerParam(cells.id, "outlineMode", "none") + applyEditorHistorySnapshot(before) + assert( + useLayerStore.getState().getLayerById(cells.id).params.threshold === 0 && + useLayerStore.getState().getLayerById(cells.id).params.edgeScatter === + 0.65, + "History lost cell settings" + ) + assert( + useLayerStore.getState().getLayerById(cells.id).params.mode === "regions" && + useLayerStore.getState().getLayerById(cells.id).params.outlineMode === + "perimeter", + "History lost region modes" + ) + const saved = buildLabProjectFile() + useLayerStore.getState().replaceState([]) + applyLabProjectFile( + parseLabProjectFileValue(JSON.parse(JSON.stringify(saved))), + [] + ) + const reopened = buildLabProjectFile() + for (const key of [ + "mode", + "regionSize", + "edgeScatter", + "outlineMode", + "outline", + ]) + assert( + reopened.layers[1].params[key] === cells.params[key], + `Save/reopen lost ${key}` + ) + assert( + reopened.layers[1].type === "photographic-cells" && + reopened.layers[1].parentId === group.id, + "Hydration lost cells/group membership" + ) + await runtimeCheck(buildShaderExportConfig(reopened)) + const first = await renderProject(saved) + close( + Array.from( + first.image.data.slice((48 * 128 + 64) * 4, (48 * 128 + 64) * 4 + 4) + ), + [0, 0, 255, 255], + "Editor group gaps", + 1 + ) + close( + Array.from( + first.image.data.slice((72 * 128 + 88) * 4, (72 * 128 + 88) * 4 + 4) + ), + [255, 0, 0, 255], + "Editor photo cells", + 1 + ) + const restored = await renderProject(reopened) + close( + Array.from(restored.image.data), + Array.from(first.image.data), + "Reopened pixels", + 0 + ) + const regionProject = { + ...saved, + layers: saved.layers.map((layer) => + layer.id === cells.id + ? { + ...layer, + params: { + ...layer.params, + threshold: 0.5, + gap: 0, + size: 0.0625, + selection: "random", + seed: 31, + regionSize: 0.4, + outline: 0, + }, + } + : layer + ), + } + const regionPreview = await renderProject(regionProject) + await runtimeCheck( + buildShaderExportConfig(regionProject), + regionPreview.image + ) + const paintProject = { + ...regionProject, + layers: regionProject.layers.map((layer) => + layer.id === cells.id + ? { + ...layer, + params: { + ...layer.params, + mode: "paint", + paintMask: encoded, + outline: 0.06, + }, + } + : layer + ), + } + applyLabProjectFile(parseLabProjectFileValue(paintProject), []) + const paintBefore = buildEditorHistorySnapshot() + const duplicateId = useLayerStore.getState().duplicateLayer(cells.id) + assert( + useLayerStore.getState().getLayerById(duplicateId).params.paintMask === + encoded, + "Duplicate lost painted selection" + ) + applyEditorHistorySnapshot(paintBefore) + useLayerStore.getState().updateLayerParam(cells.id, "mode", "regions") + useLayerStore.getState().updateLayerParam(cells.id, "mode", "paint") + useLayerStore.getState().updateLayerParam(cells.id, "size", 0.1) + assert( + useLayerStore.getState().getLayerById(cells.id).params.paintMask === + encoded, + "Layout or geometry erased paint" + ) + applyEditorHistorySnapshot(paintBefore) + useLayerStore.getState().updateLayerParam(cells.id, "paintMask", "") + applyEditorHistorySnapshot(paintBefore) + assert( + useLayerStore.getState().getLayerById(cells.id).params.paintMask === + encoded, + "Paint history lost mask" + ) + useCellPaintStore.getState().edit(cells.id) + useCellPaintStore.getState().setDraft("") + const previewLayers = withCellPaintPreview( + useLayerStore.getState().layers, + cells.id + ) + assert( + previewLayers.find((l) => l.id === cells.id).params._paintGuide === true, + "Editor guide missing" + ) + const paintSaved = buildLabProjectFile() + assert( + paintSaved.layers.find((l) => l.id === cells.id).params.paintMask === + encoded, + "Draft leaked into save" + ) + assert( + !JSON.stringify(paintSaved).includes("_paintGuide"), + "Guide leaked into save" + ) + useCellPaintStore.getState().edit(null) + useLayerStore.getState().replaceState([]) + applyLabProjectFile( + parseLabProjectFileValue(JSON.parse(JSON.stringify(paintSaved))), + [] + ) + assert( + useLayerStore.getState().getLayerById(cells.id).params.paintMask === + encoded, + "Hydration lost paint mask" + ) + const paintPreview = await renderProject(paintSaved) + await runtimeCheck(buildShaderExportConfig(paintSaved), paintPreview.image) + const asset = { + id: "photo-asset", + kind: "image", + url: "/scenes/default/rings-photo.webp", + fileName: "slice.webp", + width: 1512, + height: 908, + } + const art = { + ...saved, + composition: { width: 480, height: 600 }, + assets: [asset], + layers: [ + group, + { ...cells, params: createLayer("photographic-cells").params }, + { + ...createLayer("image"), + id: "photo", + assetId: asset.id, + parentId: group.id, + }, + ], + sceneConfig: { ...DEFAULT_SCENE_CONFIG, backgroundColor: "#f5f2ee" }, + } + const preview = await renderProject(art) + const colors = new Set() + for (let i = 0; i < preview.image.data.length; i += 4) + colors.add(preview.image.data.slice(i, i + 3).join(",")) + assert(colors.size > 100, "Cell photographic preview is blank") + const thumbnail = document.createElement("canvas") + thumbnail.width = preview.image.width + thumbnail.height = preview.image.height + thumbnail.getContext("2d").putImageData(preview.image, 0, 0) + const oldCells = { ...cells, params: { outline: 0.12 } } + applyLabProjectFile( + parseLabProjectFileValue({ ...saved, layers: [group, oldCells] }), + [] + ) + const legacy = useLayerStore.getState().getLayerById(cells.id).params + assert( + legacy.mode === "cells" && + legacy.outlineMode === "every-cell" && + legacy.outline === 0.12 && + legacy.size === 0.1 && + legacy.gap === 0.08 && + legacy.selection === "light" && + legacy.irregularity === 0.35 && + legacy.threshold === 0.35 && + legacy.edgeScatter === 0, + "New region defaults changed a saved partial cell layer" + ) + const fresh = createLayer("photographic-cells").params + assert( + fresh.mode === "regions" && + fresh.outlineMode === "perimeter" && + fresh.gap === 0 && + fresh.edgeScatter === 0, + "New layers must start with connected regions" + ) + return { + samples, + png: first.png, + previewPng: preview.png, + previewWebp: thumbnail.toDataURL("image/webp", 0.85), + } +} diff --git a/tests/composition/run.mjs b/tests/composition/run.mjs new file mode 100644 index 00000000..6749d006 --- /dev/null +++ b/tests/composition/run.mjs @@ -0,0 +1,287 @@ +import assert from "node:assert/strict" +import { mkdir } from "node:fs/promises" +import { resolve, sep } from "node:path" +import { chromium } from "playwright" + +const root = resolve(import.meta.dir, "../..") +const directory = resolve(import.meta.dir) +const update = process.argv.includes("--update") +const artifacts = resolve(root, ".context/composition-test") +await mkdir(artifacts, { recursive: true }) +const bundle = await Bun.build({ + entrypoints: [resolve(directory, "browser.mjs")], + target: "browser", + define: { "process.env": "{}" }, +}) +if (!bundle.success) + throw new AggregateError(bundle.logs, "Composition harness failed to bundle") + +const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + const path = new URL(request.url).pathname + if (path === "/") + return new Response( + ``, + { headers: { "Content-Type": "text/html" } } + ) + if (path === "/browser.js") + return new Response(bundle.outputs[0], { + headers: { "Content-Type": "text/javascript" }, + }) + if (path === "/scenes/default/alpha-sample.svg") + return new Response( + Bun.file(resolve(directory, "fixtures/alpha-sample.svg")) + ) + if (path === "/scenes/default/rings-photo.webp") + return new Response(Bun.file(resolve(root, "public/examples/slice.webp"))) + const base = + path.startsWith("/fixtures/") || path.startsWith("/baselines/") + ? directory + : resolve(root, "public") + const file = resolve(base, `.${path}`) + if (!(file.startsWith(`${base}${sep}`) && (await Bun.file(file).exists()))) + return new Response("Not found", { status: 404 }) + return new Response(Bun.file(file)) + }, +}) + +let browser +try { + browser = await chromium.launch({ + headless: true, + args: [ + "--enable-unsafe-webgpu", + "--use-webgpu-adapter=swiftshader", + "--use-angle=swiftshader", + "--enable-unsafe-swiftshader", + ], + }) + const page = await browser.newPage() + page.setDefaultTimeout(120_000) + const errors = [] + page.on("pageerror", (error) => { + errors.push(error.message) + console.error(error.message) + }) + page.on("console", (message) => { + if (message.type() === "error") { + errors.push(message.text()) + console.error(message.text()) + } + }) + page.on("requestfailed", (request) => { + const message = `${request.url()}: ${request.failure()?.errorText}` + errors.push(message) + console.error(message) + }) + await page.goto(server.url.href) + await page.waitForFunction(() => typeof window.checkProject === "function") + await page.evaluate(() => document.fonts.load('400 96px "Fixture Mono"')) + assert.ok( + await page.evaluate(async () => !!(await navigator.gpu?.requestAdapter())), + "WebGPU is required; no fallback or skipped tests" + ) + console.log( + "GPU adapter:", + await page.evaluate(async () => { + const adapter = await navigator.gpu.requestAdapter() + return adapter + ? { + vendor: adapter.info.vendor, + architecture: adapter.info.architecture, + device: adapter.info.device, + description: adapter.info.description, + } + : null + }) + ) + + console.log( + "Clean projects:", + await page.evaluate(() => window.checkCleanProjects()) + ) + + console.log( + "Existing project:", + await page.evaluate(() => window.checkExistingProject()) + ) + const blends = await page.evaluate(() => window.checkLegacyBlendContract()) + const blendPath = resolve(directory, "baselines/legacy-blends.json") + for (const sample of blends) { + assert.ok( + sample.rgba.every(Number.isFinite), + `Non-finite pixel: ${JSON.stringify(sample)}` + ) + assert.equal(sample.rgba[3], 1, "Legacy compositor is opaque") + } + const half = blends.length / 2 + for (let i = 0; i < half; i++) { + assert.deepEqual( + blends[i].rgba, + blends[i + half].rgba, + "Editor/runtime blend mismatch" + ) + } + if (update) { + await Bun.write(blendPath, `${JSON.stringify(blends, null, 2)}\n`) + } else { + const expected = await Bun.file(blendPath).json() + assert.equal(blends.length, expected.length) + for (const [index, sample] of blends.entries()) { + const { rgba, ...identity } = sample + const { rgba: expectedRgba, ...expectedIdentity } = expected[index] + assert.deepEqual(identity, expectedIdentity) + for (let channel = 0; channel < 4; channel++) { + assert.ok( + Math.abs(rgba[channel] - expectedRgba[channel]) < 0.00001, + `Legacy blend changed: ${JSON.stringify(sample)}` + ) + } + } + } + console.log(`PASS ${blends.length} editor/runtime blend and mask samples`) + for (const name of [ + "legacy-text-mask", + "solid-text-background", + "transparent-text-screen", + "contained-image-halftone", + ]) { + const result = await page.evaluate( + ({ name, update }) => window.checkProject(name, update), + { name, update } + ) + const output = update + ? resolve(directory, `baselines/${name}.png`) + : resolve(artifacts, `${name}.png`) + await Bun.write(output, Buffer.from(result.png.split(",")[1], "base64")) + assert.equal( + result.baselineDiff?.changedPixels ?? 0, + 0, + `${name}: ${JSON.stringify(result.baselineDiff)}; actual image: ${output}` + ) + console.log( + `PASS ${name}: preview/export and reopen match; ${result.colors} colors` + ) + } + const mediaBounds = await page.evaluate(() => window.checkMediaBounds()) + await Bun.write( + resolve(artifacts, "transparent-media-bounds.png"), + Buffer.from(mediaBounds.transparentPng.split(",")[1], "base64") + ) + await Bun.write( + resolve(artifacts, "solid-media-bounds.png"), + Buffer.from(mediaBounds.solidPng.split(",")[1], "base64") + ) + console.log( + `PASS media bounds: ${mediaBounds.samples} editor/runtime image/video samples, migration, save/reopen, shader export, and PNG export` + ) + const alphaSamples = await page.evaluate(() => window.checkAlphaCompositing()) + console.log( + `PASS ${alphaSamples} alpha samples: source-over, effects, pass materials, grading, and the runtime texture pipeline` + ) + const groups = await page.evaluate(() => window.checkGroups()) + await Bun.write( + resolve(artifacts, "isolated-groups.png"), + Buffer.from(groups.png.split(",")[1], "base64") + ) + console.log( + `PASS ${groups.samples} group checks: isolation, nesting, opacity, ordering, lifecycle, and editor PNG export` + ) + assert.deepEqual(errors, [], "Browser or GPU errors occurred") + const editorGroups = await page.evaluate(() => window.checkEditorGroups()) + await Bun.write( + resolve(artifacts, "saved-editor-group.png"), + Buffer.from(editorGroups.png.split(",")[1], "base64") + ) + console.log( + `PASS editor groups: store operations, history, v7 hydration, ${editorGroups.tracks} animation tracks, shader export, and saved/reopened pixels` + ) + const text = await page.evaluate(() => window.checkTransparentText()) + await Bun.write( + resolve(artifacts, "new-transparent-text.png"), + Buffer.from(text.png.split(",")[1], "base64") + ) + console.log( + `PASS transparent text: ${text.samples} editor/runtime pixel checks, legacy hydration, save/reopen, and PNG export` + ) + const rings = await page.evaluate(() => window.checkDisplacedRings()) + await Bun.write( + resolve(artifacts, "displaced-rings-preview.webp"), + Buffer.from(rings.previewWebp.split(",")[1], "base64") + ) + for (const [name, png] of [ + ["ring-cutout", rings.png], + ["displaced-rings-preview", rings.previewPng], + ]) { + await Bun.write( + resolve(artifacts, `${name}.png`), + Buffer.from(png.split(",")[1], "base64") + ) + } + console.log( + `PASS displaced rings: ${rings.samples} GPU cases, 48 distinct bands (${rings.denseMs}ms including readback), hydration, runtime export, group cutouts, preview/PNG` + ) + const threshold = await page.evaluate(() => window.checkThresholdColors()) + await Bun.write( + resolve(artifacts, "threshold-colors.png"), + Buffer.from(threshold.png.split(",")[1], "base64") + ) + console.log( + `PASS threshold colors: ${threshold.samples} editor/runtime GPU cases, decoded video, alpha, history, hydration and PNG export` + ) + const cells = await page.evaluate(() => window.checkPhotographicCells()) + await Bun.write( + resolve(artifacts, "photographic-cells-preview.webp"), + Buffer.from(cells.previewWebp.split(",")[1], "base64") + ) + for (const [name, png] of [ + ["cell-cutout", cells.png], + ["photographic-cells-preview", cells.previewPng], + ]) { + await Bun.write( + resolve(artifacts, `${name}.png`), + Buffer.from(png.split(",")[1], "base64") + ) + } + console.log( + `PASS photographic cells: ${cells.samples} editor/runtime GPU cases, full-detail interiors, alpha, hydration, runtime export, groups, preview/PNG` + ) + const masks = await page.evaluate(() => window.checkLayerMasks()) + await Bun.write( + resolve(artifacts, "layer-masks.png"), + Buffer.from(masks.png.split(",")[1], "base64") + ) + console.log( + `PASS layer masks: ${masks.samples} editor/runtime GPU cases, group cutouts, brush parity with Cells paint, hydration, history, duplication, export` + ) + const gradientMap = await page.evaluate(() => window.checkGradientMap()) + await Bun.write( + resolve(artifacts, "gradient-map.png"), + Buffer.from(gradientMap.png.split(",")[1], "base64") + ) + await Bun.write( + resolve(artifacts, "gradient-map-preview.webp"), + Buffer.from(gradientMap.previewWebp.split(",")[1], "base64") + ) + console.log( + `PASS gradient map: ${gradientMap.samples} editor/runtime GPU cases, presets, amount, invert, masked group scope, hydration, export, catalog preview` + ) + const artboard = await page.evaluate(() => window.checkArtboard()) + console.log( + `PASS artboard: ${artboard.samples} document size, fit, composition update, save/reopen and legacy checks` + ) + assert.deepEqual(errors, [], "Browser or GPU errors occurred") + console.log( + update + ? "Baseline capture complete. Review images before committing." + : "Composition baselines passed." + ) +} finally { + await browser?.close() + server.stop(true) +} diff --git a/tests/composition/threshold-colors-ui.mjs b/tests/composition/threshold-colors-ui.mjs new file mode 100644 index 00000000..4447dbbd --- /dev/null +++ b/tests/composition/threshold-colors-ui.mjs @@ -0,0 +1,169 @@ +// Start the dev server, then bun tests/composition/threshold-colors-ui.mjs. +import assert from "node:assert/strict" +import { chromium } from "playwright" +import { createLayer } from "@/lib/editor/layers" +import { DEFAULT_SCENE_CONFIG } from "@/types/editor" +import { mkdir } from "node:fs/promises" +await mkdir(".context", { recursive: true }) +const group = { + ...createLayer("group"), + id: "group", + name: "Photographic study", +} +const threshold = { + ...createLayer("threshold"), + id: "threshold", + parentId: group.id, +} +const photo = { ...createLayer("image"), id: "photo-layer", parentId: group.id } +await Bun.write( + ".context/threshold-ui-fixture.lab", + JSON.stringify({ + format: "shader-lab", + version: 7, + composition: { width: 720, height: 960 }, + selectedLayerId: threshold.id, + assets: [], + layers: [group, threshold, photo], + sceneConfig: { ...DEFAULT_SCENE_CONFIG, backgroundColor: "#f5f2ee" }, + timeline: { duration: 5, loop: true, tracks: [] }, + }) +) +const browser = await chromium.launch({ + headless: true, + args: [ + "--enable-unsafe-webgpu", + "--use-webgpu-adapter=swiftshader", + "--use-angle=swiftshader", + "--enable-unsafe-swiftshader", + ], +}) +try { + const page = await browser.newPage({ viewport: { width: 1440, height: 960 } }) + const errors = [] + page.on("pageerror", (error) => errors.push(error.message)) + await page.goto(process.env.SHADER_LAB_URL ?? "http://localhost:55000", { + waitUntil: "domcontentloaded", + timeout: 120000, + }) + const panel = page.locator('[data-layer-sidebar-panel="true"]:visible') + await panel.waitFor({ timeout: 120000 }) + async function ready() { + await page.waitForFunction( + () => + [...document.querySelectorAll('[class*="loader-bounce"]')].every( + (loader) => { + for (let node = loader; node; node = node.parentElement) { + const style = getComputedStyle(node) + if ( + style.display === "none" || + style.visibility === "hidden" || + Number(style.opacity) === 0 + ) + return true + } + return false + } + ), + null, + { timeout: 120000 } + ) + } + await ready() + await page + .getByRole("button", { name: "Export", exact: true }) + .filter({ visible: true }) + .click() + await page.getByRole("button", { name: "project", exact: true }).click() + await page + .locator('input[accept=".lab,application/json"]') + .last() + .setInputFiles(".context/threshold-ui-fixture.lab") + await panel.locator('[data-layer-row="threshold"]').waitFor() + await ready() + await panel + .locator('[data-layer-row="photo-layer"]') + .getByText("Image", { exact: true }) + .click() + const chooser = page.waitForEvent("filechooser") + const add = panel.getByRole("button", { name: "Add layer", exact: true }) + await add.click() + const menu = await add.getAttribute("aria-controls") + await page + .locator(`[id="${menu}"]`) + .getByRole("button", { name: /^Image$/ }) + .click() + await (await chooser).setFiles("public/examples/slice.webp") + await page.waitForFunction( + () => + document.querySelectorAll( + '[data-layer-sidebar-panel="true"] [data-layer-row]' + ).length >= 4 + ) + await panel + .locator('[data-layer-row="photo-layer"]') + .getByRole("button", { name: "Delete Image", exact: true }) + .click() + await panel + .locator('[data-layer-row="threshold"]') + .getByText("Threshold", { exact: true }) + .click() + await ready() + + await page.getByText("Dark Color", { exact: true }).first().waitFor() + async function color(from, to, _label) { + await page.getByRole("button", { name: from, exact: true }).click() + await page.locator("[data-color-picker-popup] input").fill(to) + await page.keyboard.press("Escape") + await page.locator("[data-color-picker-popup]").waitFor({ state: "hidden" }) + await page.waitForTimeout(350) + } + async function save(name) { + await page + .getByRole("button", { name: "Export", exact: true }) + .filter({ visible: true }) + .click() + await page.getByRole("button", { name: "project", exact: true }).click() + const downloaded = page.waitForEvent("download") + await page + .getByRole("button", { name: "Export .lab file", exact: true }) + .click() + const download = await downloaded + const file = `.context/threshold-${name}.lab` + await download.saveAs(file) + await page.keyboard.press("Escape") + await page.getByRole("dialog").waitFor({ state: "hidden" }) + return (await Bun.file(file).json()).layers.find( + (l) => l.id === "threshold" + ).params + } + await color("#000000", "#162FC4", "Dark Color") + await color("#FFFFFF", "#F04E9B", "Light Color") + const chosen = await save("chosen") + assert.equal(chosen.darkColor, "#162FC4") + assert.equal(chosen.lightColor, "#F04E9B") + await page.keyboard.press("Meta+z") + await page.waitForTimeout(350) + assert.equal((await save("undo")).lightColor.toLowerCase(), "#ffffff") + await page.keyboard.press("Meta+Shift+z") + await page.waitForTimeout(350) + assert.deepEqual(await save("redo"), chosen) + await page.screenshot({ path: ".context/threshold-editor-ui.png" }) + await page + .getByRole("button", { name: "Export", exact: true }) + .filter({ visible: true }) + .click() + await page.getByRole("button", { name: "project", exact: true }).click() + await page + .locator('input[accept=".lab,application/json"]') + .last() + .setInputFiles(".context/threshold-chosen.lab") + await ready() + assert.deepEqual(await save("reopened"), chosen) + assert.deepEqual(errors, []) + console.log( + "PASS Threshold color controls, undo/redo, actual .lab save and hydration" + ) +} finally { + await browser.close() +} diff --git a/tests/composition/threshold-colors.mjs b/tests/composition/threshold-colors.mjs new file mode 100644 index 00000000..0bc9cb67 --- /dev/null +++ b/tests/composition/threshold-colors.mjs @@ -0,0 +1,362 @@ +import * as THREE from "three/webgpu" +import { ThresholdPass } from "@/renderer/threshold-pass" +import { ThresholdPass as RuntimeThreshold } from "@runtime/renderer/threshold-pass" +import { createLayer } from "@/lib/editor/layers" +import { + applyLabProjectFile, + buildLabProjectFile, + parseLabProjectFileValue, +} from "@/lib/editor/project-file" +import { + buildEditorHistorySnapshot, + applyEditorHistorySnapshot, +} from "@/lib/editor/history" +import { buildShaderExportConfig } from "@/lib/editor/shader-export" +import { buildRendererFrame as runtimeFrame } from "@runtime/renderer/contracts" +import { useLayerStore } from "@/store/layer-store" +import { DEFAULT_SCENE_CONFIG } from "@/types/editor" + +const assert = { + ok(value, message) { + if (!value) throw new Error(message) + }, + equal(actual, expected, message) { + if (actual !== expected) + throw new Error(`${message}: ${actual} != ${expected}`) + }, +} +function close(actual, expected, label, epsilon = 0.0001) { + assert.equal(actual.length, expected.length, label) + actual.forEach((v, i) => { + assert.ok( + Number.isFinite(v) && Math.abs(v - expected[i]) <= epsilon, + `${label} channel ${i}: ${v} != ${expected[i]}` + ) + }) +} +// Capture two presented frames once, so editor/runtime see the identical video +// input regardless of shader compilation timing. No paused-seek timing assumption. +async function decodeVideoFrames() { + const video = document.createElement("video") + video.src = "/scenes/default/aura.mp4" + video.muted = true + video.loop = true + const canvas = document.createElement("canvas") + canvas.width = 64 + canvas.height = 64 + const context = canvas.getContext("2d") + const inputs = [] + try { + await video.play() + for (let frame = 0; frame <= 12; frame++) { + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("Video frame timed out")), + 8000 + ) + video.requestVideoFrameCallback(() => { + clearTimeout(timeout) + resolve() + }) + }) + if (frame === 0 || frame === 12) { + context.drawImage(video, 0, 0, 64, 64) + const input = new THREE.DataTexture( + new Uint8Array(context.getImageData(0, 0, 64, 64).data), + 64, + 64, + THREE.RGBAFormat + ) + input.needsUpdate = true + inputs.push(input) + } + } + return inputs + } catch (error) { + for (const input of inputs) input.dispose() + throw error + } finally { + video.pause() + video.removeAttribute("src") + video.load() + } +} +export async function checkThresholdColors(renderProject) { + const renderer = new THREE.WebGPURenderer({ antialias: false }) + await renderer.init() + renderer.toneMapping = THREE.NoToneMapping + const data = new Float32Array(64 * 4) + for (let x = 0; x < 64; x++) + data.set( + [x / 63, x / 63, x / 63, [0, 0.25, 0.6, 1][Math.floor(x / 16)]], + x * 4 + ) + const source = new THREE.DataTexture( + data, + 64, + 1, + THREE.RGBAFormat, + THREE.FloatType + ) + source.needsUpdate = true + const target = new THREE.RenderTarget(64, 1, { + type: THREE.FloatType, + depthBuffer: false, + }) + const expected = new Map() + let samples = 0 + const videoInputs = [] + try { + videoInputs.push(...(await decodeVideoFrames())) + for (const [name, Pass] of [ + ["editor", ThresholdPass], + ["runtime", RuntimeThreshold], + ]) { + const pass = new Pass(`threshold-${name}`) + pass.updateCompositionRole("effect") + pass.flushColorNode() + pass.updateLogicalSize(64, 1) + const node = pass.material.colorNode + const render = async (label, params, input = source, opacity = 1) => { + pass.updateOpacity(opacity) + pass.updateParams(params) + pass.render(renderer, input, target, 0, 0) + assert.equal( + pass.material.colorNode, + node, + "Palette change rebuilt the material" + ) + const pixels = Array.from( + await renderer.readRenderTargetPixelsAsync( + target, + 0, + 0, + 64, + target.height + ) + ) + if (name === "editor") expected.set(label, pixels) + else close(pixels, expected.get(label), `Runtime parity ${label}`, 0) + samples++ + return pixels + } + try { + const base = { threshold: 0.5, softness: 0.1, noise: 0 } + const legacy = await render("legacy defaults", base) + for (let x = 0; x < 64; x++) { + const t = Math.max(0, Math.min(1, (x / 63 - 0.4) / 0.2)) + const weight = t * t * (3 - 2 * t) + close( + legacy.slice(x * 4, x * 4 + 4), + [weight, weight, weight, data[x * 4 + 3]], + "Original threshold and alpha" + ) + } + const palette = { darkColor: "#162fc4", lightColor: "#f04e9b" } + const dark = new THREE.Color(palette.darkColor).toArray() + const light = new THREE.Color(palette.lightColor).toArray() + const colored = await render("custom colors", { ...base, ...palette }) + for (let x = 0; x < 64; x++) + close( + colored.slice(x * 4, x * 4 + 4), + [ + ...dark.map((v, c) => v + (light[c] - v) * legacy[x * 4]), + data[x * 4 + 3], + ], + "Palette endpoints, soft blend and source alpha" + ) + const inverted = await render("inverted colors", { + ...base, + ...palette, + invert: true, + }) + for (let x = 0; x < 64; x++) + close( + inverted.slice(x * 4, x * 4 + 4), + [ + ...dark.map((v, c) => v + (light[c] - v) * (1 - legacy[x * 4])), + data[x * 4 + 3], + ], + "Invert swaps region assignment" + ) + const noise = await render("noise legacy", { ...base, noise: 0.15 }) + const noisyColors = await render("noise colors", { + ...base, + ...palette, + noise: 0.15, + }) + for (let x = 0; x < 64; x++) + close( + noisyColors.slice(x * 4, x * 4 + 3), + dark.map((v, c) => v + (light[c] - v) * noise[x * 4]), + "Palette moved noisy boundaries" + ) + const faded = await render( + "opacity", + { ...base, ...palette }, + source, + 0.4 + ) + for (let x = 0; x < 64; x++) + close( + faded.slice(x * 4, x * 4 + 4), + [ + ...dark.map( + (_, c) => data[x * 4 + c] * 0.6 + colored[x * 4 + c] * 0.4 + ), + data[x * 4 + 3], + ], + "Effect opacity" + ) + close( + await render("reset missing colors", base), + legacy, + "Missing color fallbacks" + ) + close( + await render("explicit black white", { + ...base, + darkColor: "#000000", + lightColor: "#ffffff", + }), + legacy, + "Black white compatibility" + ) + for (const threshold of [0, 0.5, 1]) + for (const invert of [false, true]) { + const bw = await render(`hard-${threshold}-${invert}`, { + threshold, + softness: 0, + noise: 0, + invert, + }) + const colors = await render(`hard-colors-${threshold}-${invert}`, { + threshold, + softness: 0, + noise: 0, + invert, + ...palette, + }) + for (let x = 0; x < 64; x++) + close( + colors.slice(x * 4, x * 4 + 3), + dark.map((v, c) => v + (light[c] - v) * bw[x * 4]), + "Hard threshold" + ) + } + target.setSize(64, 64) + pass.updateLogicalSize(64, 64) + const frames = [] + for (const [index, input] of videoInputs.entries()) { + frames.push( + await render( + `video-${index}`, + { ...base, ...palette, threshold: 0.2, softness: 0.2 }, + input + ) + ) + } + assert.ok( + frames[0].some((v, i) => Math.abs(v - frames[1][i]) > 0.01), + "Threshold did not update with the decoded video" + ) + } finally { + pass.dispose() + target.setSize(64, 1) + } + } + } finally { + for (const input of videoInputs) input.dispose() + source.dispose() + target.dispose() + renderer.dispose() + renderer.backend.device.destroy() + } + + const layer = { + ...createLayer("threshold"), + id: "threshold", + params: { + threshold: 0.4, + softness: 0.02, + noise: 0.08, + darkColor: "#1425a8", + lightColor: "#ff593c", + }, + } + const photo = { ...createLayer("image"), id: "photo", assetId: "sample" } + const project = { + format: "shader-lab", + version: 7, + composition: { width: 512, height: 384 }, + assets: [ + { + id: "sample", + kind: "image", + fileName: "photo.webp", + url: "/scenes/default/rings-photo.webp", + width: 1512, + height: 982, + }, + ], + layers: [layer, photo], + selectedLayerId: layer.id, + sceneConfig: DEFAULT_SCENE_CONFIG, + timeline: { duration: 2, loop: true, tracks: [] }, + } + applyLabProjectFile(parseLabProjectFileValue(project), []) + const snapshot = buildEditorHistorySnapshot() + useLayerStore.getState().updateLayerParam(layer.id, "darkColor", "#000000") + applyEditorHistorySnapshot(snapshot) + const saved = buildLabProjectFile() + useLayerStore.getState().replaceState([]) + applyLabProjectFile( + parseLabProjectFileValue(JSON.parse(JSON.stringify(saved))), + [] + ) + const hydrated = useLayerStore.getState().getLayerById(layer.id) + assert.equal( + hydrated.params.darkColor, + layer.params.darkColor, + "History/hydration lost dark color" + ) + assert.equal( + hydrated.params.lightColor, + layer.params.lightColor, + "Hydration lost light color" + ) + const exported = buildShaderExportConfig(saved) + const frame = runtimeFrame(exported, 0, 0, 1, exported.composition) + assert.equal( + frame.layers.find((l) => l.id === layer.id).params.darkColor, + layer.params.darkColor, + "Runtime export lost color" + ) + const rendered = await renderProject(saved) + const reopened = await renderProject(buildLabProjectFile()) + close( + Array.from(rendered.image.data), + Array.from(reopened.image.data), + "Reopened PNG", + 0 + ) + const old = { + ...project, + layers: [ + { ...layer, params: { threshold: 0.4, softness: 0.02, noise: 0.08 } }, + photo, + ], + } + applyLabProjectFile(parseLabProjectFileValue(old), []) + assert.equal( + useLayerStore.getState().getLayerById(layer.id).params.darkColor, + "#000000", + "Legacy dark default" + ) + assert.equal( + useLayerStore.getState().getLayerById(layer.id).params.lightColor, + "#ffffff", + "Legacy light default" + ) + return { samples, png: rendered.png } +} diff --git a/tests/composition/transparent-text.mjs b/tests/composition/transparent-text.mjs new file mode 100644 index 00000000..f0291020 --- /dev/null +++ b/tests/composition/transparent-text.mjs @@ -0,0 +1,239 @@ +import { TextPass as RuntimeTextPass } from "@runtime/renderer/text-pass" +import * as THREE from "three/webgpu" +import { createLayer } from "@/lib/editor/layers" +import { + applyLabProjectFile, + buildLabProjectFile, + parseLabProjectFileValue, +} from "@/lib/editor/project-file" +import { buildShaderExportConfig } from "@/lib/editor/shader-export" +import { TextPass } from "@/renderer/text-pass" +import { useLayerStore } from "@/store/layer-store" + +function assert(condition, message) { + if (!condition) throw new Error(message) +} + +function close(actual, expected, label, tolerance = 0.002) { + assert( + actual.length === expected.length && + actual.every( + (value, i) => + Number.isFinite(value) && Math.abs(value - expected[i]) <= tolerance + ), + `${label}: expected ${expected}, received ${actual}` + ) +} + +async function checkTextPasses(layer, exportedLayer) { + const renderer = new THREE.WebGPURenderer({ antialias: false }) + await renderer.init() + renderer.toneMapping = THREE.NoToneMapping + const backdrop = [0, 0, 1, 0.4] + const input = new THREE.DataTexture( + new Float32Array(backdrop), + 1, + 1, + THREE.RGBAFormat, + THREE.FloatType + ) + input.needsUpdate = true + const target = new THREE.RenderTarget(256, 192, { + type: THREE.FloatType, + depthBuffer: false, + }) + let samples = 0 + try { + for (const [name, Pass, config] of [ + ["editor", TextPass, layer], + ["runtime", RuntimeTextPass, exportedLayer], + ]) { + const pass = new Pass(`${name}-text`) + try { + pass.resize(256, 192) + pass.updateLogicalSize(256, 192) + pass.updateCompositionRole(config.kind) + pass.updateCompositeMode(config.compositeMode) + pass.updateBlendMode(config.blendMode) + pass.flushColorNode() + const paint = async (params, opacity = 1) => { + pass.updateParams(params) + pass.updateOpacity(opacity) + pass.render(renderer, input, target, 0, 0) + return Array.from( + await renderer.readRenderTargetPixelsAsync(target, 0, 0, 256, 192) + ) + } + const transparent = await paint(config.params) + close( + transparent.slice(0, 4), + backdrop, + `${name}: transparent background preserves underlying alpha` + ) + let glyph = -1 + let softEdge = -1 + for (let i = 0; i < transparent.length; i += 4) { + if (transparent[i] > 0.999 && transparent[i + 1] > 0.999) glyph = i + if (transparent[i + 3] > 0.45 && transparent[i + 3] < 0.95) + softEdge = i + } + assert(glyph >= 0, `${name}: no fully opaque lettering`) + close( + transparent.slice(glyph, glyph + 4), + [1, 1, 1, 1], + `${name}: white glyph is opaque` + ) + assert(softEdge >= 0, `${name}: missing antialiased glyph edges`) + // White over blue always retains blue=1, even along soft alpha edges. + close([transparent[softEdge + 2]], [1], `${name}: no dark fringe`) + const colored = await paint({ ...config.params, textColor: "#ff0000" }) + close( + colored.slice(glyph, glyph + 4), + [1, 0, 0, 1], + `${name}: glyph color is editable` + ) + close( + colored.slice(0, 4), + backdrop, + `${name}: color leaves empty regions untouched` + ) + const solid = await paint({ ...config.params, backgroundAlpha: 1 }) + close( + solid.slice(0, 4), + [0, 0, 0, 1], + `${name}: deliberate solid background` + ) + const faded = await paint(config.params, 0.5) + close( + faded.slice(0, 4), + backdrop, + `${name}: opacity does not fill empty regions` + ) + close( + faded.slice(glyph, glyph + 4), + [5 / 7, 5 / 7, 1, 0.7], + `${name}: glyph opacity composites correctly` + ) + const legacyParams = { ...config.params } + delete legacyParams.backgroundAlpha + const legacy = await paint(legacyParams) + close( + legacy.slice(0, 4), + [0, 0, 0, 1], + `${name}: absent runtime parameter retains solid fallback` + ) + samples += 9 + } finally { + pass.dispose() + } + } + } finally { + input.dispose() + target.dispose() + renderer.dispose() + renderer.backend.device.destroy() + } + return samples +} + +export async function checkTransparentText(renderProject) { + const fixture = await ( + await fetch("/fixtures/solid-text-background.json") + ).json() + const text = createLayer("text") + assert( + text.compositeMode === "filter" && + text.blendMode === "normal" && + text.opacity === 1 && + text.params.backgroundAlpha === 0, + "New text requires a mask, a blend workaround, or an opaque background" + ) + + // Exercise real editor hydration, including files that relied on defaults. + for (const version of [1, 6, 7]) { + for (const alpha of [undefined, 0, 0.35, 1]) { + for (const mode of ["filter", "mask"]) { + const old = structuredClone(fixture) + old.version = version + old.layers[0].compositeMode = mode + old.layers[0].blendMode = "screen" + if (alpha === undefined) delete old.layers[0].params.backgroundAlpha + else old.layers[0].params.backgroundAlpha = alpha + applyLabProjectFile(parseLabProjectFileValue(old), []) + const restored = useLayerStore.getState().layers[0] + assert( + restored.params.backgroundAlpha === (alpha ?? 1) && + restored.compositeMode === mode && + restored.blendMode === "screen", + "Hydration changed saved text opacity, mask mode, or blend mode" + ) + } + } + } + + // Use a bundled deterministic font; leave new composition/alpha defaults intact. + text.params = { + ...text.params, + text: "V3", + fontFamily: "mono", + fontWeight: 400, + fontSize: 96, + letterSpacing: 0, + } + const project = { + ...fixture, + layers: [text], + assets: [], + selectedLayerId: text.id, + sceneConfig: { ...fixture.sceneConfig, backgroundColor: "#2468cc" }, + } + applyLabProjectFile(parseLabProjectFileValue(project), []) + const saved = buildLabProjectFile() + useLayerStore.getState().replaceState([]) + applyLabProjectFile( + parseLabProjectFileValue(JSON.parse(JSON.stringify(saved))), + [] + ) + const reopened = buildLabProjectFile() + assert( + reopened.layers[0].params.backgroundAlpha === 0 && + reopened.layers[0].compositeMode === "filter", + "Save/reopen lost transparent text" + ) + const config = buildShaderExportConfig({ + ...reopened, + timeline: reopened.timeline, + }) + assert( + config.layers[0].params.backgroundAlpha === 0 && + config.layers[0].compositeMode === "filter", + "Shader export lost transparent text" + ) + const transparent = await renderProject(saved) + close( + Array.from(transparent.image.data.slice(0, 4)), + [36, 104, 204, 255], + "Preview/PNG reveal background", + 1 + ) + const restored = await renderProject(reopened) + close( + Array.from(restored.image.data), + Array.from(transparent.image.data), + "Saved/reopened text pixels", + 0 + ) + const solidFile = structuredClone(saved) + solidFile.layers[0].params.backgroundAlpha = 1 + const solid = await renderProject(solidFile) + close( + Array.from(solid.image.data.slice(0, 4)), + [0, 0, 0, 255], + "Optional solid background in preview/PNG", + 0 + ) + return { + samples: await checkTextPasses(reopened.layers[0], config.layers[0]), + png: transparent.png, + } +} diff --git a/tests/composition/tsconfig.json b/tests/composition/tsconfig.json new file mode 100644 index 00000000..f5e2740b --- /dev/null +++ b/tests/composition/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["../../src/*"], + "@runtime/*": ["../../packages/shader-lab-react/src/*"] + } + } +}