mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
perf(player): let paused players check in once a second with low-power-idle (#4483)
* perf(player): let paused players check in once a second with low-power-idle * refactor(player): trim low-power-idle comments and tidy its test * fix(core): keep the fast paused heartbeat until a timeline is bound * fix(core): wait for sub-compositions to bind before slowing the paused heartbeat
This commit is contained in:
@@ -61,6 +61,7 @@ Set `autoplay muted` only when playback should start without a user gesture.
|
||||
| `audio-src` | string | — | Optional primary audio URL to preload in the parent frame |
|
||||
| `audio-locked` | boolean | false | Force muted playback and hide volume controls |
|
||||
| `assets-loading-ui` | `player` or `none` | `player` | `none` hides the loading-assets card; asset events still fire |
|
||||
| `low-power-idle` | boolean | false | While paused, check in once a second, not every 80 ms (many-player pages) |
|
||||
|
||||
Player also accepts `shader-capture-scale` and `shader-loading` for previewing
|
||||
projects that use shader transitions. These are preview controls, not composition
|
||||
|
||||
@@ -14,6 +14,7 @@ function createMockDeps() {
|
||||
onSetNativeMediaSyncDisabled: vi.fn(),
|
||||
onSetWebAudioMediaDisabled: vi.fn(),
|
||||
onSetPlaybackRate: vi.fn(),
|
||||
onSetIdleHeartbeat: vi.fn(),
|
||||
onSetColorGrading: vi.fn(),
|
||||
onSetColorGradingCompare: vi.fn(),
|
||||
onSetRootDuration: vi.fn(),
|
||||
|
||||
@@ -20,6 +20,7 @@ type BridgeDeps = {
|
||||
onSetNativeMediaSyncDisabled: (disabled: boolean) => void;
|
||||
onSetWebAudioMediaDisabled: (disabled: boolean) => void;
|
||||
onSetPlaybackRate: (rate: number) => void;
|
||||
onSetIdleHeartbeat: (slow: boolean) => void;
|
||||
onSetRootDuration: (durationSeconds: number) => void;
|
||||
onSetColorGrading: (target: HfColorGradingTarget | string | null, grading: unknown) => void;
|
||||
onSetColorGradingCompare: (
|
||||
@@ -71,6 +72,7 @@ const CONTROL_HANDLERS = new Map<string, ControlHandler>(
|
||||
"set-web-audio-media-disabled": (data, deps) =>
|
||||
deps.onSetWebAudioMediaDisabled(Boolean(data.disabled)),
|
||||
"set-playback-rate": (data, deps) => deps.onSetPlaybackRate(Number(data.playbackRate ?? 1)),
|
||||
"set-idle-heartbeat": (data, deps) => deps.onSetIdleHeartbeat(Boolean(data.slow)),
|
||||
"set-root-duration": (data, deps) => deps.onSetRootDuration(Number(data.durationSeconds ?? 0)),
|
||||
"set-color-grading": (data, deps) =>
|
||||
deps.onSetColorGrading(data.target ?? null, data.grading ?? null),
|
||||
|
||||
@@ -196,6 +196,8 @@ function createSettledTracker(
|
||||
};
|
||||
}
|
||||
|
||||
const SLOW_IDLE_HEARTBEAT_MS = 1000;
|
||||
|
||||
export function initSandboxRuntimeModular(): void {
|
||||
const state = createRuntimeState();
|
||||
// Runtime-data handlers may replace the timeline object they mutate. Keep the
|
||||
@@ -3432,6 +3434,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
let pausedSeekDeferredByManualGesture = false;
|
||||
// Set while the transport is parked (see scheduleNextTransportFrame).
|
||||
let transportParkTimerId: number | null = null;
|
||||
let slowIdleHeartbeat = false;
|
||||
let transportWakeRequested = false;
|
||||
let parkedPollWitness = "";
|
||||
let lastSeenTimingRevision = -1;
|
||||
@@ -3709,14 +3712,14 @@ export function initSandboxRuntimeModular(): void {
|
||||
state.capturedTimeline === lastTransportSeekTimeline;
|
||||
|
||||
/**
|
||||
* The parked loop. Two jobs the 60 Hz loop used to do implicitly:
|
||||
* The parked loop has two jobs:
|
||||
*
|
||||
* 1. Keep the control bridge's paused heartbeat on its documented interval
|
||||
* (`state.bridgeMaxPostIntervalMs`) so a paused timeline still confirms
|
||||
* its position to any listener.
|
||||
* 2. Re-read everything nothing can push (`readParkedPollWitness`). Polling
|
||||
* that 12 times a second instead of 60 is the whole reason the safety net
|
||||
* exists.
|
||||
* (`state.bridgeMaxPostIntervalMs`; a second after `set-idle-heartbeat`,
|
||||
* once the whole timeline is bound) so a paused timeline confirms its position.
|
||||
* 2. Re-read everything nothing can push (`readParkedPollWitness`) on that
|
||||
* same beat: a timer, not a frame loop, is what keeps a paused runtime
|
||||
* cheap.
|
||||
*/
|
||||
/**
|
||||
* Everything a parked transport still has to LOOK at, because no observer
|
||||
@@ -3740,7 +3743,9 @@ export function initSandboxRuntimeModular(): void {
|
||||
const armParkTimer = () => {
|
||||
transportParkTimerId = window.setTimeout(
|
||||
parkedTransportHeartbeat,
|
||||
state.bridgeMaxPostIntervalMs,
|
||||
slowIdleHeartbeat && state.capturedTimeline && childrenBound
|
||||
? SLOW_IDLE_HEARTBEAT_MS
|
||||
: state.bridgeMaxPostIntervalMs,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4250,6 +4255,10 @@ export function initSandboxRuntimeModular(): void {
|
||||
if (state.transportClock) state.transportClock.setRate(state.playbackRate);
|
||||
applyWebAudioRate();
|
||||
},
|
||||
onSetIdleHeartbeat: (slow) => {
|
||||
slowIdleHeartbeat = slow;
|
||||
wakeTransport();
|
||||
},
|
||||
onSetRootDuration: growRootDurationLive,
|
||||
onSetColorGrading: (target, grading) => {
|
||||
colorGrading.setGrading(target, grading);
|
||||
|
||||
@@ -69,7 +69,7 @@ export type RuntimeState = {
|
||||
* Today: `2 × 80 ms = 160 ms`, which sits comfortably under the
|
||||
* perceptual A/V re-sync tolerance. If you raise this interval, audit
|
||||
* `MIRROR_REQUIRED_CONSECUTIVE_DRIFT_SAMPLES` in
|
||||
* `packages/player/src/hyperframes-player.ts` — leaving it at `2` will
|
||||
* `packages/player/src/parent-media.ts` — leaving it at `2` will
|
||||
* silently push correction latency past the tolerance budget.
|
||||
*/
|
||||
bridgeMaxPostIntervalMs: number;
|
||||
|
||||
@@ -207,6 +207,62 @@ describe("parked transport loop", () => {
|
||||
expect(raf.pending()).toBe(0);
|
||||
});
|
||||
|
||||
const setIdleHeartbeat = (slow: boolean) =>
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
source: window.parent,
|
||||
data: { source: "hf-parent", type: "control", action: "set-idle-heartbeat", slow },
|
||||
}),
|
||||
);
|
||||
const states = () => posted.filter((m) => m["type"] === "state").length;
|
||||
|
||||
it("slows the parked heartbeat to once a second when the host asks, and back when it stops", () => {
|
||||
mount();
|
||||
initSandboxRuntimeModular();
|
||||
quiesce();
|
||||
|
||||
setIdleHeartbeat(true);
|
||||
quiesce();
|
||||
const slow = states();
|
||||
vi.advanceTimersByTime(3000);
|
||||
expect(states() - slow).toBe(3);
|
||||
expect(raf.pending()).toBe(0);
|
||||
|
||||
// Turned off mid-interval, the 80 ms beat resumes without waiting out the second.
|
||||
vi.advanceTimersByTime(500);
|
||||
setIdleHeartbeat(false);
|
||||
settle();
|
||||
const resumed = states();
|
||||
for (let beat = 0; beat < 3; beat += 1) vi.advanceTimersByTime(PARK_HEARTBEAT_MS);
|
||||
expect(states() - resumed).toBe(3);
|
||||
});
|
||||
|
||||
it("keeps the fast heartbeat under a slow request until a timeline is bound", () => {
|
||||
mount();
|
||||
window.__timelines = {};
|
||||
initSandboxRuntimeModular();
|
||||
document.getElementById("root")!.removeAttribute("data-duration");
|
||||
setIdleHeartbeat(true);
|
||||
quiesce();
|
||||
|
||||
// A composition that registers its timeline late (after fonts load) must still be seen at once.
|
||||
window.__timelines!["main"] = createMockTimeline(12);
|
||||
vi.advanceTimersByTime(PARK_HEARTBEAT_MS);
|
||||
settle();
|
||||
expect(window.__player!.getDuration()).toBeCloseTo(12, 3);
|
||||
});
|
||||
|
||||
it("keeps the fast heartbeat under a slow request while a sub-composition is unbound", () => {
|
||||
mount('<div data-composition-id="child"></div>');
|
||||
initSandboxRuntimeModular();
|
||||
setIdleHeartbeat(true);
|
||||
quiesce();
|
||||
|
||||
const before = states();
|
||||
for (let beat = 0; beat < 3; beat += 1) vi.advanceTimersByTime(PARK_HEARTBEAT_MS);
|
||||
expect(states() - before).toBe(3);
|
||||
});
|
||||
|
||||
it("delivers a live data-duration edit while parked", async () => {
|
||||
mount();
|
||||
initSandboxRuntimeModular();
|
||||
|
||||
@@ -20,6 +20,7 @@ type RuntimeBridgeControlActionBase =
|
||||
| "set-media-output-muted"
|
||||
| "set-native-media-sync-disabled"
|
||||
| "set-web-audio-media-disabled"
|
||||
| "set-idle-heartbeat"
|
||||
| "set-root-duration"
|
||||
| "stop-media"
|
||||
| "flash-elements";
|
||||
@@ -34,6 +35,7 @@ type RuntimeBridgeControlMessageBase = {
|
||||
volume?: number;
|
||||
durationSeconds?: number;
|
||||
disabled?: boolean;
|
||||
slow?: boolean;
|
||||
playbackRate?: number;
|
||||
target?: HfColorGradingTarget | string | null;
|
||||
grading?: RuntimeJson;
|
||||
|
||||
@@ -68,6 +68,7 @@ Show a static image before playback starts:
|
||||
| `shader-capture-scale` | number | — | Shader transition snapshot scale forwarded to browser previews (`0.25`-`1`) |
|
||||
| `shader-loading` | `composition \| player \| none` | `composition` | Controls shader transition prep loading UI ownership |
|
||||
| `assets-loading-ui` | `player \| none` | `player` | `none` never shows the loading-assets card; asset events still fire |
|
||||
| `low-power-idle` | boolean | false | While paused, check in once a second, not every 80 ms (many-player pages) |
|
||||
|
||||
### Shader transition previews
|
||||
|
||||
|
||||
@@ -2100,6 +2100,31 @@ describe("HyperframesPlayer runtime ready handshake", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("replays low-power-idle as a slow idle heartbeat, and a normal one without it", () => {
|
||||
postSpy.mockClear();
|
||||
player._onMessage(readyMessage());
|
||||
expect(findControlCalls("set-idle-heartbeat")[0]?.[0]).toMatchObject({ slow: false });
|
||||
|
||||
player.setAttribute("low-power-idle", "");
|
||||
postSpy.mockClear();
|
||||
player._onMessage(readyMessage());
|
||||
expect(findControlCalls("set-idle-heartbeat")[0]?.[0]).toMatchObject({
|
||||
action: "set-idle-heartbeat",
|
||||
slow: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("sends low-power-idle to a ready runtime as soon as it changes", () => {
|
||||
player._onMessage(readyMessage());
|
||||
postSpy.mockClear();
|
||||
player.setAttribute("low-power-idle", "");
|
||||
expect(findControlCalls("set-idle-heartbeat")[0]?.[0]).toMatchObject({ slow: true });
|
||||
|
||||
postSpy.mockClear();
|
||||
player.removeAttribute("low-power-idle");
|
||||
expect(findControlCalls("set-idle-heartbeat")[0]?.[0]).toMatchObject({ slow: false });
|
||||
});
|
||||
|
||||
it("keeps runtime WebAudio media enabled outside slideshow embeds", () => {
|
||||
postSpy.mockClear();
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ const ASSETS_READY_TIMEOUT_MS = 8_000;
|
||||
const ASSETS_LOADING_ATTR = "assets-loading";
|
||||
// "player" (default) draws the loading-assets card; "none" never does, like shader-loading="none".
|
||||
const ASSETS_LOADING_UI_ATTR = "assets-loading-ui";
|
||||
const LOW_POWER_IDLE_ATTR = "low-power-idle";
|
||||
// paint-and-idle now always has a frame to wait on, so the overlay would
|
||||
// flash on every single Play without this debounce. ponytail: 150ms is
|
||||
// unmeasured, retune once there's production data on paint-and-idle timing.
|
||||
@@ -99,6 +100,7 @@ class HyperframesPlayer extends HTMLElement {
|
||||
SHADER_CAPTURE_SCALE_ATTR,
|
||||
SHADER_LOADING_ATTR,
|
||||
ASSETS_LOADING_UI_ATTR,
|
||||
LOW_POWER_IDLE_ATTR,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -317,6 +319,9 @@ class HyperframesPlayer extends HTMLElement {
|
||||
case ASSETS_LOADING_UI_ATTR:
|
||||
if (val === "none") this.shaderLoader.hideAssetsLoading();
|
||||
break;
|
||||
case LOW_POWER_IDLE_ATTR:
|
||||
this._sendControl("set-idle-heartbeat", { slow: val !== null });
|
||||
break;
|
||||
case SHADER_CAPTURE_SCALE_ATTR:
|
||||
case SHADER_LOADING_ATTR:
|
||||
case RUNTIME_SRC_ATTR:
|
||||
@@ -839,6 +844,7 @@ class HyperframesPlayer extends HTMLElement {
|
||||
this._sendControl("set-web-audio-media-disabled", {
|
||||
disabled: this._isSlideshowPlayer(),
|
||||
});
|
||||
this._sendControl("set-idle-heartbeat", { slow: this.hasAttribute(LOW_POWER_IDLE_ATTR) });
|
||||
}
|
||||
|
||||
private _reloadShaderOptions(): void {
|
||||
|
||||
Reference in New Issue
Block a user