mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(core): the preview transport keeps ticking in a hidden tab (#5872)
* fix(core): the preview transport keeps ticking in a hidden tab A hidden document runs no animation frames, but its media keeps playing. The transport ticked on requestAnimationFrame only, so syncRuntimeMedia stopped running: a clip was never paused at the end of its window and the next clip never started until the tab was shown again. While document.visibilityState is "hidden", the transport now schedules its tick on a 16 ms timer instead. A visibilitychange listener moves a pending tick to whichever scheduler the new state calls for, and teardown removes it. A paused, hidden transport still parks as before. Refs #5710 (cause 1) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * docs(core): note the hidden-tab timer clamp for silent media Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * docs(core): shorten the hidden-tab transport comments to hold the comment share Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
cea0bad793
commit
b168d0b413
@@ -399,6 +399,7 @@ const MEDIA_URL_ATTRS = new Map([
|
||||
]);
|
||||
|
||||
const SLOW_IDLE_HEARTBEAT_MS = 1000;
|
||||
const HIDDEN_TRANSPORT_TICK_MS = 16;
|
||||
|
||||
// One document.getAnimations() per seek and the pause after it, read on first use, shared by all adapters.
|
||||
function pageAnimationsForOnePass(): () => Animation[] {
|
||||
@@ -4783,13 +4784,45 @@ export function initSandboxRuntimeModular(): void {
|
||||
armParkTimer();
|
||||
};
|
||||
|
||||
// Hidden documents run no animation frames but their media plays on, so tick on a timer.
|
||||
// Chromium clamps it to ~1 s unless audio is audible: a clip may end up to 1 s late.
|
||||
let transportTickTimerId: number | null = null;
|
||||
const transportTickPending = () => state.transportRafId != null || transportTickTimerId != null;
|
||||
const requestTransportTick = () => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
transportTickTimerId = window.setTimeout(() => {
|
||||
transportTickTimerId = null;
|
||||
transportTick();
|
||||
}, HIDDEN_TRANSPORT_TICK_MS);
|
||||
return;
|
||||
}
|
||||
state.transportRafId = window.requestAnimationFrame(transportTick);
|
||||
};
|
||||
const cancelTransportTick = () => {
|
||||
if (state.transportRafId != null) {
|
||||
window.cancelAnimationFrame(state.transportRafId);
|
||||
state.transportRafId = null;
|
||||
}
|
||||
if (transportTickTimerId != null) {
|
||||
window.clearTimeout(transportTickTimerId);
|
||||
transportTickTimerId = null;
|
||||
}
|
||||
};
|
||||
// A frame requested just before the tab hid never fires: move a pending tick across.
|
||||
const onTransportVisibilityChange = () => {
|
||||
if (state.tornDown || !transportTickPending()) return;
|
||||
cancelTransportTick();
|
||||
requestTransportTick();
|
||||
};
|
||||
document.addEventListener("visibilitychange", onTransportVisibilityChange);
|
||||
|
||||
const scheduleNextTransportFrame = () => {
|
||||
state.transportRafId = null;
|
||||
if (state.tornDown) return;
|
||||
const woken = transportWakeRequested;
|
||||
transportWakeRequested = false;
|
||||
if (woken || !canParkTransport()) {
|
||||
state.transportRafId = window.requestAnimationFrame(transportTick);
|
||||
requestTransportTick();
|
||||
return;
|
||||
}
|
||||
// Read fresh rather than reusing the tick's own `timingRevision`: the tick
|
||||
@@ -4805,13 +4838,13 @@ export function initSandboxRuntimeModular(): void {
|
||||
// Inside a tick the tail is the only scheduler; setting the flag there is
|
||||
// what stops it parking on work it has just been told about.
|
||||
transportWakeRequested = true;
|
||||
if (inTransportTick || state.transportRafId != null) return;
|
||||
if (inTransportTick || transportTickPending()) return;
|
||||
if (transportParkTimerId != null) {
|
||||
window.clearTimeout(transportParkTimerId);
|
||||
transportParkTimerId = null;
|
||||
}
|
||||
transportWakeRequested = false;
|
||||
state.transportRafId = window.requestAnimationFrame(transportTick);
|
||||
requestTransportTick();
|
||||
};
|
||||
|
||||
const followedOrLongestRunningAudio = (
|
||||
@@ -5183,8 +5216,8 @@ export function initSandboxRuntimeModular(): void {
|
||||
|
||||
installPositionEditsSeekReapply(window as Window & typeof globalThis);
|
||||
|
||||
// Start the rAF tick loop
|
||||
state.transportRafId = window.requestAnimationFrame(transportTick);
|
||||
// Start the tick loop
|
||||
requestTransportTick();
|
||||
postTimeline();
|
||||
postState(true);
|
||||
|
||||
@@ -5339,10 +5372,8 @@ export function initSandboxRuntimeModular(): void {
|
||||
const teardown = () => {
|
||||
if (state.tornDown) return;
|
||||
state.tornDown = true;
|
||||
if (state.transportRafId != null) {
|
||||
window.cancelAnimationFrame(state.transportRafId);
|
||||
state.transportRafId = null;
|
||||
}
|
||||
cancelTransportTick();
|
||||
document.removeEventListener("visibilitychange", onTransportVisibilityChange);
|
||||
if (transportParkTimerId != null) {
|
||||
window.clearTimeout(transportParkTimerId);
|
||||
transportParkTimerId = null;
|
||||
|
||||
@@ -168,6 +168,7 @@ describe("parked transport loop", () => {
|
||||
|
||||
afterEach(() => {
|
||||
window.__hfRuntimeTeardown?.();
|
||||
delete (document as { visibilityState?: DocumentVisibilityState }).visibilityState;
|
||||
document.body.innerHTML = "";
|
||||
window.__timelines = {} as Record<string, RuntimeTimelineLike>;
|
||||
delete window.__player;
|
||||
@@ -983,4 +984,50 @@ describe("parked transport loop", () => {
|
||||
expect(window.__player!.isPlaying()).toBe(true);
|
||||
expect(media.isPaused()).toBe(true);
|
||||
});
|
||||
|
||||
const setVisibility = (state: DocumentVisibilityState) => {
|
||||
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => state });
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
};
|
||||
|
||||
it("keeps the transport ticking on a timer while the document is hidden", () => {
|
||||
// A hidden document runs no animation frames but its media keeps playing, so a
|
||||
// transport driven by frames alone never ends a clip at the end of its window.
|
||||
let nowMs = 1000;
|
||||
vi.spyOn(performance, "now").mockImplementation(() => nowMs);
|
||||
mount(`<audio id="a1" data-start="0" data-duration="1"></audio>`);
|
||||
const media = stubMediaPlayback(document.getElementById("a1") as HTMLAudioElement, 10);
|
||||
initSandboxRuntimeModular();
|
||||
quiesce();
|
||||
|
||||
window.__player!.play();
|
||||
settle();
|
||||
expect(media.isPaused()).toBe(false);
|
||||
|
||||
setVisibility("hidden");
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
nowMs += 16;
|
||||
vi.advanceTimersByTime(16);
|
||||
}
|
||||
|
||||
expect(window.__player!.getTime()).toBeGreaterThan(1);
|
||||
expect(media.isPaused()).toBe(true);
|
||||
});
|
||||
|
||||
it("moves a pending tick between animation frames and a timer as visibility changes", () => {
|
||||
mount();
|
||||
initSandboxRuntimeModular();
|
||||
quiesce();
|
||||
window.__player!.play();
|
||||
expect(raf.pending()).toBe(1);
|
||||
|
||||
setVisibility("hidden");
|
||||
expect(raf.pending()).toBe(0);
|
||||
vi.advanceTimersByTime(16);
|
||||
expect(raf.pending()).toBe(0);
|
||||
expect(window.__player!.isPlaying()).toBe(true);
|
||||
|
||||
setVisibility("visible");
|
||||
expect(raf.pending()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user