Skip to content

Commit 16d2966

Browse files
authored
Merge pull request #886 from heygen-com/feat/shader-optional-css-mix
feat(shader-transitions): optional shader field — CSS crossfade mixing in HyperShader
2 parents ce95c9a + f9d22df commit 16d2966

5 files changed

Lines changed: 121 additions & 26 deletions

File tree

packages/engine/src/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ export interface HfTransitionMeta {
4545
time: number;
4646
/** Transition duration (seconds) */
4747
duration: number;
48-
/** Shader identifier (e.g. "fade", "wipe") */
49-
shader: string;
48+
/** Shader identifier. Undefined when the transition is a CSS crossfade. */
49+
shader?: string;
5050
/** GSAP easing string (e.g. "power2.inOut") */
5151
ease: string;
5252
/** Scene id the transition starts from */

packages/producer/src/services/render/stages/captureHdrHybridLoop.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -260,11 +260,17 @@ export async function runHybridLayeredFrameLoop(input: HybridLoopInput): Promise
260260
// awaits it. The encoder reorder buffer fences ordering so out-
261261
// of-order blend completion is fine.
262262
const frameIdx = i;
263+
// When the @hyperframes/shader-transitions composition omits the
264+
// shader on a transition entry, it requests a CSS crossfade. The
265+
// engine-side path uses applyFallbackTransition() on the page; the
266+
// producer's Node-side layered pipeline runs the equivalent here
267+
// by routing the blend through `crossfade`.
268+
const shaderName = activeTransition.shader;
263269
const dispatch: Promise<void> = (async () => {
264-
if (poolRef) {
270+
if (poolRef && shaderName) {
265271
const blendStart = Date.now();
266272
const result = await poolRef.run({
267-
shader: activeTransition.shader,
273+
shader: shaderName,
268274
bufferA: buffers.bufferA,
269275
bufferB: buffers.bufferB,
270276
output: buffers.output,
@@ -277,7 +283,9 @@ export async function runHybridLayeredFrameLoop(input: HybridLoopInput): Promise
277283
buffers.output = result.output;
278284
addHdrTiming(hdrPerf, "transitionCompositeMs", blendStart);
279285
} else {
280-
const transitionFn: TransitionFn = TRANSITIONS[activeTransition.shader] ?? crossfade;
286+
const transitionFn: TransitionFn = shaderName
287+
? (TRANSITIONS[shaderName] ?? crossfade)
288+
: crossfade;
281289
const blendStart = Date.now();
282290
transitionFn(
283291
buffers.bufferA,

packages/producer/src/services/render/stages/captureHdrSequentialLoop.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,13 @@ export async function runSequentialLayeredFrameLoop(input: SequentialLoopInput):
172172
});
173173
}
174174

175-
const transitionFn: TransitionFn = TRANSITIONS[activeTransition.shader] ?? crossfade;
175+
// CSS-crossfade transitions (shader omitted in the composition) take
176+
// the same Node-side blend path — `crossfade` is the engine's
177+
// canonical opacity blend, equivalent to applyFallbackTransition().
178+
const shaderName = activeTransition.shader;
179+
const transitionFn: TransitionFn = shaderName
180+
? (TRANSITIONS[shaderName] ?? crossfade)
181+
: crossfade;
176182
transitionFn(
177183
transitionBuffers.bufferA,
178184
transitionBuffers.bufferB,

packages/shader-transitions/src/engineModePageComposite.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,15 @@ import { isHtmlInCanvasCaptureSupported } from "./capture.js";
4242

4343
interface PageCompositeTransitionConfig {
4444
time: number;
45-
shader: ShaderName;
45+
/**
46+
* Shader id. Undefined entries are CSS crossfades — the page-side
47+
* compositor skips them, and the GSAP timeline in `initEngineMode`
48+
* schedules an actual opacity-crossfade tween for those entries so the
49+
* single page screenshot contains a correct blended frame. The entry
50+
* stays in the array to preserve `transitions[i]` ↔ `scenes[i]`/
51+
* `scenes[i+1]` index alignment for the surrounding shader entries.
52+
*/
53+
shader?: ShaderName;
4654
duration?: number;
4755
}
4856

@@ -114,6 +122,10 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions)
114122

115123
const programs = new Map<string, WebGLProgram>();
116124
for (const t of transitions) {
125+
// CSS crossfade entries (shader undefined) carry no program. Use a
126+
// strict undefined check so a misconfigured empty string still fails
127+
// loudly through the createProgram path below.
128+
if (t.shader === undefined) continue;
117129
if (programs.has(t.shader)) continue;
118130
try {
119131
programs.set(t.shader, createProgram(gl, getFragSource(t.shader)));
@@ -127,6 +139,10 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions)
127139
for (let i = 0; i < transitions.length; i++) {
128140
const t = transitions[i];
129141
if (!t) continue;
142+
// CSS-only transitions stay on the GSAP opacity timeline; the page-
143+
// side compositor only handles shader entries. Index i is preserved
144+
// so subsequent shader transitions still pair with the right scenes.
145+
if (t.shader === undefined) continue;
130146
const fromSceneId = scenes[i];
131147
const toSceneId = scenes[i + 1];
132148
const prog = programs.get(t.shader);

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

Lines changed: 84 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ interface GsapTimeline {
5353

5454
export interface TransitionConfig {
5555
time: number;
56-
shader: ShaderName;
56+
/** Omit to use a CSS crossfade instead of a WebGL shader. */
57+
shader?: ShaderName;
5758
duration?: number;
5859
ease?: string;
5960
}
@@ -100,7 +101,7 @@ interface CachedTransition {
100101
duration: number;
101102
fromId: string;
102103
toId: string;
103-
prog: WebGLProgram;
104+
prog: WebGLProgram | null; // null for CSS-fallback transitions
104105
frames: CachedTransitionFrame[];
105106
cacheKey: string;
106107
dirty: boolean;
@@ -825,7 +826,7 @@ export function init(config: HyperShaderConfig): GsapTimeline {
825826
interface HfTransitionMeta {
826827
time: number;
827828
duration: number;
828-
shader: string;
829+
shader?: string; // undefined = CSS crossfade (no WebGL required)
829830
ease: string;
830831
fromScene: string;
831832
toScene: string;
@@ -902,6 +903,11 @@ export function init(config: HyperShaderConfig): GsapTimeline {
902903

903904
const programs = new Map<string, WebGLProgram>();
904905
for (const t of transitions) {
906+
// Strict undefined check — an explicit empty string from a vanilla-JS
907+
// caller (the IIFE bundle is hand-loaded via <script> tags) should NOT
908+
// be silently coerced into a CSS crossfade. The shader registry will
909+
// throw a clear "unknown shader" error for it.
910+
if (t.shader === undefined) continue;
905911
if (!programs.has(t.shader)) {
906912
try {
907913
programs.set(t.shader, createProgram(gl, getFragSource(t.shader)));
@@ -1131,7 +1137,11 @@ export function init(config: HyperShaderConfig): GsapTimeline {
11311137
canvasEl.style.display = "none";
11321138
return;
11331139
}
1134-
if (cache.fallback) {
1140+
// CSS-only transitions (prog === null) MUST take the fallback path. The
1141+
// fallback flag is the normal signal, but we also guard on prog to keep
1142+
// the invariant even if some path momentarily resets fallback while prog
1143+
// stays null (it can't be re-created — there is no shader to compile).
1144+
if (cache.fallback || cache.prog === null) {
11351145
state.active = true;
11361146
state.transitionIndex = activeIndex;
11371147
state.prog = null;
@@ -1145,9 +1155,12 @@ export function init(config: HyperShaderConfig): GsapTimeline {
11451155
return;
11461156
}
11471157

1158+
// Narrow cache.prog into a non-null local. The branch above already
1159+
// returned for prog === null, but TS can't track that across the function.
1160+
const prog = cache.prog;
11481161
state.active = true;
11491162
state.transitionIndex = activeIndex;
1150-
state.prog = cache.prog;
1163+
state.prog = prog;
11511164
state.progress = clampNumber((currentTime - cache.time) / cache.duration, 0, 1);
11521165
markTextureAccess(cache);
11531166

@@ -1164,7 +1177,7 @@ export function init(config: HyperShaderConfig): GsapTimeline {
11641177
renderShader(
11651178
gl,
11661179
quadBuf,
1167-
state.prog,
1180+
prog,
11681181
interpolatedFromTex,
11691182
interpolatedToTex,
11701183
state.progress,
@@ -1293,8 +1306,19 @@ export function init(config: HyperShaderConfig): GsapTimeline {
12931306
const toId = scenes[i + 1];
12941307
if (!fromId || !toId) continue;
12951308

1296-
const prog = programs.get(t.shader);
1297-
if (!prog) continue;
1309+
// shader omitted → CSS crossfade. shader present but program failed to
1310+
// compile (logged above) → degrade gracefully to CSS crossfade so the
1311+
// opacity timeline still runs and scene progression isn't broken. Both
1312+
// paths land in the always-ready prog=null cache.
1313+
const requestedShader = t.shader !== undefined;
1314+
const compiledProg = requestedShader ? (programs.get(t.shader!) ?? null) : null;
1315+
const isCssFallback = !requestedShader || compiledProg === null;
1316+
if (requestedShader && compiledProg === null) {
1317+
console.warn(
1318+
`[HyperShader] Shader "${t.shader}" failed to compile — falling back to CSS crossfade.`,
1319+
);
1320+
}
1321+
const prog = isCssFallback ? null : compiledProg;
12981322

12991323
const dur = t.duration ?? DEFAULT_DURATION;
13001324
const ease = t.ease ?? DEFAULT_EASE;
@@ -1309,10 +1333,10 @@ export function init(config: HyperShaderConfig): GsapTimeline {
13091333
prog,
13101334
frames: [],
13111335
cacheKey: "",
1312-
dirty: true,
1313-
ready: false,
1314-
fallback: false,
1315-
persisted: false,
1336+
dirty: !isCssFallback,
1337+
ready: isCssFallback,
1338+
fallback: isCssFallback,
1339+
persisted: isCssFallback,
13161340
textureReady: false,
13171341
texturePromise: null,
13181342
textureGeneration: 0,
@@ -1451,15 +1475,28 @@ export function init(config: HyperShaderConfig): GsapTimeline {
14511475
cache.textureReady = false;
14521476
};
14531477

1478+
// Caches with prog === null are CSS crossfade transitions and must stay in
1479+
// the always-ready fallback state. Without this guard, disposeCachedTransition
1480+
// + markScenesDirty would route them through the WebGL prewarm path and
1481+
// tickShader would eventually call renderShader(state.prog!) with a null prog.
1482+
const isCssOnlyTransition = (cache: CachedTransition): boolean => cache.prog === null;
1483+
14541484
const disposeCachedTransition = (cache: CachedTransition): void => {
14551485
disposeTransitionTextures(cache);
14561486
cache.texturePromise = null;
14571487
cache.frames = [];
1488+
cache.lastError = undefined;
1489+
if (isCssOnlyTransition(cache)) {
1490+
cache.ready = true;
1491+
cache.fallback = true;
1492+
cache.persisted = true;
1493+
cache.textureReady = false;
1494+
return;
1495+
}
14581496
cache.ready = false;
14591497
cache.fallback = false;
14601498
cache.persisted = false;
14611499
cache.textureReady = false;
1462-
cache.lastError = undefined;
14631500
};
14641501

14651502
const markTextureAccess = (cache: CachedTransition): void => {
@@ -1566,6 +1603,9 @@ export function init(config: HyperShaderConfig): GsapTimeline {
15661603
let changed = false;
15671604
for (const cache of cachedTransitions) {
15681605
if (!sceneIds.has(cache.fromId) && !sceneIds.has(cache.toId)) continue;
1606+
// Skip CSS-only transitions: there is no shader to recompile and no
1607+
// texture pyramid to recapture, so they stay permanently ready.
1608+
if (isCssOnlyTransition(cache)) continue;
15691609
disposeCachedTransition(cache);
15701610
cache.dirty = true;
15711611
cache.cacheKey = "";
@@ -1888,7 +1928,11 @@ export function init(config: HyperShaderConfig): GsapTimeline {
18881928
if (transitionCachePromise) return transitionCachePromise;
18891929

18901930
transitionCachePromise = (async () => {
1891-
const work = cachedTransitions.filter((cache) => cache.dirty || !cache.ready);
1931+
// CSS-only transitions (prog === null) never need prewarming — they
1932+
// are always ready and route through applyFallbackTransition().
1933+
const work = cachedTransitions.filter(
1934+
(cache) => !isCssOnlyTransition(cache) && (cache.dirty || !cache.ready),
1935+
);
18921936
const workItems = work.map((cache) => ({
18931937
cache,
18941938
sampleCount: sampleCountForCache(cache),
@@ -2209,13 +2253,28 @@ function initEngineMode(
22092253
if (!fromId || !toId) continue;
22102254

22112255
const dur = t.duration ?? DEFAULT_DURATION;
2256+
const ease = t.ease ?? DEFAULT_EASE;
22122257
const T = t.time;
22132258

2214-
// During the transition both scenes need to be visible so the engine
2215-
// can composite each side; afterwards the outgoing scene must drop out
2216-
// so it stops contributing to the normal-frame layer composite.
2217-
tl.set(`#${toId}`, { opacity: 1 }, T);
2218-
tl.set(`#${fromId}`, { opacity: 0 }, T + dur);
2259+
if (t.shader === undefined) {
2260+
// CSS-crossfade transition: schedule an actual opacity tween so the
2261+
// page produces a correct blended frame at every seek time. This
2262+
// matters when the producer captures with page-side compositing
2263+
// (one opaque screenshot per frame) — there is no Node-side blend
2264+
// step in that path, so the page must show the correct mix. Even
2265+
// in the layered Node path the crossfade is harmless (it merely
2266+
// mirrors what `crossfade()` computes from the per-scene buffers).
2267+
tl.fromTo(`#${toId}`, { opacity: 0 }, { opacity: 1, duration: dur, ease }, T);
2268+
tl.fromTo(`#${fromId}`, { opacity: 1 }, { opacity: 0, duration: dur, ease }, T);
2269+
} else {
2270+
// Shader transition: both scenes must stay at opacity=1 during the
2271+
// transition window so the Node-side layered compositor can capture
2272+
// each scene separately and blend them itself. The from-scene drops
2273+
// out at T+dur so it stops contributing to the next normal-frame
2274+
// layer composite.
2275+
tl.set(`#${toId}`, { opacity: 1 }, T);
2276+
tl.set(`#${fromId}`, { opacity: 0 }, T + dur);
2277+
}
22192278
}
22202279

22212280
// Page-side compositing opt-in (default OFF). When the producer launches
@@ -2242,6 +2301,12 @@ function initEngineMode(
22422301
const rawH = Number(root?.getAttribute("data-height"));
22432302
const compWidth = Number.isFinite(rawW) && rawW > 0 ? rawW : 1920;
22442303
const compHeight = Number.isFinite(rawH) && rawH > 0 ? rawH : 1080;
2304+
// Pass the full transitions array so transition[i] still pairs with
2305+
// scenes[i]/scenes[i+1]. The compositor itself skips entries with
2306+
// `shader === undefined` while preserving the index↔scene mapping.
2307+
// CSS crossfades produce a correct blended frame via the actual
2308+
// opacity-crossfade tween scheduled above (search `t.shader === undefined`
2309+
// in this function).
22452310
installPageSideCompositor({
22462311
scenes,
22472312
transitions,

0 commit comments

Comments
 (0)