Skip to content

Commit f75042a

Browse files
committed
perf(core): pre-decode audio at mount, upcoming-clip readiness, stall telemetry
Cut-boundary playback smoothness for segment-heavy compositions (an editor emitting one media element per cut): - init: decode-only warm pass over audio[data-start] as soon as the WebAudio transport exists (and periodically for late-loaded sub-compositions), so first play after a document (re)load doesn't run full-file decodeAudioData lazily while sound falls back to the buffering HTMLMedia element. - init: staged preload — when a composition carries >6 timed media elements, far-future segments (start >10s) begin preload=metadata; the sync layer upgrades them as the playhead approaches. Stops the mount-time fetch stampede that leaves EARLY segments buffering late. - media: two readiness stages for upcoming clips while playing: pre-seek to the clip's media offset ~3s ahead (buffer the RIGHT region), and a muted video pre-roll ~350ms ahead so activation is a visibility flip instead of a cold seek+play (decoder reset ~150ms freeze). - init: bounded runtime_media_stall diagnostics (waiting/stalled while playing) + freeze the transport clock when a buffering ACTIVE video has no audio mastering the clock (video-only compositions), mirroring the existing audio-buffering freeze.
1 parent 32a1492 commit f75042a

4 files changed

Lines changed: 326 additions & 2 deletions

File tree

packages/core/src/runtime/init.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2683,4 +2683,61 @@ describe("initSandboxRuntimeModular", () => {
26832683
}).not.toThrow();
26842684
});
26852685
});
2686+
2687+
describe("audio buffer pre-decode at mount", () => {
2688+
it("warm-decodes every timed audio source before any play()", async () => {
2689+
const decodeAudioData = vi.fn(async () => ({}) as AudioBuffer);
2690+
class MockAudioContext {
2691+
currentTime = 0;
2692+
state = "running";
2693+
destination = {};
2694+
decodeAudioData = decodeAudioData;
2695+
createGain() {
2696+
return { gain: { value: 1 }, connect: vi.fn(), disconnect: vi.fn() };
2697+
}
2698+
resume = vi.fn();
2699+
close = vi.fn();
2700+
}
2701+
vi.stubGlobal("AudioContext", MockAudioContext);
2702+
const fetchMock = vi.fn(async () => ({
2703+
ok: true,
2704+
arrayBuffer: async () => new ArrayBuffer(8),
2705+
}));
2706+
vi.stubGlobal("fetch", fetchMock);
2707+
2708+
try {
2709+
const root = document.createElement("div");
2710+
root.setAttribute("data-composition-id", "main");
2711+
root.setAttribute("data-root", "true");
2712+
root.setAttribute("data-start", "0");
2713+
root.setAttribute("data-duration", "10");
2714+
root.setAttribute("data-width", "1920");
2715+
root.setAttribute("data-height", "1080");
2716+
const audioA = document.createElement("audio");
2717+
audioA.setAttribute("data-start", "0");
2718+
audioA.setAttribute("data-duration", "5");
2719+
audioA.setAttribute("src", "https://media.example/a.mp3");
2720+
const audioB = document.createElement("audio");
2721+
audioB.setAttribute("data-start", "5");
2722+
audioB.setAttribute("data-duration", "5");
2723+
audioB.setAttribute("src", "https://media.example/b.mp3");
2724+
root.append(audioA, audioB);
2725+
document.body.appendChild(root);
2726+
window.__timelines = { main: createMockTimeline(10) };
2727+
2728+
initSandboxRuntimeModular();
2729+
2730+
// No play() has happened — the decode must be driven by mount alone.
2731+
await vi.waitFor(() => {
2732+
expect(fetchMock).toHaveBeenCalledTimes(2);
2733+
expect(decodeAudioData).toHaveBeenCalledTimes(2);
2734+
});
2735+
const urls = fetchMock.mock.calls.map((call) => String(call[0]));
2736+
expect(urls.some((u) => u.endsWith("a.mp3"))).toBe(true);
2737+
expect(urls.some((u) => u.endsWith("b.mp3"))).toBe(true);
2738+
} finally {
2739+
vi.unstubAllGlobals();
2740+
}
2741+
});
2742+
});
26862743
});

packages/core/src/runtime/init.ts

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,9 @@ export function initSandboxRuntimeModular(): void {
174174
let webAudioReady = false;
175175
void webAudio.init().then((ok) => {
176176
webAudioReady = ok;
177+
// Warm the decoded-buffer cache as soon as the transport exists (decode
178+
// works on a suspended AudioContext, so no user gesture is needed).
179+
if (ok) predecodeAudioClipBuffers();
177180
});
178181
// `_auto` is a Studio-internal keyframe marker (an auto-tracked endpoint the
179182
// parser reads back), NOT an animatable property. Register it as a no-op GSAP
@@ -1808,19 +1811,69 @@ export function initSandboxRuntimeModular(): void {
18081811
}
18091812
};
18101813

1814+
// Boundary-readiness telemetry: surface playback stalls (`waiting`/`stalled`
1815+
// on a timed media element while the transport plays) so cut-boundary tuning
1816+
// can target the right layer. Bounded per document to avoid diagnostic spam.
1817+
let mediaStallDiagnosticsPosted = 0;
1818+
const MAX_MEDIA_STALL_DIAGNOSTICS = 20;
1819+
const onMediaStallEvent = (event: Event) => {
1820+
const el = event.currentTarget;
1821+
if (!(el instanceof HTMLMediaElement)) return;
1822+
if (!state.isPlaying || state.tornDown) return;
1823+
if (mediaStallDiagnosticsPosted >= MAX_MEDIA_STALL_DIAGNOSTICS) return;
1824+
mediaStallDiagnosticsPosted += 1;
1825+
let bufferedEnd: number | null = null;
1826+
try {
1827+
bufferedEnd = el.buffered.length > 0 ? el.buffered.end(el.buffered.length - 1) : null;
1828+
} catch {
1829+
// buffered ranges unavailable
1830+
}
1831+
postRuntimeMessage({
1832+
source: "hf-preview",
1833+
type: "diagnostic",
1834+
code: "runtime_media_stall",
1835+
details: {
1836+
event: event.type,
1837+
tagName: el.tagName.toLowerCase(),
1838+
currentSrc: el.currentSrc || null,
1839+
readyState: el.readyState,
1840+
networkState: el.networkState,
1841+
mediaTime: el.currentTime,
1842+
bufferedEnd,
1843+
compositionTime: state.currentTime,
1844+
clipStart: Number.parseFloat(el.dataset.start ?? "") || null,
1845+
},
1846+
});
1847+
};
1848+
18111849
const unbindMediaMetadataListeners = () => {
18121850
for (const mediaEl of metadataBoundMedia) {
18131851
mediaEl.removeEventListener("loadedmetadata", scheduleMetadataDurationHydration);
18141852
mediaEl.removeEventListener("durationchange", scheduleMetadataDurationHydration);
18151853
mediaEl.removeEventListener("loadedmetadata", onMediaLoadedMetadataForProxy);
18161854
mediaEl.removeEventListener("error", onMediaErrorForProxy);
1855+
mediaEl.removeEventListener("waiting", onMediaStallEvent);
1856+
mediaEl.removeEventListener("stalled", onMediaStallEvent);
18171857
}
18181858
metadataBoundMedia.clear();
18191859
};
18201860

18211861
const bindMediaMetadataListeners = () => {
18221862
if (state.tornDown) return;
18231863
const mediaEls = Array.from(document.querySelectorAll("video, audio")) as HTMLMediaElement[];
1864+
// Segment-heavy compositions (editors emit one media element per cut) must
1865+
// not full-preload EVERYTHING at mount: dozens of parallel fetches contend
1866+
// for the connection pool and the EARLY segments buffer late. Above the
1867+
// threshold, far-future timed segments start metadata-only; the sync layer
1868+
// upgrades them to full preload as the playhead approaches (media.ts
1869+
// readiness stages set preload="auto" ~3s ahead).
1870+
const EAGER_PRELOAD_MAX_TIMED_MEDIA = 6;
1871+
const STAGED_PRELOAD_HORIZON_SECONDS = 10;
1872+
const timedMediaCount = mediaEls.reduce(
1873+
(count, el) => count + (el.hasAttribute("data-start") ? 1 : 0),
1874+
0,
1875+
);
1876+
const stagePreload = timedMediaCount > EAGER_PRELOAD_MAX_TIMED_MEDIA;
18241877
for (const mediaEl of mediaEls) {
18251878
if (metadataBoundMedia.has(mediaEl)) continue;
18261879
metadataBoundMedia.add(mediaEl);
@@ -1835,6 +1888,8 @@ export function initSandboxRuntimeModular(): void {
18351888
// for <audio> — all guarded inside mediaProxy.ts itself.
18361889
mediaEl.addEventListener("loadedmetadata", onMediaLoadedMetadataForProxy);
18371890
mediaEl.addEventListener("error", onMediaErrorForProxy);
1891+
mediaEl.addEventListener("waiting", onMediaStallEvent);
1892+
mediaEl.addEventListener("stalled", onMediaStallEvent);
18381893

18391894
// Proactive proxy-fallback trigger: consult the codec map and swap
18401895
// BEFORE the eager load() below, so a known-hostile asset never even
@@ -1845,8 +1900,12 @@ export function initSandboxRuntimeModular(): void {
18451900
// Eagerly preload media data so audio/video is buffered before the user
18461901
// clicks play. Without this, the first play() call fires on un-fetched
18471902
// media, producing silence or choppy audio until the browser caches it.
1848-
if (mediaEl.preload !== "auto") {
1849-
mediaEl.preload = "auto";
1903+
// Exception: staged preload for far-future segments (see above).
1904+
const startAttr = Number.parseFloat(mediaEl.dataset.start ?? "");
1905+
const farFuture = Number.isFinite(startAttr) && startAttr > STAGED_PRELOAD_HORIZON_SECONDS;
1906+
const targetPreload = stagePreload && farFuture ? "metadata" : "auto";
1907+
if (mediaEl.preload !== targetPreload) {
1908+
mediaEl.preload = targetPreload;
18501909
}
18511910
if (mediaEl.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) {
18521911
mediaEl.load();
@@ -1861,6 +1920,29 @@ export function initSandboxRuntimeModular(): void {
18611920
}
18621921
};
18631922

1923+
// Decode-only warm pass over every timed audio source so WebAudio buffers are
1924+
// ready BEFORE the first play() instead of being decoded lazily inside it.
1925+
// The lazy path costs a full-file fetch+decodeAudioData per source at the
1926+
// moment the user hits play — on a freshly (re)loaded document (every editor
1927+
// edit reloads the preview iframe) that gap plays through the HTMLMedia
1928+
// fallback, heard as an audio dropout. Pre-decoding overlaps that work with
1929+
// document load. `decodeAudioElement` dedupes via its buffer cache and
1930+
// failure blacklist, so repeated calls per element are cheap map hits.
1931+
// Skipped during render capture: the producer mixes audio with ffmpeg from
1932+
// source files and never plays through WebAudio.
1933+
const predecodeAudioClipBuffers = () => {
1934+
if (state.tornDown || !webAudioReady) return;
1935+
if (state.nativeMediaSyncDisabled || state.webAudioMediaDisabled) return;
1936+
if ((window as Window & { __HF_RENDER_CAPTURE_MODE?: boolean }).__HF_RENDER_CAPTURE_MODE) {
1937+
return;
1938+
}
1939+
const audioEls = document.querySelectorAll("audio[data-start]");
1940+
for (const el of audioEls) {
1941+
if (!(el instanceof HTMLMediaElement) || !el.isConnected) continue;
1942+
void webAudio.decodeAudioElement(el);
1943+
}
1944+
};
1945+
18641946
const probeAndCacheVolumeKeyframes = (mediaEl: HTMLMediaElement) => {
18651947
if (volumeKeyframeCache.has(mediaEl)) return;
18661948
probeAndCacheElementVolume(
@@ -2882,6 +2964,9 @@ export function initSandboxRuntimeModular(): void {
28822964
}
28832965
if (transportTickCount % 30 === 0) {
28842966
bindMediaMetadataListeners();
2967+
// Also warm decode for audio elements discovered after mount (loaded
2968+
// sub-compositions) or bound before the async WebAudio init resolved.
2969+
predecodeAudioClipBuffers();
28852970
}
28862971

28872972
// Sync clock duration with the resolved timeline each tick (catches async
@@ -2933,6 +3018,31 @@ export function initSandboxRuntimeModular(): void {
29333018
break;
29343019
}
29353020
}
3021+
if (!foundActive) {
3022+
// No audio is mastering the clock (video-only composition, or the
3023+
// audio hasn't reached its window). A buffering ACTIVE video should
3024+
// freeze time the same way buffering audio does above — otherwise
3025+
// the monotonic clock runs ahead while frames are stuck, and the
3026+
// drift correction later hard-seeks (decoder reset) to catch up.
3027+
const videoEls = document.querySelectorAll("video[data-start]");
3028+
for (const rawEl of videoEls) {
3029+
if (!(rawEl instanceof HTMLMediaElement) || !rawEl.isConnected) continue;
3030+
const start = Number.parseFloat(rawEl.dataset.start ?? "");
3031+
if (!Number.isFinite(start)) continue;
3032+
const durAttr = Number.parseFloat(rawEl.dataset.duration ?? "");
3033+
const end = Number.isFinite(durAttr) && durAttr > 0 ? start + durAttr : Infinity;
3034+
if (state.currentTime < start || state.currentTime >= end) continue;
3035+
if (
3036+
!rawEl.paused &&
3037+
!rawEl.error &&
3038+
rawEl.readyState < HTMLMediaElement.HAVE_FUTURE_DATA
3039+
) {
3040+
clock.attachAudioSource({ currentTimeSeconds: state.currentTime });
3041+
foundActive = true;
3042+
}
3043+
break;
3044+
}
3045+
}
29363046
if (!foundActive && clock.hasAudioSource()) {
29373047
clock.detachAudioSource();
29383048
}

packages/core/src/runtime/media.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1045,4 +1045,74 @@ describe("syncRuntimeMedia", () => {
10451045
});
10461046
expect(clip.el.muted).toBe(true);
10471047
});
1048+
1049+
describe("upcoming-clip readiness (boundary smoothness)", () => {
1050+
function readyClip(overrides?: Partial<RuntimeMediaClip>): RuntimeMediaClip {
1051+
const clip = createMockClip(overrides);
1052+
Object.defineProperty(clip.el, "readyState", { value: 4, writable: true });
1053+
return clip;
1054+
}
1055+
1056+
function syncAt(
1057+
clip: RuntimeMediaClip,
1058+
timeSeconds: number,
1059+
extra?: { playing?: boolean; outputMuted?: boolean },
1060+
): void {
1061+
syncRuntimeMedia({
1062+
clips: [clip],
1063+
timeSeconds,
1064+
playing: extra?.playing ?? true,
1065+
playbackRate: 1,
1066+
outputMuted: extra?.outputMuted,
1067+
});
1068+
}
1069+
1070+
it("pre-seeks an upcoming clip to its media offset within the lookahead window", () => {
1071+
const clip = readyClip({ start: 7, end: 12, mediaStart: 30 });
1072+
clip.el.preload = "metadata";
1073+
syncAt(clip, 5);
1074+
expect(clip.el.currentTime).toBe(30);
1075+
expect(clip.el.preload).toBe("auto");
1076+
expect(clip.el.play).not.toHaveBeenCalled();
1077+
});
1078+
1079+
it("leaves clips beyond the lookahead window untouched", () => {
1080+
const clip = readyClip({ start: 20, end: 30, mediaStart: 30 });
1081+
clip.el.currentTime = 3;
1082+
syncAt(clip, 5);
1083+
expect(clip.el.currentTime).toBe(3);
1084+
expect(clip.el.play).not.toHaveBeenCalled();
1085+
});
1086+
1087+
it("pre-rolls a mutable upcoming video just before its boundary — playing, muted, not paused", () => {
1088+
const clip = readyClip({ start: 5.2, end: 12, mediaStart: 30 });
1089+
syncAt(clip, 5, { outputMuted: true });
1090+
expect(clip.el.play).toHaveBeenCalled();
1091+
expect(clip.el.muted).toBe(true);
1092+
// Rolls in from mediaStart - lead so playback crosses the boundary at
1093+
// exactly mediaStart — activation then sees ~zero drift (no cold seek).
1094+
expect(clip.el.currentTime).toBeCloseTo(29.8, 5);
1095+
expect(clip.el.pause).not.toHaveBeenCalled();
1096+
});
1097+
1098+
it("never pre-rolls a video that would be audible — falls back to pre-seek", () => {
1099+
const clip = readyClip({ start: 5.2, end: 12, mediaStart: 30 });
1100+
syncAt(clip, 5);
1101+
expect(clip.el.play).not.toHaveBeenCalled();
1102+
expect(clip.el.currentTime).toBe(30);
1103+
});
1104+
1105+
it("skips the pre-roll when the roll-in would cross media time 0", () => {
1106+
const clip = readyClip({ start: 5.2, end: 12, mediaStart: 0.05 });
1107+
syncAt(clip, 5, { outputMuted: true });
1108+
expect(clip.el.play).not.toHaveBeenCalled();
1109+
});
1110+
1111+
it("paused transport leaves upcoming clips untouched", () => {
1112+
const clip = readyClip({ start: 6, end: 12, mediaStart: 30 });
1113+
syncAt(clip, 5, { playing: false });
1114+
expect(clip.el.currentTime).toBe(0);
1115+
expect(clip.el.play).not.toHaveBeenCalled();
1116+
});
1117+
});
10481118
});

0 commit comments

Comments
 (0)