Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/transparent-media-bounds.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
branches:
- main
- git-chad/shader-lab-v3-plan
- git-chad/v3-*
push:
branches:
- main
Expand Down Expand Up @@ -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
10 changes: 7 additions & 3 deletions V3-ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@ 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`. El siguiente PR usa esa rama como base. Al integrar PRs, actualizar las bases de los descendientes para conservar la pila; únicamente el PR principal apunta a `main` y permanece en borrador hasta completar la validación obligatoria.

PR principal: [#150 — V3: designer-focused composition and experimental graphics](https://github.com/basementstudio/shader-lab/pull/150).

Primer paso de la fase 1: [baselines de composición y mapa del canal alfa](tests/composition/README.md). Esta cobertura inicial no cierra la fase ni sustituye la validación visual con las catorce referencias elegidas.

## Fase 1 — Resolver la base de composición y proteger proyectos existentes

Expand Down
9 changes: 7 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions packages/shader-lab-react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,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)
Expand Down
9 changes: 8 additions & 1 deletion packages/shader-lab-react/src/renderer/media-pass.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
}
Expand Down
10 changes: 10 additions & 0 deletions src/lib/editor/config/layer-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ const mediaPlacementParams = [
],
type: "select",
},
{
defaultValue: true,
key: "transparentBounds",
label: "Transparent Borders",
type: "boolean",
visibleWhen: {
equals: "contain",
key: "fitMode",
},
},
{
defaultValue: 1,
key: "scale",
Expand Down
9 changes: 9 additions & 0 deletions src/lib/editor/project-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,15 @@ export function migrateLayerParams(
): LayerParameterValues {
const params: LayerParameterValues = { ...layer.params }

// Files without this setting predate transparent contain bounds. Preserve
// their black borders before filling missing parameters with new defaults.
if (
(layer.type === "image" || layer.type === "video") &&
params.transparentBounds === undefined
) {
params.transparentBounds = false
}

if (layer.type === "ascii" && typeof params.fontWeight === "string") {
params.fontWeight = LEGACY_ASCII_FONT_WEIGHTS[params.fontWeight] ?? 400
}
Expand Down
9 changes: 8 additions & 1 deletion src/renderer/media-pass.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,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
Expand All @@ -47,6 +48,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)
Expand Down Expand Up @@ -114,6 +116,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

Expand Down Expand Up @@ -212,7 +215,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)
}
Expand Down
71 changes: 71 additions & 0 deletions tests/composition/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# V3 composition baselines

Regression coverage for roadmap steps 1.1–1.2. The first PR establishes legacy baselines; its stacked successor adds transparent media bounds. Full transparent composition, groups, and new masks remain outstanding.

Integration branch: `git-chad/shader-lab-v3-plan`. Parent PR: [#150](https://github.com/basementstudio/shader-lab/pull/150). The first child, [#151](https://github.com/basementstudio/shader-lab/pull/151), targets integration. Each subsequent stacked PR targets the preceding feature 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.

## Transparent media bounds

New image and video layers expose **Transparent Borders** when Fit is **Contain**. It defaults on; disabling it restores black borders. Saved layers without `transparentBounds` are migrated to `false` before new defaults are applied. Runtime configs that omit it also keep black borders. Cover mode is unchanged, including its existing edge sampling. No saved fixture or visual baseline is refreshed for this change.

`media-bounds.mjs` renders the editor and runtime MediaPass with both the SVG and bundled video over a colored input texture. It checks legacy/missing, explicit solid, transparent, partial-opacity, scaled/offset, and Cover cases. It also checks new-layer defaults, migration, save/reopen, shader-config export, and preview/PNG export against explicit pixel expectations. Rendered transparent/solid examples are saved alongside the baseline artifacts.

This change reveals lower content at empty media bounds; it does not make the scene canvas or exported PNG background transparent. The existing scene background, mask semantics, and global effect scope remain 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 now supports transparent out-of-bounds samples, defaulting on for new layers; absent settings retain black | Broader alpha composition remains separate from this source-boundary fix |
| `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.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading