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:
Miguel Ángel
2026-09-25 10:15:42 +00:00
committed by GitHub
parent 11ac9bce69
commit ab540fc42e
10 changed files with 111 additions and 8 deletions
+1
View File
@@ -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
+1
View File
@@ -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(),
+2
View File
@@ -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),
+16 -7
View File
@@ -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);
+1 -1
View File
@@ -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();
+2
View File
@@ -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;
+1
View File
@@ -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 {