fix(shader-transitions): page-side render dropped shader transitions and final-scene content

Page-side compositing (default on) silently dropped HyperShader.init shader
transitions in the engine render. The compositor clones the from/to scenes to
feed drawElementImage, but cloneNode copies the GSAP opacity-fade, and Chrome
won't paint hidden elements, so drawElementImage throws "No cached paint record"
and the shader degrades to a hard cut. Force the clones visible before capture,
as the html2canvas path already does via forceSceneVisibleInClone.

Also fixes the final scene's content dropping in the last beat: the core clip
runtime hides it shortly before the composition ends, and page-side screenshots
the live page (the layered path survives via forceVisible per-scene capture).
Un-hide the settled scene on non-transition frames.

Page-side only; retains the ~6.5x page-side speedup.
This commit is contained in:
ukimsanov
2026-06-20 21:19:55 -07:00
parent 0d2198e654
commit a85a507cdc
@@ -196,6 +196,16 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions)
return null;
}
// Scene on screen at a non-transition time: after the last transition whose
// window has passed. Full transitions list so the index matches scene order.
function settledSceneIdAt(time: number): string | undefined {
let idx = 0;
for (const t of transitions) {
if (time >= t.time + (t.duration ?? defaultDuration)) idx += 1;
}
return scenes[Math.min(idx, scenes.length - 1)];
}
let currentActive: ResolvedTransition | null = null;
let currentProgress = 0;
@@ -224,8 +234,24 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions)
}
while (fromStaging.firstChild) fromStaging.removeChild(fromStaging.firstChild);
while (toStaging.firstChild) toStaging.removeChild(toStaging.firstChild);
fromStaging.appendChild(fromEl.cloneNode(true));
toStaging.appendChild(toEl.cloneNode(true));
const fromClone = fromEl.cloneNode(true) as HTMLElement;
const toClone = toEl.cloneNode(true) as HTMLElement;
fromStaging.appendChild(fromClone);
toStaging.appendChild(toClone);
// cloneNode copies the GSAP opacity-fade (opacity:0 / hidden data-start), and
// Chrome won't paint hidden elements — drawElementImage then throws "No cached
// paint record" and the shader degrades to a hard cut. The shader blends from
// full-opacity textures via u_progress, so force the clones visible. Cf.
// forceSceneVisibleInClone (html2canvas path).
for (const clone of [fromClone, toClone]) {
clone.style.opacity = "1";
clone.style.visibility = "visible";
clone.querySelectorAll<HTMLElement>("[data-start]").forEach((el) => {
el.style.opacity = "1";
el.style.visibility = "visible";
});
}
// Decode any data-URI images in clones so the browser has current
// bitmaps before the micro-screenshot forces a paint pass.
@@ -326,6 +352,14 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions)
pWin.__hf_page_composite_pending = false;
while (fromStaging.firstChild) fromStaging.removeChild(fromStaging.firstChild);
while (toStaging.firstChild) toStaging.removeChild(toStaging.firstChild);
// Live-page screenshot parity with the layered path's forceVisible: the
// core clip runtime hides the final scene a beat before the comp ends, so
// un-hide the settled scene (others stay at opacity 0).
const settledId = settledSceneIdAt(time);
const settled = settledId ? document.getElementById(settledId) : null;
if (settled instanceof HTMLElement && settled.style.visibility === "hidden") {
settled.style.visibility = "visible";
}
return result;
}
currentActive = active;