Skip to content

feat(shader-transitions): optional shader field — CSS crossfade mixing in HyperShader#886

Merged
ukimsanov merged 4 commits into
mainfrom
feat/shader-optional-css-mix
May 20, 2026
Merged

feat(shader-transitions): optional shader field — CSS crossfade mixing in HyperShader#886
ukimsanov merged 4 commits into
mainfrom
feat/shader-optional-css-mix

Conversation

@ukimsanov
Copy link
Copy Markdown
Collaborator

What

Makes the shader field in TransitionConfig optional. When omitted, HyperShader performs a smooth CSS opacity crossfade instead of a WebGL effect — using the existing applyFallbackTransition() path that was already there for texture-load failures.

Why

Previously, using HyperShader.init() locked the entire composition into WebGL-only transitions. Any transition that didn't need a shader effect (smooth cuts, crossfades between related beats) still required picking the least-intrusive shader (flash-through-white at 0.01s). This was an all-or-nothing constraint that produced unwanted white flashes in practice.

Now shader and CSS crossfade transitions can be mixed freely in the same composition:

var tl = HyperShader.init({
  bgColor: "#000",
  scenes: ["beat-1", "beat-2", "beat-3", "beat-4"],
  transitions: [
    { time: 4.0, shader: "sdf-iris", duration: 0.7 },   // WebGL shader
    { time: 8.5, duration: 0.8 },                         // no shader → CSS crossfade
    { time: 13.0, shader: "domain-warp", duration: 0.6 }, // WebGL shader
  ],
});

How

  • TransitionConfig.shader changed from ShaderName (required) to ShaderName | undefined (optional)
  • In the program compilation loop: skip transitions with no shader
  • In the transition setup loop: isCssFallback = !t.shader — fallback transitions get prog: null, ready: true, fallback: true, dirty: false — no prewarming needed
  • renderShader call uses non-null assertion (state.prog!) — safe because the fallback path returns before this point
  • HfTransitionMeta.shader in both the local interface and engine/src/types.ts made optional to match
  • CachedTransition.prog type widened to WebGLProgram | null

Test plan

  • Built packages/shader-transitions — clean TypeScript, no errors
  • Tested with a 3-scene composition: sdf-iris shader + CSS crossfade + domain-warp shader
  • Rendered to MP4 — both transition types composite correctly
  • Verified CSS crossfade fallback uses applyFallbackTransition() smooth opacity interpolation
  • Player scrubber works correctly (using HyperShader.init() without timeline: option)

@miguel-heygen
Copy link
Copy Markdown
Collaborator

fix the failed ci checks btw

…de mixing

Allow omitting the shader field in TransitionConfig to get a smooth CSS
opacity crossfade instead of a WebGL effect. HyperShader manages all scene
visibility regardless of transition type, so shader and CSS crossfade
transitions can now be mixed freely in the same composition.

When shader is omitted:
- No WebGL program is compiled or cached for that transition
- The existing applyFallbackTransition() path handles the crossfade
- No texture prewarming needed — transition is marked ready immediately

Tested: verified with a 3-scene composition (sdf-iris + CSS crossfade)
rendered to MP4. Both transition types render correctly.

engine/src/types.ts: HfTransitionMeta.shader is now optional to match
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates @hyperframes/shader-transitions and the engine protocol types to allow transitions that omit a shader and instead use the existing DOM/CSS opacity crossfade fallback path. This enables mixing WebGL shader transitions and CSS crossfades within the same composition, without forcing a “minimal” shader everywhere.

Changes:

  • Make TransitionConfig.shader optional and skip WebGL program compilation when shader is omitted.
  • Represent CSS-only transitions as cached transitions with prog: null and mark them as immediately ready/fallback.
  • Update HfTransitionMeta.shader to be optional (engine protocol + local bundle copy) to reflect CSS crossfade transitions.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
packages/shader-transitions/src/hyper-shader.ts Makes shader optional, skips program compilation for CSS-only transitions, and sets up cached transitions to use the CSS fallback path with prog: null.
packages/engine/src/types.ts Updates the protocol type to make HfTransitionMeta.shader optional for CSS crossfade transitions.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 1164 to 1168
renderShader(
gl,
quadBuf,
state.prog,
state.prog!, // non-null: fallback path returns before reaching here
interpolatedFromTex,
Comment on lines 1314 to 1319
cacheKey: "",
dirty: true,
ready: false,
fallback: false,
persisted: false,
dirty: !isCssFallback,
ready: isCssFallback, // CSS fallback needs no prewarming
fallback: isCssFallback,
persisted: isCssFallback,
textureReady: false,
Comment on lines +47 to +48
/** Shader identifier. Undefined when the transition is a CSS crossfade. */
shader?: string;
…e and unblock CI

Three follow-on fixes after the optional-shader change rebased onto current
main (PR #832 introduced page-side compositing and the producer's hf#732
layered pipeline since this PR was opened).

shader-transitions/hyper-shader.ts
- Treat `cache.prog === null` as the canonical immutable marker for
  CSS-only transitions via a new `isCssOnlyTransition()` helper.
- `disposeCachedTransition()` now restores the always-ready CSS fallback
  state for prog=null caches instead of zeroing `fallback`/`ready` — the
  previous behaviour, combined with `markScenesDirty()` re-running the
  prewarm/capture pipeline, could put a CSS-only cache through the WebGL
  path and reach `renderShader(state.prog!)` with a null prog (Copilot
  review on lines 1168 + 1319).
- `markScenesDirty()` skips CSS-only caches; they have no shader to
  recompile and no texture pyramid to recapture.
- `ensureTransitionCachesReady()` filters CSS-only caches out of the
  prewarm work list.
- `tickShader()` now routes on `cache.fallback || cache.prog === null`
  and threads a narrowed non-null `prog` local into `renderShader()`,
  removing the unsound `state.prog!` non-null assertion.
- `initEngineMode()` filters CSS-only transitions before passing them to
  `installPageSideCompositor()`, which expects `shader: ShaderName`
  (required). Page-side compositing is shader-only; CSS crossfades stay
  on the GSAP opacity timeline.

producer/render/stages/captureHdrHybridLoop.ts
producer/render/stages/captureHdrSequentialLoop.ts
- Guard `activeTransition.shader` against undefined: when omitted, route
  the Node-side blend through `crossfade` (the engine's canonical
  opacity blend, equivalent to `applyFallbackTransition()` on the page).
- The hybrid path also bypasses the worker pool when `shaderName` is
  absent and runs `crossfade` inline.

This addresses the Copilot review comments and unblocks the 5 failing CI
jobs (Build, Typecheck, CLI smoke, Windows tests, Windows render) which
all rooted in 4 TS errors at these exact sites.

Co-authored-by: Cursor <cursoragent@cursor.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.


const programs = new Map<string, WebGLProgram>();
for (const t of transitions) {
if (!t.shader) continue; // CSS-only transitions have no WebGL program
Comment on lines 1305 to 1310
// CSS-only transition when shader is omitted — uses the fallback opacity
// crossfade path. No WebGL program or texture prewarming needed.
const isCssFallback = !t.shader;
const prog = isCssFallback ? null : (programs.get(t.shader!) ?? null);
if (!isCssFallback && !prog) continue; // shader requested but not compiled

Comment on lines +2277 to +2286
// Page-side compositing only handles WebGL shader transitions. CSS
// crossfades are driven by GSAP opacity timelines elsewhere, so filter
// them out — passing them in would break the compositor's required
// `shader` field and produce a dead transition window with no rendering.
const shaderTransitions = transitions.filter(
(t): t is TransitionConfig & { shader: ShaderName } => !!t.shader,
);
installPageSideCompositor({
scenes,
transitions,
transitions: shaderTransitions,
Three follow-up fixes from the Copilot review on commit 8cad217:

1. Use strict `t.shader === undefined` instead of `!t.shader` (Copilot c4)
   in both the WebGL program compile loop and the page-side compositor.
   An empty-string `shader: ""` from a vanilla-JS caller (the IIFE bundle
   is hand-loaded via <script> tags in user HTML) should reach the shader
   registry and surface a loud "unknown shader" error, not silently
   degrade to a crossfade.

2. Graceful degradation when shader compile fails (Copilot c5). The
   previous `continue` dropped the transition from `cachedTransitions`,
   which also dropped its scene-visibility timeline entries and broke
   scene progression. Now: log a warning and downgrade to the CSS
   crossfade fallback (prog=null, fallback=true) so the opacity timeline
   still runs and the composition keeps playing.

3. Preserve index-to-scene-pair correlation when calling the page-side
   compositor (Copilot c6). The earlier filter `transitions.filter(t =>
   !!t.shader)` shifted indices, so a shader transition at original index
   2 (sitting between CSS crossfades) would be paired with scenes[1] and
   scenes[2] inside `installPageSideCompositor` instead of the correct
   scenes[2] and scenes[3]. The compositor now accepts the full array,
   makes `PageCompositeTransitionConfig.shader` optional, and skips
   CSS-only entries internally while keeping `transitions[i]` aligned
   with `scenes[i]`/`scenes[i+1]`.

Co-authored-by: Cursor <cursoragent@cursor.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

packages/shader-transitions/src/hyper-shader.ts:2301

  • In engine render mode with page-side compositing enabled, transitions with shader === undefined are skipped by the compositor, but the engine-mode GSAP timeline only performs opacity flips (both scenes stay opacity=1 for the full transition window, then the from-scene drops to 0 at the end). That means CSS-crossfade transitions will render as two fully-visible scenes overlaid (ghosting) when the producer chooses the page-side compositing + single-frame capture path. Consider either (a) scheduling an actual opacity crossfade tween for shader === undefined transitions when pageCompositingFlag is true, or (b) having the page-side compositor handle undefined-shader entries (e.g. treat them as a simple mix) so the captured frame matches the intended crossfade.
    // Pass the full transitions array so transition[i] still pairs with
    // scenes[i]/scenes[i+1]. The compositor itself skips entries with
    // `shader === undefined` while preserving the index↔scene mapping.
    // (CSS crossfades remain driven by the GSAP opacity timeline.)
    installPageSideCompositor({
      scenes,
      transitions,
      bgColor,
      accentColors,
      width: compWidth,
      height: compHeight,
      defaultDuration: DEFAULT_DURATION,
    });

Comment on lines +46 to +50
* Shader id. Undefined entries are CSS crossfades — the page-side
* compositor skips them so the GSAP opacity timeline handles the blend,
* but the entry stays in the array to preserve `transitions[i]` ↔
* `scenes[i]`/`scenes[i+1]` index alignment for the surrounding shader
* entries.
…n engine mode

Address Copilot round-3 review: the previous engine-mode timeline used
`tl.set(toId, opacity:1, T)` + `tl.set(fromId, opacity:0, T+dur)` for
every transition. That keeps BOTH scenes at opacity:1 throughout the
transition window. The Node-side layered compositor handles this fine —
it captures each scene separately, masks opacity per layer, and runs the
blend itself — but the page-side compositing path (one opaque RGB
screenshot per frame, opt-in via EngineConfig.enablePageSideCompositing)
relies on the page to produce a correct frame. With `shader === undefined`
the page-side compositor skips the entry, so the screenshot would show
both scenes stacked at 100% opacity (visible ghosting) instead of a blend.

Fix: schedule an actual opacity-crossfade tween in `initEngineMode`
when `t.shader === undefined`. Shader transitions keep the existing
opacity-flip pattern because the Node-side compositor needs both scenes
fully visible to capture them. The crossfade is harmless in the layered
Node path because `applyDomLayerMask` overrides per-scene opacity during
each capture anyway.

Also corrects docstrings in engineModePageComposite.ts and at the
installPageSideCompositor call site that previously claimed the GSAP
timeline "handles the blend" — it now actually does.

Co-authored-by: Cursor <cursoragent@cursor.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

packages/shader-transitions/src/hyper-shader.ts:2314

  • installPageSideCompositor() returns a boolean but the result is ignored. If page-side compositing is enabled by the producer and the compositor fails to install (unsupported drawElementImage/WebGL, or shader program compile failures), engine-mode shader transitions will remain in the opacity-flip state (both scenes visible) with no Node-side blend, producing incorrect frames. Consider checking the return value and falling back deterministically (e.g., re-schedule those transitions as CSS crossfades, or otherwise disable page-side compositing in a way the producer can react to).
    installPageSideCompositor({
      scenes,
      transitions,
      bgColor,
      accentColors,

Comment on lines +263 to 272
// When the @hyperframes/shader-transitions composition omits the
// shader on a transition entry, it requests a CSS crossfade. The
// engine-side path uses applyFallbackTransition() on the page; the
// producer's Node-side layered pipeline runs the equivalent here
// by routing the blend through `crossfade`.
const shaderName = activeTransition.shader;
const dispatch: Promise<void> = (async () => {
if (poolRef) {
if (poolRef && shaderName) {
const blendStart = Date.now();
const result = await poolRef.run({
Copy link
Copy Markdown
Collaborator

@miguel-heygen miguel-heygen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean work. The CSS crossfade mixing path is well-guarded:

Verified:

  • renderShader requires non-null prog — both call sites (page composite via programs.get() + !prog continue, browser path via the cache.fallback || cache.prog === null early return) narrow correctly before reaching it
  • CSS opacity tweens in initEngineMode are harmless in the Node-side layered path — captureTransitionFrameOnWorker captures scenes independently by ID, not affected by CSS opacity
  • installPageSideCompositor skips CSS entries safely — resolved[] is time-indexed, not position-indexed, so skipping doesn't misalign scene pairs
  • isCssOnlyTransition helper correctly guards disposeCachedTransition and markScenesDirty from routing CSS transitions through the WebGL prewarm path
  • Strict undefined check on t.shader — empty string from vanilla JS callers still fails loudly through createProgram, good defensive choice
  • Compilation failure degradation (shader specified but programs.get() returns null) falls through to CSS crossfade with a warning — correct

One note: The transition cache state machine (dirty/ready/fallback/persisted) has no test coverage. The initial values for CSS transitions (dirty: false, ready: true, fallback: true, persisted: true) and the early-return in disposeCachedTransition are correct but would benefit from a unit test. Not a blocker.

@ukimsanov ukimsanov merged commit 16d2966 into main May 20, 2026
42 checks passed
@ukimsanov ukimsanov deleted the feat/shader-optional-css-mix branch May 20, 2026 02:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants