Files
hyperframes/packages/core/src/runtime/media.ts
T
Miguel Ángel f1a37400b4 fix(runtime): silent-first-play + loading overlay for preview (#293)
## Summary

Fixes three audio-sync defects in the studio preview plus a small UX improvement. All four land in one commit so the PR stays aligned with one bug fix per commit.

### 1\. Silent / very-late first play on slow-loading audio (`packages/core/src/runtime/media.ts`)

`syncRuntimeMedia`'s old flow — when it hit `readyState < HAVE_FUTURE_DATA` — called `el.load()` and attached a `canplay` listener to retry `play()`. Two real problems, neither of which is "lost user activation" (the sync runs from a 50 ms `setInterval`, well outside any gesture window):

- `bindMediaMetadataListeners` already sets `preload="auto"` and calls `el.load()` at runtime init. The sync's duplicate `el.load()` aborts that in-flight fetch and restarts from zero — on slow networks this delayed playback by seconds, which users perceived as "silent until a second click."
- The `canplay` listener was racy: the event can fire between `load()` and `addEventListener`, leaving the element wedged.

`HTMLMediaElement.play()` is already spec'd to queue playback until data arrives, so we can unconditionally call it. Drop the `readyState` gate, the redundant `load()`, and the `canplay` listener. Also dedup in-flight `play()` calls with a `WeakSet` (cleared on `playing`/`pause`/`error`) — without it the 50 ms poll fires 20–40 spurious calls per element during buffer, each silencing real `AbortError`/`NotAllowedError` diagnostics in the `.catch`.

### 2\. Audible stutter on rapid pause/play (`packages/core/src/runtime/media.ts`)

The 0.3 s drift-seek threshold fired on nearly every toggle because pause/play ordering between timeline and media produces 0.1–0.4 s of transient drift. Each forced `el.currentTime = relTime` drops `readyState` and surfaces as a `waiting` event the user hears as a stutter. Threshold raised to 0.5 s.

### 3\. Skipped words on cold first play (`packages/core/src/runtime/media.ts`)

Even with 0.5 s, drift grew past 0.5 s during initial buffering while the audio element was stuck at `currentTime = 0`. The old logic would then force-seek audio forward and the user missed the opening of the narration.

Fix distinguishes drift that grows _gradually_ (buffer catch-up, ~16 ms/tick) from drift that _jumps_ in one tick (a scrub). Only jumps, first-tick clip activation, or catastrophic drift (>3 s) trigger a resync. Inline tradeoff note in code: strictly lip-synced dialogue would want a tighter threshold (~0.15 s) outside a 500 ms toggle window — deferred to a future PR.

### 4\. "Loading assets…" overlay in the studio preview (`packages/studio/src/player/components/Player.tsx`)

Spinner while every timed `<audio>`/`<video>` has enough buffered data and every Lottie animation is loaded. Preserves the previous overlay state on cross-origin / transient-DOM catches so a brief access failure doesn't flicker, and logs `console.debug` when the 10 s safety cap trips so a stuck asset is diagnosable. Lottie readiness handles both `lottie-web` (`isLoaded`) and `@dotlottie/player-component` (`totalFrames > 0`), with an inline `@see` pointing to `packages/core/src/runtime/adapters/lottie.ts` so the two sites stay in sync.

## Verification

- 456 core tests pass; 34 in `media.test.ts` cover synchronous play, preload nudge, play-request dedup, offset-jump vs gradual drift, first-tick hard-sync, catastrophic-drift safety valve, and inactive-clip baseline reset.
- Full monorepo build green (`bun run build`), typecheck clean, lint/format clean.
- End-to-end with agent-browser against a composition that uses a 50 s voiceover plus multiple sub-composition video clips. Four scenarios, all pass:

| Scenario | Metric | Result |
| --- | --- | --- |
| Normal first play | Audio plays from click, smooth progression |  |
| Cold play (forced unbuffered) | First `play` event fires at `ct: 0` — no word-skip |  |
| Rapid pause/play (12 toggles) | `waiting` events: 1 (was 40+ bursts) |  |
| Scrub mid-playback | Lands exactly at target frame |  |

## Files changed

- `packages/core/src/runtime/media.ts` — unconditional synchronous `play()`; play-request dedup WeakSet; offset-jump-only drift correction; 0.5 s threshold; first-tick hard-sync; catastrophic-drift safety valve.
- `packages/core/src/runtime/media.test.ts` — coverage for the above plus the gradual-drift cold-play case, scrub offset-jump, in-flight dedup, and inactive-clip baseline reset.
- `packages/core/src/runtime/adapters/lottie.ts` — exported `isLottieAnimationLoaded` helper documenting the two supported player shapes.
- `packages/studio/src/player/components/Player.tsx` — loading-assets overlay with cached-return catch, timeout debug log, and the Lottie readiness check.

## Follow-ups (deferred)

- Tight-threshold short-window drift correction for lip-synced dialogue.
- A perf-regression test that fails on `waiting`\-event resurgence.

## Test plan

- [x] `hyperframes preview` a composition with audio, `Cmd+Shift+R`, click play immediately — audio starts from the very beginning, no skipped words.
- [x] Rapidly pause/play the preview — audio stays smooth (no stutter, no `waiting` events).
- [x] Cold-load a composition — "Loading assets…" overlay appears and disappears once media buffers.
- [ ] Scrub the timeline mid-playback — audio follows the scrub, lands on frame.
2026-04-16 21:51:06 +02:00

187 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
export type RuntimeMediaClip = {
el: HTMLVideoElement | HTMLAudioElement;
start: number;
mediaStart: number;
duration: number;
end: number;
volume: number | null;
playbackRate: number;
loop: boolean;
/** Source media duration in seconds (from el.duration). Used for loop wrapping. */
sourceDuration: number | null;
};
export function refreshRuntimeMediaCache(params?: {
resolveStartSeconds?: (element: Element) => number;
}): {
timedMediaEls: Array<HTMLVideoElement | HTMLAudioElement>;
mediaClips: RuntimeMediaClip[];
videoClips: RuntimeMediaClip[];
maxMediaEnd: number;
} {
const mediaEls = Array.from(
document.querySelectorAll("video[data-start], audio[data-start]"),
) as Array<HTMLVideoElement | HTMLAudioElement>;
const mediaClips: RuntimeMediaClip[] = [];
const videoClips: RuntimeMediaClip[] = [];
let maxMediaEnd = 0;
for (const el of mediaEls) {
const start = params?.resolveStartSeconds
? params.resolveStartSeconds(el)
: Number.parseFloat(el.dataset.start ?? "0");
if (!Number.isFinite(start)) continue;
const mediaStart =
Number.parseFloat(el.dataset.playbackStart ?? el.dataset.mediaStart ?? "0") || 0;
// Read per-element rate from the native defaultPlaybackRate property.
// LLMs set this via el.defaultPlaybackRate = 0.5 in a <script> tag.
const rawRate = el.defaultPlaybackRate;
const playbackRate =
Number.isFinite(rawRate) && rawRate > 0 ? Math.max(0.1, Math.min(5, rawRate)) : 1;
const loop = el.loop;
const sourceDuration = Number.isFinite(el.duration) && el.duration > 0 ? el.duration : null;
let duration = Number.parseFloat(el.dataset.duration ?? "");
if ((!Number.isFinite(duration) || duration <= 0) && sourceDuration != null) {
// Effective duration accounts for playback rate:
// at 0.5x, a 10s source plays for 20s on the timeline
duration = Math.max(0, (sourceDuration - mediaStart) / playbackRate);
}
const end =
Number.isFinite(duration) && duration > 0 ? start + duration : Number.POSITIVE_INFINITY;
const volumeRaw = Number.parseFloat(el.dataset.volume ?? "");
const clip: RuntimeMediaClip = {
el,
start,
mediaStart,
duration: Number.isFinite(duration) && duration > 0 ? duration : Number.POSITIVE_INFINITY,
end,
volume: Number.isFinite(volumeRaw) ? volumeRaw : null,
playbackRate,
loop,
sourceDuration,
};
mediaClips.push(clip);
if (el.tagName === "VIDEO") videoClips.push(clip);
if (Number.isFinite(end)) maxMediaEnd = Math.max(maxMediaEnd, end);
}
return { timedMediaEls: mediaEls, mediaClips, videoClips, maxMediaEnd };
}
// Per-element timeline→media offset from the previous tick. Used to tell a
// gradual drift (initial buffer catch-up, where offset grows ~16ms/tick) from
// a scrub (where offset jumps in one tick). Cleared when a clip becomes
// inactive so the next activation gets a hard resync on its first tick.
const lastOffset = new WeakMap<HTMLMediaElement, number>();
// Elements whose play() is in flight. The sync runs on a 50 ms poll and with
// a 12 s buffer that would fire 2040 spurious play() calls per element —
// noise in devtools and, worse, each `.catch(() => {})` would swallow a real
// AbortError / NotAllowedError that should surface. Cleared on the `playing`
// event (actual playback started) or on `pause`/`error` (state ended).
const playRequested = new WeakSet<HTMLMediaElement>();
function markPlayRequested(el: HTMLMediaElement): void {
if (playRequested.has(el)) return;
playRequested.add(el);
const clear = () => playRequested.delete(el);
el.addEventListener("playing", clear, { once: true });
el.addEventListener("pause", clear, { once: true });
el.addEventListener("error", clear, { once: true });
}
export function syncRuntimeMedia(params: {
clips: RuntimeMediaClip[];
timeSeconds: number;
playing: boolean;
playbackRate: number;
}): void {
for (const clip of params.clips) {
const { el } = clip;
if (!el.isConnected) continue;
let relTime = (params.timeSeconds - clip.start) * clip.playbackRate + clip.mediaStart;
const isActive =
params.timeSeconds >= clip.start && params.timeSeconds < clip.end && relTime >= 0;
if (isActive) {
// Loop wrapping: when media reaches end, restart from mediaStart
if (clip.loop && clip.sourceDuration != null && clip.sourceDuration > 0) {
const loopLength = clip.sourceDuration - clip.mediaStart;
if (loopLength > 0 && relTime >= clip.sourceDuration) {
relTime = clip.mediaStart + ((relTime - clip.mediaStart) % loopLength);
}
}
if (clip.volume != null) el.volume = clip.volume;
try {
// Per-element rate × global transport rate
el.playbackRate = clip.playbackRate * params.playbackRate;
} catch {
// ignore unsupported playbackRate
}
// Drift correction. Forcing `el.currentTime = relTime` every frame
// causes an audible seek+rebuffer hiccup (readyState drops briefly).
//
// We only want to correct drift that came from an *event* — an explicit
// user seek, a sub-composition activation, or a timeline jump — not
// drift that grew naturally from initial-buffer latency. Telling them
// apart by timing: scrubs move the timeline-to-media offset by seconds
// in a single tick; buffer catch-up grows the offset by ~one frame
// (<20ms) per tick.
//
// The first tick a clip is active we don't have a previous offset to
// compare against — treat that as a hard resync so sub-compositions
// with non-zero `mediaStart` land on the right frame.
//
// Tradeoff: the 3 s catastrophic-drift valve means an unnoticed
// steady-state drift can accumulate up to ~3 s before we correct.
// For music / motion graphics this is inaudible; for lip-synced
// dialogue it is not. If that becomes a target use case, switch to
// a short-window tight threshold (e.g. tighten to 0.15 s when the
// last play/pause transition was >500 ms ago).
const currentElTime = el.currentTime || 0;
const drift = Math.abs(currentElTime - relTime);
const offset = relTime - currentElTime;
const prevOffset = lastOffset.get(el);
lastOffset.set(el, offset);
const firstTickOfClip = prevOffset === undefined;
const offsetJumped = !firstTickOfClip && Math.abs(offset - prevOffset!) > 0.5;
const catastrophicDrift = drift > 3;
if (drift > 0.5 && (firstTickOfClip || offsetJumped || catastrophicDrift)) {
try {
el.currentTime = relTime;
} catch {
// ignore browser seek restrictions
}
}
if (params.playing && el.paused && !playRequested.has(el)) {
// `HTMLMediaElement.play()` is spec'd to queue playback and resolve
// once enough data is buffered, so we can unconditionally call it —
// no need to gate on `readyState` or defer to a `canplay` listener.
//
// The old `readyState < HAVE_FUTURE_DATA` branch called `el.load()`
// inside the listener, which *aborts* the in-flight fetch that
// `bindMediaMetadataListeners` already started at init time and
// restarts from zero. On slow networks this delayed playback by
// seconds. The canplay listener was also racey — the event could
// fire between `load()` and `addEventListener` attachment, wedging
// the element waiting for a callback that never came.
//
// preload="auto" is already set at bind time in init.ts; the
// re-assignment here is defensive for media elements that were
// inserted after the runtime bound its listeners.
if (el.preload !== "auto") el.preload = "auto";
markPlayRequested(el);
void el.play().catch(() => {
// If play() rejects — e.g. autoplay blocked, element removed
// mid-flight — drop the in-flight flag so a future sync tick can
// retry rather than getting stuck waiting for `playing`/`pause`.
playRequested.delete(el);
});
} else if (!params.playing && !el.paused) {
el.pause();
}
continue;
}
// Clip left its active window — drop the offset baseline so the next
// activation (e.g. re-entering a sub-composition) gets a hard resync.
lastOffset.delete(el);
if (!el.paused) el.pause();
}
}