feat(shader-transitions): optional shader field — CSS crossfade mixing in HyperShader#886
Conversation
|
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
There was a problem hiding this comment.
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.shaderoptional and skip WebGL program compilation whenshaderis omitted. - Represent CSS-only transitions as cached transitions with
prog: nulland mark them as immediately ready/fallback. - Update
HfTransitionMeta.shaderto 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.
| renderShader( | ||
| gl, | ||
| quadBuf, | ||
| state.prog, | ||
| state.prog!, // non-null: fallback path returns before reaching here | ||
| interpolatedFromTex, |
| cacheKey: "", | ||
| dirty: true, | ||
| ready: false, | ||
| fallback: false, | ||
| persisted: false, | ||
| dirty: !isCssFallback, | ||
| ready: isCssFallback, // CSS fallback needs no prewarming | ||
| fallback: isCssFallback, | ||
| persisted: isCssFallback, | ||
| textureReady: false, |
| /** 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>
54a5398 to
8cad217
Compare
|
|
||
| const programs = new Map<string, WebGLProgram>(); | ||
| for (const t of transitions) { | ||
| if (!t.shader) continue; // CSS-only transitions have no WebGL program |
| // 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 | ||
|
|
| // 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>
There was a problem hiding this comment.
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 === undefinedare 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 forshader === undefinedtransitions whenpageCompositingFlagis 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,
});
| * 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>
There was a problem hiding this comment.
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,
| // 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({ |
miguel-heygen
left a comment
There was a problem hiding this comment.
Clean work. The CSS crossfade mixing path is well-guarded:
Verified:
renderShaderrequires non-nullprog— both call sites (page composite viaprograms.get()+!prog continue, browser path via thecache.fallback || cache.prog === nullearly return) narrow correctly before reaching it- CSS opacity tweens in
initEngineModeare harmless in the Node-side layered path —captureTransitionFrameOnWorkercaptures scenes independently by ID, not affected by CSS opacity installPageSideCompositorskips CSS entries safely —resolved[]is time-indexed, not position-indexed, so skipping doesn't misalign scene pairsisCssOnlyTransitionhelper correctly guardsdisposeCachedTransitionandmarkScenesDirtyfrom routing CSS transitions through the WebGL prewarm path- Strict
undefinedcheck ont.shader— empty string from vanilla JS callers still fails loudly throughcreateProgram, 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.
What
Makes the
shaderfield inTransitionConfigoptional. When omitted, HyperShader performs a smooth CSS opacity crossfade instead of a WebGL effect — using the existingapplyFallbackTransition()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-whiteat 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:
How
TransitionConfig.shaderchanged fromShaderName(required) toShaderName | undefined(optional)isCssFallback = !t.shader— fallback transitions getprog: null,ready: true,fallback: true,dirty: false— no prewarming neededrenderShadercall uses non-null assertion (state.prog!) — safe because the fallback path returns before this pointHfTransitionMeta.shaderin both the local interface andengine/src/types.tsmade optional to matchCachedTransition.progtype widened toWebGLProgram | nullTest plan
packages/shader-transitions— clean TypeScript, no errorssdf-irisshader + CSS crossfade +domain-warpshaderapplyFallbackTransition()smooth opacity interpolationHyperShader.init()withouttimeline:option)