mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(shader-transitions,producer): harden CSS-only transition lifecycle 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>
This commit is contained in:
@@ -260,11 +260,17 @@ export async function runHybridLayeredFrameLoop(input: HybridLoopInput): Promise
|
|||||||
// awaits it. The encoder reorder buffer fences ordering so out-
|
// awaits it. The encoder reorder buffer fences ordering so out-
|
||||||
// of-order blend completion is fine.
|
// of-order blend completion is fine.
|
||||||
const frameIdx = i;
|
const frameIdx = i;
|
||||||
|
// 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 () => {
|
const dispatch: Promise<void> = (async () => {
|
||||||
if (poolRef) {
|
if (poolRef && shaderName) {
|
||||||
const blendStart = Date.now();
|
const blendStart = Date.now();
|
||||||
const result = await poolRef.run({
|
const result = await poolRef.run({
|
||||||
shader: activeTransition.shader,
|
shader: shaderName,
|
||||||
bufferA: buffers.bufferA,
|
bufferA: buffers.bufferA,
|
||||||
bufferB: buffers.bufferB,
|
bufferB: buffers.bufferB,
|
||||||
output: buffers.output,
|
output: buffers.output,
|
||||||
@@ -277,7 +283,9 @@ export async function runHybridLayeredFrameLoop(input: HybridLoopInput): Promise
|
|||||||
buffers.output = result.output;
|
buffers.output = result.output;
|
||||||
addHdrTiming(hdrPerf, "transitionCompositeMs", blendStart);
|
addHdrTiming(hdrPerf, "transitionCompositeMs", blendStart);
|
||||||
} else {
|
} else {
|
||||||
const transitionFn: TransitionFn = TRANSITIONS[activeTransition.shader] ?? crossfade;
|
const transitionFn: TransitionFn = shaderName
|
||||||
|
? (TRANSITIONS[shaderName] ?? crossfade)
|
||||||
|
: crossfade;
|
||||||
const blendStart = Date.now();
|
const blendStart = Date.now();
|
||||||
transitionFn(
|
transitionFn(
|
||||||
buffers.bufferA,
|
buffers.bufferA,
|
||||||
|
|||||||
@@ -172,7 +172,13 @@ export async function runSequentialLayeredFrameLoop(input: SequentialLoopInput):
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const transitionFn: TransitionFn = TRANSITIONS[activeTransition.shader] ?? crossfade;
|
// CSS-crossfade transitions (shader omitted in the composition) take
|
||||||
|
// the same Node-side blend path — `crossfade` is the engine's
|
||||||
|
// canonical opacity blend, equivalent to applyFallbackTransition().
|
||||||
|
const shaderName = activeTransition.shader;
|
||||||
|
const transitionFn: TransitionFn = shaderName
|
||||||
|
? (TRANSITIONS[shaderName] ?? crossfade)
|
||||||
|
: crossfade;
|
||||||
transitionFn(
|
transitionFn(
|
||||||
transitionBuffers.bufferA,
|
transitionBuffers.bufferA,
|
||||||
transitionBuffers.bufferB,
|
transitionBuffers.bufferB,
|
||||||
|
|||||||
@@ -1133,7 +1133,11 @@ export function init(config: HyperShaderConfig): GsapTimeline {
|
|||||||
canvasEl.style.display = "none";
|
canvasEl.style.display = "none";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (cache.fallback) {
|
// CSS-only transitions (prog === null) MUST take the fallback path. The
|
||||||
|
// fallback flag is the normal signal, but we also guard on prog to keep
|
||||||
|
// the invariant even if some path momentarily resets fallback while prog
|
||||||
|
// stays null (it can't be re-created — there is no shader to compile).
|
||||||
|
if (cache.fallback || cache.prog === null) {
|
||||||
state.active = true;
|
state.active = true;
|
||||||
state.transitionIndex = activeIndex;
|
state.transitionIndex = activeIndex;
|
||||||
state.prog = null;
|
state.prog = null;
|
||||||
@@ -1147,9 +1151,12 @@ export function init(config: HyperShaderConfig): GsapTimeline {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Narrow cache.prog into a non-null local. The branch above already
|
||||||
|
// returned for prog === null, but TS can't track that across the function.
|
||||||
|
const prog = cache.prog;
|
||||||
state.active = true;
|
state.active = true;
|
||||||
state.transitionIndex = activeIndex;
|
state.transitionIndex = activeIndex;
|
||||||
state.prog = cache.prog;
|
state.prog = prog;
|
||||||
state.progress = clampNumber((currentTime - cache.time) / cache.duration, 0, 1);
|
state.progress = clampNumber((currentTime - cache.time) / cache.duration, 0, 1);
|
||||||
markTextureAccess(cache);
|
markTextureAccess(cache);
|
||||||
|
|
||||||
@@ -1166,7 +1173,7 @@ export function init(config: HyperShaderConfig): GsapTimeline {
|
|||||||
renderShader(
|
renderShader(
|
||||||
gl,
|
gl,
|
||||||
quadBuf,
|
quadBuf,
|
||||||
state.prog!, // non-null: fallback path returns before reaching here
|
prog,
|
||||||
interpolatedFromTex,
|
interpolatedFromTex,
|
||||||
interpolatedToTex,
|
interpolatedToTex,
|
||||||
state.progress,
|
state.progress,
|
||||||
@@ -1456,15 +1463,28 @@ export function init(config: HyperShaderConfig): GsapTimeline {
|
|||||||
cache.textureReady = false;
|
cache.textureReady = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Caches with prog === null are CSS crossfade transitions and must stay in
|
||||||
|
// the always-ready fallback state. Without this guard, disposeCachedTransition
|
||||||
|
// + markScenesDirty would route them through the WebGL prewarm path and
|
||||||
|
// tickShader would eventually call renderShader(state.prog!) with a null prog.
|
||||||
|
const isCssOnlyTransition = (cache: CachedTransition): boolean => cache.prog === null;
|
||||||
|
|
||||||
const disposeCachedTransition = (cache: CachedTransition): void => {
|
const disposeCachedTransition = (cache: CachedTransition): void => {
|
||||||
disposeTransitionTextures(cache);
|
disposeTransitionTextures(cache);
|
||||||
cache.texturePromise = null;
|
cache.texturePromise = null;
|
||||||
cache.frames = [];
|
cache.frames = [];
|
||||||
|
cache.lastError = undefined;
|
||||||
|
if (isCssOnlyTransition(cache)) {
|
||||||
|
cache.ready = true;
|
||||||
|
cache.fallback = true;
|
||||||
|
cache.persisted = true;
|
||||||
|
cache.textureReady = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
cache.ready = false;
|
cache.ready = false;
|
||||||
cache.fallback = false;
|
cache.fallback = false;
|
||||||
cache.persisted = false;
|
cache.persisted = false;
|
||||||
cache.textureReady = false;
|
cache.textureReady = false;
|
||||||
cache.lastError = undefined;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const markTextureAccess = (cache: CachedTransition): void => {
|
const markTextureAccess = (cache: CachedTransition): void => {
|
||||||
@@ -1571,6 +1591,9 @@ export function init(config: HyperShaderConfig): GsapTimeline {
|
|||||||
let changed = false;
|
let changed = false;
|
||||||
for (const cache of cachedTransitions) {
|
for (const cache of cachedTransitions) {
|
||||||
if (!sceneIds.has(cache.fromId) && !sceneIds.has(cache.toId)) continue;
|
if (!sceneIds.has(cache.fromId) && !sceneIds.has(cache.toId)) continue;
|
||||||
|
// Skip CSS-only transitions: there is no shader to recompile and no
|
||||||
|
// texture pyramid to recapture, so they stay permanently ready.
|
||||||
|
if (isCssOnlyTransition(cache)) continue;
|
||||||
disposeCachedTransition(cache);
|
disposeCachedTransition(cache);
|
||||||
cache.dirty = true;
|
cache.dirty = true;
|
||||||
cache.cacheKey = "";
|
cache.cacheKey = "";
|
||||||
@@ -1893,7 +1916,11 @@ export function init(config: HyperShaderConfig): GsapTimeline {
|
|||||||
if (transitionCachePromise) return transitionCachePromise;
|
if (transitionCachePromise) return transitionCachePromise;
|
||||||
|
|
||||||
transitionCachePromise = (async () => {
|
transitionCachePromise = (async () => {
|
||||||
const work = cachedTransitions.filter((cache) => cache.dirty || !cache.ready);
|
// CSS-only transitions (prog === null) never need prewarming — they
|
||||||
|
// are always ready and route through applyFallbackTransition().
|
||||||
|
const work = cachedTransitions.filter(
|
||||||
|
(cache) => !isCssOnlyTransition(cache) && (cache.dirty || !cache.ready),
|
||||||
|
);
|
||||||
const workItems = work.map((cache) => ({
|
const workItems = work.map((cache) => ({
|
||||||
cache,
|
cache,
|
||||||
sampleCount: sampleCountForCache(cache),
|
sampleCount: sampleCountForCache(cache),
|
||||||
@@ -2247,9 +2274,16 @@ function initEngineMode(
|
|||||||
const rawH = Number(root?.getAttribute("data-height"));
|
const rawH = Number(root?.getAttribute("data-height"));
|
||||||
const compWidth = Number.isFinite(rawW) && rawW > 0 ? rawW : 1920;
|
const compWidth = Number.isFinite(rawW) && rawW > 0 ? rawW : 1920;
|
||||||
const compHeight = Number.isFinite(rawH) && rawH > 0 ? rawH : 1080;
|
const compHeight = Number.isFinite(rawH) && rawH > 0 ? rawH : 1080;
|
||||||
|
// 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({
|
installPageSideCompositor({
|
||||||
scenes,
|
scenes,
|
||||||
transitions,
|
transitions: shaderTransitions,
|
||||||
bgColor,
|
bgColor,
|
||||||
accentColors,
|
accentColors,
|
||||||
width: compWidth,
|
width: compWidth,
|
||||||
|
|||||||
Reference in New Issue
Block a user