Files
heygen-com__hyperframes/packages/shader-transitions/src/engineModePageComposite.ts
Vance Ingalls b3d851d65a fix(shader-transitions): pin clones to their measured live box, not composition size
Addresses review feedback on #2254: the previous fix hardcoded every clone
to the full composition width/height regardless of the source scene's own
sizing, silently overriding any authored explicit width/height. Measuring
each scene's getBoundingClientRect() while still live in the document (its
inset:0 already resolves correctly against the real ancestor chain there)
and reapplying that exact box to the clone fixes the 0x0 collapse without
ever clobbering an author's own layout.
2026-07-11 14:42:43 -07:00

444 lines
16 KiB
TypeScript

/**
* engineModePageComposite — page-side WebGL compositor for engine render mode.
*
* Opt-in via `window.__HF_PAGE_SIDE_COMPOSITING__ = true` (set by the producer
* when `EngineConfig.enablePageSideCompositing` is true). When the flag is
* off, hyper-shader's engine-mode path stays on the opacity-flip-only timeline
* and the producer's hf#677 Node-side layered pipeline runs the shader blend.
*
* Two-phase capture protocol:
*
* Phase 1 (seek wrapper, runs inside page.evaluate):
* - Runs original GSAP seek to position the timeline
* - If inside a transition window, clones FROM/TO scene elements into
* layoutsubtree staging canvases
* - Sets window.__hf_page_composite_pending with transition metadata
* - Returns immediately (seek resolves)
*
* Paint force (engine-side, frameCapture.ts):
* - Engine detects the pending flag and fires a micro Page.captureScreenshot
* to force the browser compositor to paint the staging canvas clones
*
* Phase 2 (engine calls window.__hf_page_composite_resolve):
* - drawElementImage reads the now-valid paint records from the clones
* - Uploads textures to WebGL, runs the shader, shows the GL overlay
* - Cleans up staging canvases
*
* This gives native-fidelity capture (identical to preview-path
* drawElementImage) without depending on requestAnimationFrame for paint.
*/
import {
createContext,
setupQuad,
createProgram,
createTexture,
uploadTextureSource,
renderShader,
type AccentColors,
} from "./webgl.js";
import { getFragSource, type ShaderName } from "./shaders/registry.js";
import { isHtmlInCanvasCaptureSupported } from "./capture.js";
interface PageCompositeTransitionConfig {
time: number;
/**
* Shader id. Undefined entries are CSS crossfades — the page-side
* compositor skips them, and the GSAP timeline in `initEngineMode`
* schedules an actual opacity-crossfade tween for those entries so the
* single page screenshot contains a correct blended frame. The entry
* stays in the array to preserve `transitions[i]` ↔ `scenes[i]`/
* `scenes[i+1]` index alignment for the surrounding shader entries.
*/
shader?: ShaderName;
duration?: number;
}
export interface PageCompositorInstallOptions {
scenes: string[];
transitions: PageCompositeTransitionConfig[];
bgColor: string;
accentColors: AccentColors;
width: number;
height: number;
defaultDuration: number;
}
interface ResolvedTransition {
time: number;
duration: number;
shader: string;
fromSceneId: string;
toSceneId: string;
prog: WebGLProgram;
}
export const PAGE_COMPOSITOR_CANVAS_ID = "__hf-page-side-compositor";
export const PAGE_COMPOSITOR_BUILD_CANARY = "__hf_page_compositor_v1__";
export interface ClonePinStyle {
left: string;
top: string;
width: string;
height: string;
}
/**
* Style values to pin a cloned scene root to the box its source measured
* WHILE STILL LIVE in the document — never the composition's full pixel size.
* A live-document `getBoundingClientRect()` already resolves `inset:0` (and
* any authored explicit width/height) correctly against the real ancestor
* chain; reapplying that exact box to the clone fixes the 0x0 collapse a
* detached `inset:0` clone would otherwise have inside the staging canvas's
* layout subtree, without ever overriding an author's own sizing.
*/
export function clonePinStyleFor(rect: {
left: number;
top: number;
width: number;
height: number;
}): ClonePinStyle {
return {
left: `${rect.left}px`,
top: `${rect.top}px`,
width: `${rect.width}px`,
height: `${rect.height}px`,
};
}
export function isPageSideCompositingSupported(): boolean {
if (typeof window === "undefined" || typeof document === "undefined") return false;
if (!isHtmlInCanvasCaptureSupported()) return false;
const probe = document.createElement("canvas");
const gl = probe.getContext("webgl") || probe.getContext("experimental-webgl");
if (!gl) return false;
(gl as WebGLRenderingContext).getExtension("WEBGL_lose_context")?.loseContext();
return true;
}
export function installPageSideCompositor(options: PageCompositorInstallOptions): boolean {
if (typeof window === "undefined") return false;
(window as unknown as { __HF_PAGE_COMPOSITOR_CANARY__?: string }).__HF_PAGE_COMPOSITOR_CANARY__ =
PAGE_COMPOSITOR_BUILD_CANARY;
if (!isPageSideCompositingSupported()) {
// eslint-disable-next-line no-console
console.warn(
"[HyperShader] page-side compositing requested but drawElementImage/WebGL is not " +
"available; falling back to opacity-flip mode " +
"(Node-side layered pipeline will handle the blend).",
);
return false;
}
if (document.getElementById(PAGE_COMPOSITOR_CANVAS_ID)) return true;
const { scenes, transitions, accentColors, width, height, defaultDuration } = options;
const glCanvas = document.createElement("canvas");
glCanvas.id = PAGE_COMPOSITOR_CANVAS_ID;
glCanvas.width = width;
glCanvas.height = height;
glCanvas.style.cssText =
"position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483646;pointer-events:none;display:none;";
document.body.appendChild(glCanvas);
const gl = createContext(glCanvas, width, height);
if (!gl) {
// eslint-disable-next-line no-console
console.warn("[HyperShader] page-side compositor: WebGL context unavailable.");
glCanvas.remove();
return false;
}
const quadBuf = setupQuad(gl);
const programs = new Map<string, WebGLProgram>();
for (const t of transitions) {
// CSS crossfade entries (shader undefined) carry no program. Use a
// strict undefined check so a misconfigured empty string still fails
// loudly through the createProgram path below.
if (t.shader === undefined) continue;
if (programs.has(t.shader)) continue;
try {
programs.set(t.shader, createProgram(gl, getFragSource(t.shader)));
} catch (err) {
// eslint-disable-next-line no-console
console.warn(`[HyperShader] page-side compositor: failed to compile "${t.shader}":`, err);
}
}
const resolved: ResolvedTransition[] = [];
for (let i = 0; i < transitions.length; i++) {
const t = transitions[i];
if (!t) continue;
// CSS-only transitions stay on the GSAP opacity timeline; the page-
// side compositor only handles shader entries. Index i is preserved
// so subsequent shader transitions still pair with the right scenes.
if (t.shader === undefined) continue;
const fromSceneId = scenes[i];
const toSceneId = scenes[i + 1];
const prog = programs.get(t.shader);
if (!fromSceneId || !toSceneId || !prog) continue;
resolved.push({
time: t.time,
duration: t.duration ?? defaultDuration,
shader: t.shader,
fromSceneId,
toSceneId,
prog,
});
}
if (resolved.length === 0) {
glCanvas.remove();
return false;
}
const fromTex = createTexture(gl);
const toTex = createTexture(gl);
type DrawElementImageCtx = CanvasRenderingContext2D & {
drawElementImage: (el: Element, x: number, y: number, w: number, h: number) => void;
};
interface StagingCanvas extends HTMLCanvasElement {
layoutSubtree?: boolean;
}
// Persistent staging canvases — children are swapped per transition frame.
// Kept in the DOM so the compositor paints them on the next frame.
const fromStaging = document.createElement("canvas") as StagingCanvas;
const toStaging = document.createElement("canvas") as StagingCanvas;
for (const s of [fromStaging, toStaging]) {
s.width = width;
s.height = height;
s.setAttribute("layoutsubtree", "");
s.style.cssText =
"position:fixed;top:0;left:0;width:" +
width +
"px;height:" +
height +
"px;z-index:-9998;pointer-events:none;";
document.body.appendChild(s);
}
function findActive(time: number): ResolvedTransition | null {
for (const t of resolved) {
if (time >= t.time && time <= t.time + t.duration) return t;
}
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;
type PendingWindow = Window & {
__hf_page_composite_pending?: boolean;
__hf_page_composite_resolve?: () => boolean;
};
const pWin = window as PendingWindow;
// Phase 2a: clone scenes into staging canvases. Called after video frame
// injection so cloneNode picks up <img> replacements for <video> elements.
// Awaits decode on cloned data-URI images so drawElementImage reads
// the current frame, not a stale paint cache entry.
async function prepareComposite(): Promise<boolean> {
const active = currentActive;
if (!active) {
pWin.__hf_page_composite_pending = false;
return false;
}
const fromEl = document.getElementById(active.fromSceneId);
const toEl = document.getElementById(active.toSceneId);
if (!(fromEl instanceof HTMLElement) || !(toEl instanceof HTMLElement)) {
pWin.__hf_page_composite_pending = false;
return false;
}
// Measure each scene's rendered box WHILE STILL LIVE — a scene root sized
// only by `position:absolute; inset:0` resolves to 0x0 once cloned into
// the staging canvas's layout subtree (no containing-block dimensions
// there), and the transition textures blank out (wild report: explicit
// 1080x1920 anchors fixed both transitions). The live document already
// resolves inset:0 (and any authored explicit width/height) correctly
// against the real ancestor chain, so pinning the clone to THIS measured
// box fixes the collapse without ever overriding an author's own sizing.
const fromPin = clonePinStyleFor(fromEl.getBoundingClientRect());
const toPin = clonePinStyleFor(toEl.getBoundingClientRect());
while (fromStaging.firstChild) fromStaging.removeChild(fromStaging.firstChild);
while (toStaging.firstChild) toStaging.removeChild(toStaging.firstChild);
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, pin] of [
[fromClone, fromPin],
[toClone, toPin],
] as const) {
clone.style.opacity = "1";
clone.style.visibility = "visible";
clone.style.position = "absolute";
clone.style.left = pin.left;
clone.style.top = pin.top;
clone.style.width = pin.width;
clone.style.height = pin.height;
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.
const decodes: Promise<void>[] = [];
for (const staging of [fromStaging, toStaging]) {
for (const img of staging.querySelectorAll("img")) {
if (img.src && img.src.startsWith("data:") && typeof img.decode === "function") {
decodes.push(img.decode().catch(() => {}));
}
}
}
if (decodes.length > 0) await Promise.all(decodes);
return true;
}
// Phase 2b: drawElementImage from painted clones + shader composite.
// Called after micro-screenshot forces the browser to paint the clones.
function resolveComposite(): boolean {
const active = currentActive;
if (!active) {
pWin.__hf_page_composite_pending = false;
return false;
}
const fromChild = fromStaging.firstElementChild;
const toChild = toStaging.firstElementChild;
if (!fromChild || !toChild) {
pWin.__hf_page_composite_pending = false;
return false;
}
const fromCtx = fromStaging.getContext("2d") as DrawElementImageCtx | null;
const toCtx = toStaging.getContext("2d") as DrawElementImageCtx | null;
if (!fromCtx?.drawElementImage || !toCtx?.drawElementImage) {
pWin.__hf_page_composite_pending = false;
return false;
}
try {
fromCtx.fillStyle = options.bgColor;
fromCtx.fillRect(0, 0, width, height);
fromCtx.drawElementImage(fromChild, 0, 0, width, height);
toCtx.fillStyle = options.bgColor;
toCtx.fillRect(0, 0, width, height);
toCtx.drawElementImage(toChild, 0, 0, width, height);
} catch (err) {
// eslint-disable-next-line no-console
console.warn("[HyperShader] page-side compositor: drawElementImage failed:", err);
pWin.__hf_page_composite_pending = false;
return false;
}
uploadTextureSource(gl as WebGLRenderingContext, fromTex, fromStaging);
uploadTextureSource(gl as WebGLRenderingContext, toTex, toStaging);
try {
renderShader(
gl as WebGLRenderingContext,
quadBuf,
active.prog,
fromTex,
toTex,
currentProgress,
accentColors,
width,
height,
);
glCanvas.style.display = "block";
} catch (err) {
// eslint-disable-next-line no-console
console.warn("[HyperShader] page-side compositor: renderShader failed:", err);
glCanvas.style.display = "none";
}
pWin.__hf_page_composite_pending = false;
return true;
}
pWin.__hf_page_composite_resolve = resolveComposite;
(
pWin as unknown as { __hf_page_composite_prepare?: () => Promise<boolean> }
).__hf_page_composite_prepare = prepareComposite;
type HfWindow = Window & {
__hf?: { seek?: (t: number) => unknown };
};
const hfWin = window as HfWindow;
const wrapSeek = (): void => {
if (!hfWin.__hf) return;
const originalSeek = hfWin.__hf.seek;
if (typeof originalSeek !== "function") return;
const wrapped = (time: number): unknown => {
const result = originalSeek.call(hfWin.__hf, time);
const active = findActive(time);
if (!active) {
glCanvas.style.display = "none";
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;
currentProgress =
active.duration === 0
? 1
: Math.min(1, Math.max(0, (time - active.time) / active.duration));
pWin.__hf_page_composite_pending = true;
return result;
};
hfWin.__hf.seek = wrapped;
};
let attempts = 0;
const ivHandle = window.setInterval(() => {
attempts += 1;
if (hfWin.__hf?.seek) {
wrapSeek();
window.clearInterval(ivHandle);
} else if (attempts > 200) {
window.clearInterval(ivHandle);
// eslint-disable-next-line no-console
console.warn(
"[HyperShader] page-side compositor: window.__hf.seek never appeared after 10s; " +
"the engine bridge did not initialize. Falling back to opacity-flip mode.",
);
}
}, 50);
return true;
}