diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7a0c800..8c894539 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,3 +50,24 @@ jobs: - name: Ensure changeset exists for PR changes if: github.event_name == 'pull_request' run: bunx changeset status --since=origin/${{ github.base_ref }} + + composition: + # Linux Chromium/SwiftShader currently drops its GPU instance during these + # texture-backed shader compilations. Keep the full GPU checks on macOS. + runs-on: macos-14 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - run: bun install --frozen-lockfile + - run: bunx playwright install chromium + - run: bun run test:composition + - name: Upload rendered compositions + if: always() + uses: actions/upload-artifact@v4 + with: + name: composition-actuals + path: .context/composition-test/ + if-no-files-found: ignore diff --git a/V3-ROADMAP.md b/V3-ROADMAP.md index d7a7ac6b..537b02a1 100644 --- a/V3-ROADMAP.md +++ b/V3-ROADMAP.md @@ -14,6 +14,10 @@ Los PRs pequeños pueden integrarse progresivamente en la rama de V3. Ningún ca 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. +PR principal: [#150 — V3: designer-focused composition and experimental graphics](https://github.com/basementstudio/shader-lab/pull/150). + +Primer paso de la fase 1: [baselines de composición y mapa del canal alfa](tests/composition/README.md). Esta cobertura inicial no cierra la fase ni sustituye la validación visual con las catorce referencias elegidas. + ## 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. 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/tests/composition/README.md b/tests/composition/README.md new file mode 100644 index 00000000..c382b789 --- /dev/null +++ b/tests/composition/README.md @@ -0,0 +1,63 @@ +# V3 composition baselines + +First slice of roadmap steps 1.1–1.2. This PR establishes regression checks before changing alpha behavior; it does not implement transparent composition, groups, or new masks. + +Integration branch: `git-chad/shader-lab-v3-plan`. Parent PR: [#150](https://github.com/basementstudio/shader-lab/pull/150). Child PRs target that branch, never `main`. + +## Run + +```sh +bun install --frozen-lockfile +bunx playwright install chromium +bun run test:composition +``` + +The supported baseline runner is macOS, including the dedicated `macos-14` CI job. Linux Chromium/SwiftShader currently drops its GPU instance during texture-backed shader compilation (`Instance dropped in popErrorScope`); Linux support is not verified. The existing build, lint, and type checks remain on Linux. + +The runner serves a bundled harness on an ephemeral loopback port and launches the locked Playwright Chromium with an explicitly selected SwiftShader WebGPU adapter. No app server, account, or external services are required. Missing WebGPU, browser errors, shader compilation errors, missing baselines, and mismatches fail the command; tests do not silently skip or fall back to WebGL. + +Actual PNGs are saved in `.context/composition-test/`. Run `bun run test:composition --update` only when deliberately capturing a reviewed baseline. Never refresh existing legacy expectations just to make an alpha change pass. + +## Frozen references + +Captured before renderer changes, from the renderer at `6f39a88` (the pre-V3 `origin/main` revision). Fixture JSON files retain explicit version-6 settings instead of regenerating from layer defaults. The four render fixtures are synthetic saved projects created for regression coverage, not user-supplied artworks. They use a local SVG with a transparent cutout and soft alpha edge, and the bundled Geist Mono font. + +| Fixture | What it protects | +| --- | --- | +| `legacy-text-mask` | Existing text in luminance-mask mode, including its opaque black surroundings | +| `solid-text-background` | Deliberate solid text backgrounds and partial layer opacity | +| `transparent-text-screen` | Existing transparent text with an explicitly selected Screen blend | +| `contained-image-halftone` | Contained media, current black letterboxing, and partial-strength halftone | + +Each fixture renders through the editor renderer, compares preview with PNG export, and reopens serialized JSON into a new renderer/device. PNG comparisons allow at most two byte values per channel for backend rounding; no pixels may exceed that tolerance. Reopen and preview/export comparisons are exact within a run. Images are also checked for opaque alpha and nontrivial content. + +`legacy-blends.json` freezes 136 GPU samples across the editor and exported runtime: all 16 blend modes at three opacities, plus five mask sources, two mask modes, and inversion. The shaders are evaluated into float render targets; both implementations must match, and reference values have a tolerance of `0.00001`. These are compatibility expectations, not endorsements of the old alpha semantics. + +`existing-default-project.json` is an unmodified copy of the repository's existing saved project. Parsing, serialization, reopening, viewer hydration, and the actual editor hydration/save path must retain its nine layers, two bundled assets, blend modes, masks, opacity, and parameter values. The editor check also verifies scene settings, composition dimensions, selection, timeline, audio, and the scene-replacement signal. Its video/audio and full effect stack are **not** rendered by this initial suite. + +Editor hydration starts from deliberately different store state. A separate in-memory variant adds an opacity track with keyframes, tracks targeting a missing layer and parameter, and an invalid layer selection. It verifies that hydration preserves valid animation, prunes invalid tracks, clears previous timeline selection/playhead state, and saves only the surviving track. The frozen project fixture remains unchanged. + +## Confirmed alpha boundaries + +These findings come from source inspection and the limited GPU checks above, not a completed end-to-end alpha audit. + +| Boundary | Current behavior | Required follow-up | +| --- | --- | --- | +| `blend-modes.ts` in editor and runtime | Filter mixes RGB using source alpha but returns alpha 1; masks multiply RGB or threshold to black and return alpha 1 | Distinguish source-over composition from effect interpolation; introduce true coverage masks with a compatibility path for saved masks | +| `pass-node.ts` in both renderers | Source and effect passes share composition; default opaque node materials can force alpha 1 independently of shader output | Define straight/premultiplied alpha conventions and material settings together with separate source/effect semantics | +| `media-pass.ts` | Contain mode fills out-of-bounds samples with opaque black | Preserve previous saved appearances while allowing transparent bounds in new composition behavior | +| `pipeline-manager.ts` | A single global ping-pong chain begins over an opaque base and finishes through an opaque blit | Introduce isolated group targets and their own effect scope; keep the scene background a deliberate choice | +| `scene-post-process.ts` | Scene color adjustments return alpha 1 | Preserve coverage through color grading | +| Canvas renderer creation | Editor and runtime use `alpha: false`; editor clears to alpha 1 | Carry alpha through preview/export where supported, including texture output | +| Text creation | New text defaults to mask mode and background alpha 1 | Change only new-layer defaults in phase 2; preserve saved text settings | + +Changing the shared mix formula alone is insufficient: source-over applied to a filtered copy of the same translucent input can increase its coverage. True masks also change the visual meaning of existing saved masks. Resolve both before switching the pipeline to transparent targets. + +## Next implementation slice + +1. Define source versus effect composition, alpha representation, and a persisted compatibility strategy for saved projects and exported runtime configs. +2. Add expected-output GPU tests for empty pixels next to opaque content, partial coverage, source-over blending, effect opacity, and soft masks over colored lower layers. +3. Implement alpha behavior across render targets, materials, postprocessing, preview, and supported exports while keeping these legacy fixtures stable. +4. Extend into isolated groups, scoped masks, reordering, undo, and save/reopen tests. + +Still outstanding for phase 1: the fourteen selected artistic references, broader photographs/portraits/objects and video captures, complete runtime scene/export coverage, group/mask behavior, and the short 3D feasibility checks. This suite is a starting point, not phase-1 acceptance. diff --git a/tests/composition/baselines/contained-image-halftone.png b/tests/composition/baselines/contained-image-halftone.png new file mode 100644 index 00000000..7f2640e0 Binary files /dev/null and b/tests/composition/baselines/contained-image-halftone.png differ diff --git a/tests/composition/baselines/legacy-blends.json b/tests/composition/baselines/legacy-blends.json new file mode 100644 index 00000000..d8c1a9f8 --- /dev/null +++ b/tests/composition/baselines/legacy-blends.json @@ -0,0 +1,1538 @@ +[ + { + "implementation": "editor", + "mode": "normal", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "normal", + "opacity": 0.35, + "rgba": [ + 0.3050000071525574, + 0.5475000143051147, + 0.7599999308586121, + 1 + ] + }, + { + "implementation": "editor", + "mode": "normal", + "opacity": 1, + "rgba": [ + 0.5, + 0.45000001788139343, + 0.5, + 1 + ] + }, + { + "implementation": "editor", + "mode": "multiply", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "multiply", + "opacity": 0.35, + "rgba": [ + 0.19300000369548798, + 0.5264999866485596, + 0.7582499384880066, + 1 + ] + }, + { + "implementation": "editor", + "mode": "multiply", + "opacity": 1, + "rgba": [ + 0.18000000715255737, + 0.39000001549720764, + 0.4949999749660492, + 1 + ] + }, + { + "implementation": "editor", + "mode": "screen", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "screen", + "opacity": 0.35, + "rgba": [ + 0.31200000643730164, + 0.6209999918937683, + 0.9017499685287476, + 1 + ] + }, + { + "implementation": "editor", + "mode": "screen", + "opacity": 1, + "rgba": [ + 0.5200000405311584, + 0.6600000262260437, + 0.9049999713897705, + 1 + ] + }, + { + "implementation": "editor", + "mode": "overlay", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "overlay", + "opacity": 0.35, + "rgba": [ + 0.2840000092983246, + 0.5720000267028809, + 0.8859999179840088, + 1 + ] + }, + { + "implementation": "editor", + "mode": "overlay", + "opacity": 1, + "rgba": [ + 0.4399999976158142, + 0.5200000405311584, + 0.85999995470047, + 1 + ] + }, + { + "implementation": "editor", + "mode": "darken", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "darken", + "opacity": 0.35, + "rgba": [ + 0.20000000298023224, + 0.5475000143051147, + 0.7599999308586121, + 1 + ] + }, + { + "implementation": "editor", + "mode": "darken", + "opacity": 1, + "rgba": [ + 0.20000000298023224, + 0.45000001788139343, + 0.5, + 1 + ] + }, + { + "implementation": "editor", + "mode": "lighten", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "lighten", + "opacity": 0.35, + "rgba": [ + 0.3050000071525574, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "lighten", + "opacity": 1, + "rgba": [ + 0.5, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "color-dodge", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "color-dodge", + "opacity": 0.35, + "rgba": [ + 0.3400000035762787, + 0.6449999809265137, + 0.9174999594688416, + 1 + ] + }, + { + "implementation": "editor", + "mode": "color-dodge", + "opacity": 1, + "rgba": [ + 0.6000000238418579, + 0.7285714745521545, + 0.949999988079071, + 1 + ] + }, + { + "implementation": "editor", + "mode": "color-burn", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "color-burn", + "opacity": 0.35, + "rgba": [ + 0.16500000655651093, + 0.4950000047683716, + 0.7424999475479126, + 1 + ] + }, + { + "implementation": "editor", + "mode": "color-burn", + "opacity": 1, + "rgba": [ + 0.10000000149011612, + 0.30000001192092896, + 0.44999998807907104, + 1 + ] + }, + { + "implementation": "editor", + "mode": "hard-light", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "hard-light", + "opacity": 0.35, + "rgba": [ + 0.2840000092983246, + 0.5720000267028809, + 0.8859999179840088, + 1 + ] + }, + { + "implementation": "editor", + "mode": "hard-light", + "opacity": 1, + "rgba": [ + 0.4399999976158142, + 0.5200000405311584, + 0.85999995470047, + 1 + ] + }, + { + "implementation": "editor", + "mode": "soft-light", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "soft-light", + "opacity": 0.35, + "rgba": [ + 0.22595743834972382, + 0.5877782702445984, + 0.8931843042373657, + 1 + ] + }, + { + "implementation": "editor", + "mode": "soft-light", + "opacity": 1, + "rgba": [ + 0.274164080619812, + 0.5650807023048401, + 0.8805266618728638, + 1 + ] + }, + { + "implementation": "editor", + "mode": "difference", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "difference", + "opacity": 0.35, + "rgba": [ + 0.27000001072883606, + 0.5475000143051147, + 0.8824999332427979, + 1 + ] + }, + { + "implementation": "editor", + "mode": "difference", + "opacity": 1, + "rgba": [ + 0.4000000059604645, + 0.45000001788139343, + 0.8499999642372131, + 1 + ] + }, + { + "implementation": "editor", + "mode": "exclusion", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "exclusion", + "opacity": 0.35, + "rgba": [ + 0.2839999794960022, + 0.5895000100135803, + 0.8859999179840088, + 1 + ] + }, + { + "implementation": "editor", + "mode": "exclusion", + "opacity": 1, + "rgba": [ + 0.4399999678134918, + 0.5700000524520874, + 0.8600000143051147, + 1 + ] + }, + { + "implementation": "editor", + "mode": "hue", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "hue", + "opacity": 0.35, + "rgba": [ + 0.33033299446105957, + 0.5728330016136169, + 0.785332977771759, + 1 + ] + }, + { + "implementation": "editor", + "mode": "hue", + "opacity": 1, + "rgba": [ + 0.572380006313324, + 0.522379994392395, + 0.572380006313324, + 1 + ] + }, + { + "implementation": "editor", + "mode": "saturation", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "saturation", + "opacity": 0.35, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "saturation", + "opacity": 1, + "rgba": [ + 0.19999998807907104, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "color", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "color", + "opacity": 0.35, + "rgba": [ + 0.33033299446105957, + 0.5728330016136169, + 0.785332977771759, + 1 + ] + }, + { + "implementation": "editor", + "mode": "color", + "opacity": 1, + "rgba": [ + 0.572380006313324, + 0.522379994392395, + 0.572380006313324, + 1 + ] + }, + { + "implementation": "editor", + "mode": "luminosity", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "luminosity", + "opacity": 0.35, + "rgba": [ + 0.17466700077056885, + 0.5746670365333557, + 0.8746669888496399, + 1 + ] + }, + { + "implementation": "editor", + "mode": "luminosity", + "opacity": 1, + "rgba": [ + 0.12761999666690826, + 0.5276200175285339, + 0.8276199698448181, + 1 + ] + }, + { + "implementation": "editor", + "source": "alpha", + "mode": "multiply", + "invert": false, + "rgba": [ + 0.10000000149011612, + 0.30000001192092896, + 0.44999998807907104, + 1 + ] + }, + { + "implementation": "editor", + "source": "alpha", + "mode": "multiply", + "invert": true, + "rgba": [ + 0.10000000149011612, + 0.30000001192092896, + 0.44999998807907104, + 1 + ] + }, + { + "implementation": "editor", + "source": "alpha", + "mode": "stencil", + "invert": false, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "source": "alpha", + "mode": "stencil", + "invert": true, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "source": "red", + "mode": "multiply", + "invert": false, + "rgba": [ + 0.1600000113248825, + 0.48000001907348633, + 0.7199999690055847, + 1 + ] + }, + { + "implementation": "editor", + "source": "red", + "mode": "multiply", + "invert": true, + "rgba": [ + 0.03999999910593033, + 0.11999999731779099, + 0.17999997735023499, + 1 + ] + }, + { + "implementation": "editor", + "source": "red", + "mode": "stencil", + "invert": false, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "source": "red", + "mode": "stencil", + "invert": true, + "rgba": [ + 0, + 0, + 0, + 1 + ] + }, + { + "implementation": "editor", + "source": "green", + "mode": "multiply", + "invert": false, + "rgba": [ + 0.06000000238418579, + 0.18000000715255737, + 0.27000001072883606, + 1 + ] + }, + { + "implementation": "editor", + "source": "green", + "mode": "multiply", + "invert": true, + "rgba": [ + 0.14000000059604645, + 0.42000001668930054, + 0.6299999952316284, + 1 + ] + }, + { + "implementation": "editor", + "source": "green", + "mode": "stencil", + "invert": false, + "rgba": [ + 0, + 0, + 0, + 1 + ] + }, + { + "implementation": "editor", + "source": "green", + "mode": "stencil", + "invert": true, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "source": "blue", + "mode": "multiply", + "invert": false, + "rgba": [ + 0.020000001415610313, + 0.06000000238418579, + 0.08999999612569809, + 1 + ] + }, + { + "implementation": "editor", + "source": "blue", + "mode": "multiply", + "invert": true, + "rgba": [ + 0.17999999225139618, + 0.5400000214576721, + 0.809999942779541, + 1 + ] + }, + { + "implementation": "editor", + "source": "blue", + "mode": "stencil", + "invert": false, + "rgba": [ + 0, + 0, + 0, + 1 + ] + }, + { + "implementation": "editor", + "source": "blue", + "mode": "stencil", + "invert": true, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "source": "luminance", + "mode": "multiply", + "invert": false, + "rgba": [ + 0.07837200164794922, + 0.23511601984500885, + 0.3526740074157715, + 1 + ] + }, + { + "implementation": "editor", + "source": "luminance", + "mode": "multiply", + "invert": true, + "rgba": [ + 0.12162800133228302, + 0.36488401889801025, + 0.5473259687423706, + 1 + ] + }, + { + "implementation": "editor", + "source": "luminance", + "mode": "stencil", + "invert": false, + "rgba": [ + 0, + 0, + 0, + 1 + ] + }, + { + "implementation": "editor", + "source": "luminance", + "mode": "stencil", + "invert": true, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "normal", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "normal", + "opacity": 0.35, + "rgba": [ + 0.3050000071525574, + 0.5475000143051147, + 0.7599999308586121, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "normal", + "opacity": 1, + "rgba": [ + 0.5, + 0.45000001788139343, + 0.5, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "multiply", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "multiply", + "opacity": 0.35, + "rgba": [ + 0.19300000369548798, + 0.5264999866485596, + 0.7582499384880066, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "multiply", + "opacity": 1, + "rgba": [ + 0.18000000715255737, + 0.39000001549720764, + 0.4949999749660492, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "screen", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "screen", + "opacity": 0.35, + "rgba": [ + 0.31200000643730164, + 0.6209999918937683, + 0.9017499685287476, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "screen", + "opacity": 1, + "rgba": [ + 0.5200000405311584, + 0.6600000262260437, + 0.9049999713897705, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "overlay", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "overlay", + "opacity": 0.35, + "rgba": [ + 0.2840000092983246, + 0.5720000267028809, + 0.8859999179840088, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "overlay", + "opacity": 1, + "rgba": [ + 0.4399999976158142, + 0.5200000405311584, + 0.85999995470047, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "darken", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "darken", + "opacity": 0.35, + "rgba": [ + 0.20000000298023224, + 0.5475000143051147, + 0.7599999308586121, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "darken", + "opacity": 1, + "rgba": [ + 0.20000000298023224, + 0.45000001788139343, + 0.5, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "lighten", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "lighten", + "opacity": 0.35, + "rgba": [ + 0.3050000071525574, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "lighten", + "opacity": 1, + "rgba": [ + 0.5, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "color-dodge", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "color-dodge", + "opacity": 0.35, + "rgba": [ + 0.3400000035762787, + 0.6449999809265137, + 0.9174999594688416, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "color-dodge", + "opacity": 1, + "rgba": [ + 0.6000000238418579, + 0.7285714745521545, + 0.949999988079071, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "color-burn", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "color-burn", + "opacity": 0.35, + "rgba": [ + 0.16500000655651093, + 0.4950000047683716, + 0.7424999475479126, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "color-burn", + "opacity": 1, + "rgba": [ + 0.10000000149011612, + 0.30000001192092896, + 0.44999998807907104, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "hard-light", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "hard-light", + "opacity": 0.35, + "rgba": [ + 0.2840000092983246, + 0.5720000267028809, + 0.8859999179840088, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "hard-light", + "opacity": 1, + "rgba": [ + 0.4399999976158142, + 0.5200000405311584, + 0.85999995470047, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "soft-light", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "soft-light", + "opacity": 0.35, + "rgba": [ + 0.22595743834972382, + 0.5877782702445984, + 0.8931843042373657, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "soft-light", + "opacity": 1, + "rgba": [ + 0.274164080619812, + 0.5650807023048401, + 0.8805266618728638, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "difference", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "difference", + "opacity": 0.35, + "rgba": [ + 0.27000001072883606, + 0.5475000143051147, + 0.8824999332427979, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "difference", + "opacity": 1, + "rgba": [ + 0.4000000059604645, + 0.45000001788139343, + 0.8499999642372131, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "exclusion", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "exclusion", + "opacity": 0.35, + "rgba": [ + 0.2839999794960022, + 0.5895000100135803, + 0.8859999179840088, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "exclusion", + "opacity": 1, + "rgba": [ + 0.4399999678134918, + 0.5700000524520874, + 0.8600000143051147, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "hue", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "hue", + "opacity": 0.35, + "rgba": [ + 0.33033299446105957, + 0.5728330016136169, + 0.785332977771759, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "hue", + "opacity": 1, + "rgba": [ + 0.572380006313324, + 0.522379994392395, + 0.572380006313324, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "saturation", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "saturation", + "opacity": 0.35, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "saturation", + "opacity": 1, + "rgba": [ + 0.19999998807907104, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "color", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "color", + "opacity": 0.35, + "rgba": [ + 0.33033299446105957, + 0.5728330016136169, + 0.785332977771759, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "color", + "opacity": 1, + "rgba": [ + 0.572380006313324, + 0.522379994392395, + 0.572380006313324, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "luminosity", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "luminosity", + "opacity": 0.35, + "rgba": [ + 0.17466700077056885, + 0.5746670365333557, + 0.8746669888496399, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "luminosity", + "opacity": 1, + "rgba": [ + 0.12761999666690826, + 0.5276200175285339, + 0.8276199698448181, + 1 + ] + }, + { + "implementation": "runtime", + "source": "alpha", + "mode": "multiply", + "invert": false, + "rgba": [ + 0.10000000149011612, + 0.30000001192092896, + 0.44999998807907104, + 1 + ] + }, + { + "implementation": "runtime", + "source": "alpha", + "mode": "multiply", + "invert": true, + "rgba": [ + 0.10000000149011612, + 0.30000001192092896, + 0.44999998807907104, + 1 + ] + }, + { + "implementation": "runtime", + "source": "alpha", + "mode": "stencil", + "invert": false, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "source": "alpha", + "mode": "stencil", + "invert": true, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "source": "red", + "mode": "multiply", + "invert": false, + "rgba": [ + 0.1600000113248825, + 0.48000001907348633, + 0.7199999690055847, + 1 + ] + }, + { + "implementation": "runtime", + "source": "red", + "mode": "multiply", + "invert": true, + "rgba": [ + 0.03999999910593033, + 0.11999999731779099, + 0.17999997735023499, + 1 + ] + }, + { + "implementation": "runtime", + "source": "red", + "mode": "stencil", + "invert": false, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "source": "red", + "mode": "stencil", + "invert": true, + "rgba": [ + 0, + 0, + 0, + 1 + ] + }, + { + "implementation": "runtime", + "source": "green", + "mode": "multiply", + "invert": false, + "rgba": [ + 0.06000000238418579, + 0.18000000715255737, + 0.27000001072883606, + 1 + ] + }, + { + "implementation": "runtime", + "source": "green", + "mode": "multiply", + "invert": true, + "rgba": [ + 0.14000000059604645, + 0.42000001668930054, + 0.6299999952316284, + 1 + ] + }, + { + "implementation": "runtime", + "source": "green", + "mode": "stencil", + "invert": false, + "rgba": [ + 0, + 0, + 0, + 1 + ] + }, + { + "implementation": "runtime", + "source": "green", + "mode": "stencil", + "invert": true, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "source": "blue", + "mode": "multiply", + "invert": false, + "rgba": [ + 0.020000001415610313, + 0.06000000238418579, + 0.08999999612569809, + 1 + ] + }, + { + "implementation": "runtime", + "source": "blue", + "mode": "multiply", + "invert": true, + "rgba": [ + 0.17999999225139618, + 0.5400000214576721, + 0.809999942779541, + 1 + ] + }, + { + "implementation": "runtime", + "source": "blue", + "mode": "stencil", + "invert": false, + "rgba": [ + 0, + 0, + 0, + 1 + ] + }, + { + "implementation": "runtime", + "source": "blue", + "mode": "stencil", + "invert": true, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "source": "luminance", + "mode": "multiply", + "invert": false, + "rgba": [ + 0.07837200164794922, + 0.23511601984500885, + 0.3526740074157715, + 1 + ] + }, + { + "implementation": "runtime", + "source": "luminance", + "mode": "multiply", + "invert": true, + "rgba": [ + 0.12162800133228302, + 0.36488401889801025, + 0.5473259687423706, + 1 + ] + }, + { + "implementation": "runtime", + "source": "luminance", + "mode": "stencil", + "invert": false, + "rgba": [ + 0, + 0, + 0, + 1 + ] + }, + { + "implementation": "runtime", + "source": "luminance", + "mode": "stencil", + "invert": true, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + } +] diff --git a/tests/composition/baselines/legacy-text-mask.png b/tests/composition/baselines/legacy-text-mask.png new file mode 100644 index 00000000..bb1c40ec Binary files /dev/null and b/tests/composition/baselines/legacy-text-mask.png differ diff --git a/tests/composition/baselines/solid-text-background.png b/tests/composition/baselines/solid-text-background.png new file mode 100644 index 00000000..8645a06b Binary files /dev/null and b/tests/composition/baselines/solid-text-background.png differ diff --git a/tests/composition/baselines/transparent-text-screen.png b/tests/composition/baselines/transparent-text-screen.png new file mode 100644 index 00000000..bd1d17c0 Binary files /dev/null and b/tests/composition/baselines/transparent-text-screen.png differ diff --git a/tests/composition/browser.mjs b/tests/composition/browser.mjs new file mode 100644 index 00000000..aed845f3 --- /dev/null +++ b/tests/composition/browser.mjs @@ -0,0 +1,371 @@ +import { buildBlendNode as buildRuntimeBlendNode } from "@runtime/renderer/blend-modes" +import { float, vec4 } from "three/tsl" +import * as THREE from "three/webgpu" +import { + applyLabProjectFile, + buildLabProjectFile, + buildViewerProjectState, + parseLabProjectFileValue, +} from "@/lib/editor/project-file" +import { buildBlendNode } from "@/renderer/blend-modes" +import { buildRendererFrame } from "@/renderer/contracts" +import { createWebGPURenderer } from "@/renderer/create-webgpu-renderer" +import { useAssetStore } from "@/store/asset-store" +import { useEditorStore } from "@/store/editor-store" +import { useLayerStore } from "@/store/layer-store" +import { useTimelineStore } from "@/store/timeline-store" +import { BLEND_MODES } from "@/types/editor" + +function pixels(canvas) { + const copy = document.createElement("canvas") + copy.width = canvas.width + copy.height = canvas.height + const context = copy.getContext("2d", { willReadFrequently: true }) + context.drawImage(canvas, 0, 0) + return context.getImageData(0, 0, copy.width, copy.height) +} + +function canonicalJson(value) { + return JSON.stringify(value, (_key, entry) => { + if (entry && typeof entry === "object" && !Array.isArray(entry)) { + return Object.fromEntries( + Object.entries(entry).sort(([a], [b]) => a.localeCompare(b)) + ) + } + return entry + }) +} + +function compare(actual, expected, tolerance = 0) { + if (actual.width !== expected.width || actual.height !== expected.height) { + throw new Error("Image dimensions changed") + } + let changedPixels = 0 + let maxDelta = 0 + for (let index = 0; index < actual.data.length; index += 4) { + let delta = 0 + for (let channel = 0; channel < 4; channel++) { + delta = Math.max( + delta, + Math.abs(actual.data[index + channel] - expected.data[index + channel]) + ) + } + if (delta > tolerance) changedPixels++ + maxDelta = Math.max(maxDelta, delta) + } + return { changedPixels, maxDelta } +} + +async function settle(renderer, frame) { + renderer.render(frame) + const deadline = performance.now() + 30_000 + while (renderer.hasPendingResources()) { + if (performance.now() > deadline) + throw new Error( + `Timed out loading or compiling a fixture (compilation pending: ${renderer.hasPendingCompilations()})` + ) + await new Promise((resolve) => setTimeout(resolve, 20)) + } + await renderer.prepareForExportFrame(frame.clock.timelineTime, true) + renderer.render(frame) + await renderer.waitForGpuIdle() +} + +async function renderProject(value) { + const project = parseLabProjectFileValue(value) + const state = buildViewerProjectState(project) + const size = project.composition + const canvas = document.createElement("canvas") + const renderer = await createWebGPURenderer(canvas, { + strictPassFailures: true, + }) + try { + await renderer.initialize() + renderer.resize(size, 1) + const frame = buildRendererFrame({ + ...state, + outputSize: size, + viewportSize: size, + delta: 0, + clockTime: 0, + pixelRatio: 1, + timeline: { ...state.timeline, currentTime: 0, isPlaying: false }, + }) + await settle(renderer, frame) + const preview = pixels(canvas) + const exported = renderer.exportFrame(frame, size) + const exportDiff = compare(preview, pixels(exported)) + if (exportDiff.changedPixels) + throw new Error(`Preview/export mismatch: ${JSON.stringify(exportDiff)}`) + return { image: preview, png: exported.toDataURL("image/png") } + } finally { + renderer.dispose() + await renderer.destroyDevice() + } +} + +window.checkProject = async (name, update) => { + const file = await (await fetch(`/fixtures/${name}.json`)).json() + const first = await renderProject(file) + // Reopen a serialized project in a new renderer/device, with fresh textures. + const reopened = await renderProject( + JSON.parse(JSON.stringify(parseLabProjectFileValue(file))) + ) + const reopenDiff = compare(first.image, reopened.image) + if (reopenDiff.changedPixels) + throw new Error(`Reopen mismatch: ${JSON.stringify(reopenDiff)}`) + const colors = new Set() + for (let i = 0; i < first.image.data.length; i += 4) { + colors.add( + `${first.image.data[i]},${first.image.data[i + 1]},${first.image.data[i + 2]}` + ) + if (first.image.data[i + 3] !== 255) + throw new Error("Legacy fixture unexpectedly became transparent") + } + if (colors.size < 8) + throw new Error("Fixture is blank or has lost its content") + let baselineDiff = null + if (!update) { + const response = await fetch(`/baselines/${name}.png`) + if (!response.ok) throw new Error(`Missing baseline: ${name}`) + const bitmap = await createImageBitmap(await response.blob()) + const reference = document.createElement("canvas") + reference.width = bitmap.width + reference.height = bitmap.height + reference.getContext("2d").drawImage(bitmap, 0, 0) + bitmap.close() + baselineDiff = compare(first.image, pixels(reference), 2) + } + return { png: first.png, baselineDiff, reopenDiff, colors: colors.size } +} + +window.checkLegacyBlendContract = async () => { + const renderer = new THREE.WebGPURenderer({ antialias: false }) + await renderer.init() + renderer.toneMapping = THREE.NoToneMapping + const target = new THREE.RenderTarget(1, 1, { + type: THREE.FloatType, + depthBuffer: false, + }) + const geometry = new THREE.PlaneGeometry(2, 2) + const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1) + const material = new THREE.MeshBasicNodeMaterial({ + transparent: true, + blending: THREE.NoBlending, + }) + const mesh = new THREE.Mesh(geometry, material) + const scene = new THREE.Scene() + scene.add(mesh) + const results = [] + try { + for (const [implementation, build] of [ + ["editor", buildBlendNode], + ["runtime", buildRuntimeBlendNode], + ]) { + for (const mode of BLEND_MODES) { + for (const opacity of [0, 0.35, 1]) { + material.colorNode = build( + mode, + vec4(0.2, 0.6, 0.9, 1), + vec4(0.8, 0.3, 0.1, 0.5), + float(opacity) + ) + material.needsUpdate = true + renderer.setRenderTarget(target) + renderer.render(scene, camera) + const data = await renderer.readRenderTargetPixelsAsync( + target, + 0, + 0, + 1, + 1 + ) + results.push({ + implementation, + mode, + opacity, + rgba: Array.from(data), + }) + } + } + for (const source of ["alpha", "red", "green", "blue", "luminance"]) { + for (const mode of ["multiply", "stencil"]) { + for (const invert of [false, true]) { + material.colorNode = build( + "normal", + vec4(0.2, 0.6, 0.9, 1), + vec4(0.8, 0.3, 0.1, 0.5), + float(1), + "mask", + { source, mode, invert } + ) + material.needsUpdate = true + renderer.render(scene, camera) + const data = await renderer.readRenderTargetPixelsAsync( + target, + 0, + 0, + 1, + 1 + ) + results.push({ + implementation, + source, + mode, + invert, + rgba: Array.from(data), + }) + } + } + } + } + return results + } finally { + target.dispose() + material.dispose() + geometry.dispose() + renderer.dispose() + renderer.backend.device.destroy() + } +} + +window.checkExistingProject = async () => { + const original = await ( + await fetch("/fixtures/existing-default-project.json") + ).json() + const parsed = parseLabProjectFileValue(original) + const reopened = parseLabProjectFileValue(JSON.parse(JSON.stringify(parsed))) + if (JSON.stringify(parsed) !== JSON.stringify(reopened)) + throw new Error("Saved project is not stable across parse/serialize/reopen") + const state = buildViewerProjectState(reopened) + // Start from a different editor session so no-op restoration cannot pass + // merely because the stores already contain their default values. + useLayerStore.getState().replaceState([], null, null) + useAssetStore.getState().replaceAssets([]) + useEditorStore.getState().updateSceneConfig({ exposure: 2 }) + useEditorStore.getState().setOutputSize(320, 240) + useTimelineStore.setState({ currentTime: 3, isPlaying: false, loop: false }) + const revision = useEditorStore.getState().sceneRevision + const missing = applyLabProjectFile(reopened, []) + if (missing.missingAssetCount || missing.missingAudioSource) { + throw new Error("Editor hydration lost bundled media") + } + if (useEditorStore.getState().sceneRevision !== revision + 1) { + throw new Error("Editor did not signal scene replacement") + } + const stored = buildLabProjectFile() + for (const key of [ + "layers", + "sceneConfig", + "composition", + "selectedLayerId", + "timeline", + "audio", + ]) { + if (JSON.stringify(stored[key]) !== JSON.stringify(reopened[key])) { + throw new Error(`Editor hydration/save changed ${key}`) + } + } + if ( + useAssetStore.getState().assets.length !== original.assets.length || + useLayerStore.getState().layers.length !== original.layers.length || + useTimelineStore.getState().currentTime !== 0 + ) { + throw new Error("Editor stores were not restored") + } + if ( + state.layers.length !== original.layers.length || + state.assets.length !== original.assets.length + ) { + throw new Error("Existing project lost layers or bundled assets") + } + for (const [index, layer] of state.layers.entries()) { + const saved = original.layers[index] + for (const key of [ + "blendMode", + "compositeMode", + "maskConfig", + "opacity", + "params", + ]) { + if (JSON.stringify(layer[key]) !== JSON.stringify(saved[key])) + throw new Error(`Existing ${layer.type} layer changed ${key}`) + } + } + const validTrack = { + id: "hydration-opacity", + layerId: reopened.layers[0].id, + binding: { + kind: "layer", + property: "opacity", + label: "Opacity", + valueType: "number", + }, + enabled: true, + keyframes: [ + { + id: "opacity-start", + time: 0, + value: 0.25, + easing: { type: "bezier", controlPoints: [0, 0, 1, 1] }, + }, + { + id: "opacity-end", + time: 1, + value: 1, + easing: { type: "bezier", controlPoints: [0, 0, 1, 1] }, + }, + ], + } + const tracks = [ + validTrack, + { ...validTrack, id: "missing-layer", layerId: "deleted-layer" }, + { + ...validTrack, + id: "missing-parameter", + binding: { + kind: "param", + key: "removed-parameter", + label: "Removed", + valueType: "number", + }, + }, + ] + const withTracks = structuredClone(reopened) + withTracks.timeline.tracks = tracks + withTracks.selectedLayerId = "deleted-layer" + useTimelineStore.setState({ + tracks: [{ ...validTrack, id: "previous-session-track" }], + currentTime: 3, + isPlaying: false, + selectedTrackId: "previous-session-track", + selectedKeyframeId: "previous-session-keyframe", + selectedKeyframeIds: ["previous-session-keyframe"], + }) + applyLabProjectFile(withTracks, useAssetStore.getState().assets) + const timeline = useTimelineStore.getState() + if (canonicalJson(timeline.tracks) !== canonicalJson([validTrack])) { + throw new Error( + `Hydration must retain valid tracks and prune missing layers/parameters: ${JSON.stringify(timeline.tracks)}` + ) + } + if ( + timeline.currentTime !== 0 || + !timeline.isPlaying || + timeline.selectedTrackId !== null || + timeline.selectedKeyframeId !== null || + timeline.selectedKeyframeIds.length !== 0 || + useLayerStore.getState().selectedLayerId !== null + ) { + throw new Error( + "Hydration retained stale timeline state or an invalid layer selection" + ) + } + if ( + canonicalJson(buildLabProjectFile().timeline.tracks) !== + canonicalJson([validTrack]) + ) { + throw new Error("Saving restored pruned timeline tracks") + } + return { layers: state.layers.length, assets: state.assets.length } +} 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/run.mjs b/tests/composition/run.mjs new file mode 100644 index 00000000..7aaabf08 --- /dev/null +++ b/tests/composition/run.mjs @@ -0,0 +1,173 @@ +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")) + ) + 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( + "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` + ) + } + 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/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/*"] + } + } +}