mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(shader-transitions): a paused preview keeps the runtime's scene visibility (#4522)
* fix(shader-transitions): a paused preview keeps the runtime's scene visibility After the transition prewarm, a paused preview showed a shader scene outside its window (a scene with a background covered the film until the next seek). The prewarm's seeks fired HyperShader's transition callbacks, which showed both scenes, and its teardown restored a stale visibility snapshot. The callbacks now skip while prewarming (the prewarm ends after its restore seek), the snapshot no longer includes visibility, and the settled state outside a transition only resets opacity. * fix(shader-transitions): the native scene capture draws a hidden scene visible The html-in-canvas capture copied the live scene with its inline styles and ignored forceVisible, so a scene the runtime had hidden captured blank once the prewarm stopped firing the transition callbacks. It now shows the copy and its timed clips the way the html2canvas path does. The preview test hides the scenes after the prewarm has taken its style snapshot, and the end callback guard and a dead prewarming check are gone. * test(shader-transitions): drop an unused scene and a timing note the markup does not set * fix(shader-transitions): transitions leave scene visibility to the runtime A transition now hides scenes outside its pair with opacity only, so a scene without timing attributes is not left hidden after a seek that skips events. The prewarm clears its flag in a finally, so a timeline callback that throws during the restore seek no longer stops every later seek. The preview test also checks that the prewarm ran. * fix(shader-transitions): the prewarm finishes when a callback throws while it restores A timeline callback that throws during the restore seek is warned about and the prewarm goes on to repaint, resume and fire its ready event, the way a failed capture already does.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { forceSceneVisibleInClone } from "./capture.js";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { captureScene, forceSceneVisibleInClone } from "./capture.js";
|
||||
|
||||
describe("forceSceneVisibleInClone", () => {
|
||||
it("shows the scene and its timed clips while the runtime's first-pass hide rule is up", () => {
|
||||
@@ -18,3 +18,34 @@ describe("forceSceneVisibleInClone", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("captureScene with html-in-canvas", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete (HTMLCanvasElement.prototype as { layoutSubtree?: boolean }).layoutSubtree;
|
||||
});
|
||||
|
||||
it("draws a scene the runtime hid, and its timed clips, visible when asked to", async () => {
|
||||
document.body.innerHTML =
|
||||
'<div id="scene" style="visibility: hidden; opacity: 0"><p data-start="0" style="visibility: hidden">Title</p></div>';
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, "layoutSubtree", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
const drawn: string[] = [];
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation((() => ({
|
||||
fillRect: () => {},
|
||||
drawImage: () => {},
|
||||
drawElementImage: (el: HTMLElement) => {
|
||||
const title = el.querySelector("p") as HTMLElement;
|
||||
drawn.push(el.style.opacity, el.style.visibility, title.style.visibility);
|
||||
},
|
||||
})) as unknown as HTMLCanvasElement["getContext"]);
|
||||
|
||||
await captureScene(document.getElementById("scene") as HTMLElement, "#000", 16, 9, {
|
||||
forceVisible: true,
|
||||
});
|
||||
|
||||
expect(drawn).toEqual(["1", "visible", "visible"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,16 +49,18 @@ export interface CaptureSceneOptions {
|
||||
scale?: number;
|
||||
}
|
||||
|
||||
function showSceneAndClips(scene: HTMLElement): void {
|
||||
scene.style.opacity = "1";
|
||||
scene.style.setProperty("visibility", "visible", "important");
|
||||
scene.querySelectorAll<HTMLElement>("[data-start]").forEach((el) => {
|
||||
el.style.setProperty("visibility", "visible", "important");
|
||||
});
|
||||
}
|
||||
|
||||
export function forceSceneVisibleInClone(source: HTMLElement, cloneDoc: Document): void {
|
||||
if (!source.id) return;
|
||||
const clone = cloneDoc.getElementById(source.id);
|
||||
if (!(clone instanceof HTMLElement)) return;
|
||||
|
||||
clone.style.opacity = "1";
|
||||
clone.style.setProperty("visibility", "visible", "important");
|
||||
clone.querySelectorAll<HTMLElement>("[data-start]").forEach((el) => {
|
||||
el.style.setProperty("visibility", "visible", "important");
|
||||
});
|
||||
if (clone instanceof HTMLElement) showSceneAndClips(clone);
|
||||
}
|
||||
|
||||
function stabilizeTransformedBoxShadows(root: HTMLElement): void {
|
||||
@@ -117,13 +119,16 @@ async function captureSceneWithHtmlInCanvas(
|
||||
bgColor: string,
|
||||
width: number,
|
||||
height: number,
|
||||
options: CaptureSceneOptions,
|
||||
): Promise<HTMLCanvasElement> {
|
||||
const canvas = document.createElement("canvas") as CanvasWithLayoutSubtree;
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
canvas.setAttribute("layoutsubtree", "");
|
||||
canvas.style.cssText = `position:fixed;top:0;left:0;width:${width}px;height:${height}px;z-index:-9999;pointer-events:none;opacity:0`;
|
||||
canvas.appendChild(sceneEl.cloneNode(true));
|
||||
const clone = sceneEl.cloneNode(true) as HTMLElement;
|
||||
if (options.forceVisible) showSceneAndClips(clone);
|
||||
canvas.appendChild(clone);
|
||||
document.body.appendChild(canvas);
|
||||
|
||||
try {
|
||||
@@ -153,7 +158,7 @@ export function captureScene(
|
||||
options: CaptureSceneOptions = {},
|
||||
): Promise<HTMLCanvasElement> {
|
||||
if (isHtmlInCanvasCaptureSupported() && !options.preferBrowserPaint) {
|
||||
return captureSceneWithHtmlInCanvas(sceneEl, bgColor, width, height).catch(() =>
|
||||
return captureSceneWithHtmlInCanvas(sceneEl, bgColor, width, height, options).catch(() =>
|
||||
captureSceneWithHtml2Canvas(sceneEl, bgColor, width, height, options),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { init } from "./hyper-shader.js";
|
||||
|
||||
// The slice of a paused GSAP timeline that init() drives: time() fires the callbacks it
|
||||
// crosses unless events are suppressed, then onUpdate.
|
||||
function stubGsap() {
|
||||
let now = 0;
|
||||
let onUpdate: (() => void) | undefined;
|
||||
let paused = true;
|
||||
const calls: { fn: () => void; at: number }[] = [];
|
||||
const tl = {
|
||||
paused: () => paused,
|
||||
play: () => ((paused = false), tl),
|
||||
pause: () => ((paused = true), tl),
|
||||
time: (t?: number, suppressEvents = false) => {
|
||||
if (t === undefined) return now;
|
||||
const from = now;
|
||||
now = t;
|
||||
if (!suppressEvents) {
|
||||
for (const { fn, at } of calls) {
|
||||
if ((from < at && at <= t) || (t < at && at <= from)) fn();
|
||||
}
|
||||
}
|
||||
onUpdate?.();
|
||||
return tl;
|
||||
},
|
||||
seek: (t: number) => tl.time(t),
|
||||
call: (fn: () => void, _args: null, at: number) => (calls.push({ fn, at }), tl),
|
||||
set: () => tl,
|
||||
to: () => tl,
|
||||
fromTo: () => tl,
|
||||
};
|
||||
const timeline = (opts: { onUpdate?: () => void }) => ((onUpdate = opts.onUpdate), tl);
|
||||
vi.stubGlobal("gsap", { timeline, set: () => {}, to: () => {}, fromTo: () => {} });
|
||||
}
|
||||
|
||||
function stubWebGl() {
|
||||
vi.stubGlobal("CanvasRenderingContext2D", class {});
|
||||
const gl = new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_target, key) =>
|
||||
key === "getShaderParameter" || key === "getProgramParameter" ? () => true : () => ({}),
|
||||
},
|
||||
);
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(((type: string) =>
|
||||
type === "webgl" ? gl : null) as HTMLCanvasElement["getContext"]);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
type ShaderTimeline = ReturnType<typeof init> & {
|
||||
call: (fn: () => void, args: null, at: number) => unknown;
|
||||
};
|
||||
|
||||
function mountScenes(ids: string[]): void {
|
||||
document.body.innerHTML = `<div data-composition-id="main" data-width="640" data-height="360">${ids
|
||||
.map((id) => `<div id="${id}" class="scene clip">${id}</div>`)
|
||||
.join("")}</div>`;
|
||||
}
|
||||
|
||||
function prewarmDone(): Promise<void> {
|
||||
return (window as unknown as { __hf: { shaderTransitionsReady: Promise<void> } }).__hf
|
||||
.shaderTransitionsReady;
|
||||
}
|
||||
|
||||
function visibilityOf(ids: string[]): string[] {
|
||||
return ids.map((id) => (document.getElementById(id) as HTMLElement).style.visibility);
|
||||
}
|
||||
|
||||
describe("preview outside a transition", () => {
|
||||
it("leaves the runtime's hide on shader scenes after the prewarm on a paused page", async () => {
|
||||
stubGsap();
|
||||
stubWebGl();
|
||||
// jsdom cannot capture a scene; HyperShader falls back to a CSS crossfade and warns.
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
let prewarmedTransitions = 0;
|
||||
window.addEventListener("hyperShader:ready", (e) => {
|
||||
prewarmedTransitions = (e as CustomEvent<{ total: number }>).detail.total;
|
||||
});
|
||||
mountScenes(["s4", "s5"]);
|
||||
const tl = init({
|
||||
bgColor: "#000",
|
||||
scenes: ["s4", "s5"],
|
||||
transitions: [{ time: 4.4, duration: 0.8, shader: "domain-warp" }],
|
||||
});
|
||||
// The runtime starts after the film's script and hides both while the prewarm runs.
|
||||
await Promise.resolve();
|
||||
for (const id of ["s4", "s5"]) {
|
||||
(document.getElementById(id) as HTMLElement).style.visibility = "hidden";
|
||||
}
|
||||
await prewarmDone();
|
||||
|
||||
// Paused: nothing re-syncs visibility until the next seek.
|
||||
tl.time(0);
|
||||
expect(prewarmedTransitions).toBeGreaterThan(0);
|
||||
expect(visibilityOf(["s4", "s5"])).toEqual(["hidden", "hidden"]);
|
||||
});
|
||||
|
||||
it("does not leave a scene outside the transition pair hidden", async () => {
|
||||
stubGsap();
|
||||
stubWebGl();
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
mountScenes(["s1", "s2", "s3"]);
|
||||
const tl = init({
|
||||
bgColor: "#000",
|
||||
scenes: ["s1", "s2", "s3"],
|
||||
transitions: [
|
||||
{ time: 2, duration: 0.8, shader: "domain-warp" },
|
||||
{ time: 4, duration: 0.8, shader: "domain-warp" },
|
||||
],
|
||||
});
|
||||
await prewarmDone();
|
||||
|
||||
tl.time(4.2);
|
||||
expect((document.getElementById("s1") as HTMLElement).style.opacity).toBe("0");
|
||||
tl.time(1, true);
|
||||
expect(visibilityOf(["s1"])).not.toEqual(["hidden"]);
|
||||
});
|
||||
|
||||
it("finishes the prewarm and keeps seeking after a timeline callback throws while it restores", async () => {
|
||||
stubGsap();
|
||||
stubWebGl();
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
mountScenes(["s4", "s5"]);
|
||||
const tl = init({
|
||||
bgColor: "#000",
|
||||
scenes: ["s4", "s5"],
|
||||
transitions: [{ time: 4.4, duration: 0.8, shader: "domain-warp" }],
|
||||
}) as ShaderTimeline;
|
||||
let throwing = true;
|
||||
tl.call(
|
||||
() => {
|
||||
if (throwing) throw new Error("author callback");
|
||||
},
|
||||
null,
|
||||
1,
|
||||
);
|
||||
let hits = 0;
|
||||
tl.call(() => (hits += 1), null, 3);
|
||||
await prewarmDone();
|
||||
throwing = false;
|
||||
hits = 0;
|
||||
|
||||
tl.time(3.5);
|
||||
expect(hits).toBe(1);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("restoring the playhead"),
|
||||
expect.objectContaining({ message: "author callback" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -143,7 +143,6 @@ interface SnapshotCacheEntry {
|
||||
interface SceneStyleState {
|
||||
scene: HTMLElement | null;
|
||||
opacity: string;
|
||||
visibility: string;
|
||||
pointerEvents: string;
|
||||
}
|
||||
|
||||
@@ -990,12 +989,16 @@ export function init(config: HyperShaderConfig): GsapTimeline {
|
||||
}
|
||||
};
|
||||
|
||||
const setScenePlaybackState = (scene: HTMLElement, visible: boolean, opacity: string): void => {
|
||||
const setSceneLayer = (scene: HTMLElement, shown: boolean, opacity: string): void => {
|
||||
rememberScenePointerEvents(scene);
|
||||
markRuntimeSceneMutation();
|
||||
scene.style.opacity = opacity;
|
||||
scene.style.pointerEvents = shown ? (scenePointerEvents.get(scene) ?? "") : "none";
|
||||
};
|
||||
|
||||
const setScenePlaybackState = (scene: HTMLElement, visible: boolean, opacity: string): void => {
|
||||
setSceneLayer(scene, visible, opacity);
|
||||
scene.style.visibility = visible ? "visible" : "hidden";
|
||||
scene.style.pointerEvents = visible ? (scenePointerEvents.get(scene) ?? "") : "none";
|
||||
};
|
||||
|
||||
const paintScenePairState = (
|
||||
@@ -1012,7 +1015,7 @@ export function init(config: HyperShaderConfig): GsapTimeline {
|
||||
} else if (sceneId === toId) {
|
||||
setScenePlaybackState(scene, true, toOpacity);
|
||||
} else {
|
||||
setScenePlaybackState(scene, false, "0");
|
||||
setSceneLayer(scene, false, "0");
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1107,8 +1110,8 @@ export function init(config: HyperShaderConfig): GsapTimeline {
|
||||
scenes.forEach((sceneId, index) => {
|
||||
const scene = document.getElementById(sceneId);
|
||||
if (!scene) return;
|
||||
const visible = index === visibleIndex;
|
||||
setScenePlaybackState(scene, visible, visible ? "1" : "0");
|
||||
const settled = index === visibleIndex;
|
||||
setSceneLayer(scene, settled, settled ? "1" : "0");
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1365,6 +1368,7 @@ export function init(config: HyperShaderConfig): GsapTimeline {
|
||||
|
||||
tl.call(
|
||||
() => {
|
||||
if (prewarming) return;
|
||||
suppressSceneMutationTracking(() => {
|
||||
const fromScene = document.getElementById(fromId);
|
||||
const toScene = document.getElementById(toId);
|
||||
@@ -1380,7 +1384,7 @@ export function init(config: HyperShaderConfig): GsapTimeline {
|
||||
return;
|
||||
}
|
||||
canvasEl.style.display =
|
||||
!prewarming && cache?.ready && !cache.dirty && cache.textureReady ? "block" : "none";
|
||||
cache?.ready && !cache.dirty && cache.textureReady ? "block" : "none";
|
||||
paintScenePairState(fromId, toId, "1", "1");
|
||||
});
|
||||
},
|
||||
@@ -2011,7 +2015,6 @@ export function init(config: HyperShaderConfig): GsapTimeline {
|
||||
return {
|
||||
scene,
|
||||
opacity: scene?.style.opacity ?? "",
|
||||
visibility: scene?.style.visibility ?? "",
|
||||
pointerEvents: scene?.style.pointerEvents ?? "",
|
||||
};
|
||||
});
|
||||
@@ -2157,18 +2160,21 @@ export function init(config: HyperShaderConfig): GsapTimeline {
|
||||
if (shouldResume && resumeTime !== hydratedTextureTime) {
|
||||
await ensurePlaybackTextureWindow(resumeTime);
|
||||
}
|
||||
prewarming = false;
|
||||
state.active = false;
|
||||
state.transitionIndex = -1;
|
||||
canvasEl.style.display = "none";
|
||||
suppressSceneMutationTracking(() => {
|
||||
setActualTimelineTime(restoreTimelineTime, false);
|
||||
});
|
||||
try {
|
||||
suppressSceneMutationTracking(() => {
|
||||
setActualTimelineTime(restoreTimelineTime, false);
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("[HyperShader] A timeline callback failed while restoring the playhead:", e);
|
||||
}
|
||||
prewarming = false;
|
||||
publicTimelineTime = restoreTimelineTime;
|
||||
for (const item of originalSceneStyles) {
|
||||
if (!item.scene) continue;
|
||||
item.scene.style.opacity = item.opacity;
|
||||
item.scene.style.visibility = item.visibility;
|
||||
item.scene.style.pointerEvents = item.pointerEvents;
|
||||
}
|
||||
tickShader();
|
||||
|
||||
Reference in New Issue
Block a user