fix(core): play waits 0.3 s for a clip with no data, then runs on and lets it catch up

This commit is contained in:
Miguel Angel Simon Sierra
2026-11-02 02:11:05 -05:00
parent a17af79ab0
commit 4e0740ee16
4 changed files with 100 additions and 23 deletions
@@ -323,17 +323,19 @@ describe("the audio the playhead follows", () => {
expect(window.__player!.getTime()).toBeGreaterThan(1.4);
});
it("holds the playhead for a playing clip that has no data until it has some", async () => {
it("holds the playhead for a playing clip that has no data for the start wait, then plays on", async () => {
await playStarved();
stepFrames(1);
const held = window.__player!.getTime();
stepFrames(5 * 60);
stepFrames(15);
expect(window.__player!.getTime()).toBe(held);
stepFrames(90);
expect(window.__player!.getTime()).toBeGreaterThan(held + 0.5);
});
it("plays on from where it held once a clip's late data arrives", async () => {
const late = await playStarved();
stepFrames(5 * 60);
stepFrames(10);
const before = window.__player!.getTime();
expect(before).toBeLessThan(0.1);
late.readyState = 4;
@@ -28,7 +28,12 @@ function audio(id: string, duration: number, readyState: { value: number; buffer
return el;
}
function playPodcast(voiceStart = 0, voiceTag = "audio", extraHtml = "") {
function playPodcast(
voiceStart = 0,
voiceTag = "audio",
musicReadyState = 4,
extraHtml = "",
) {
resetRuntimeFixtureDom();
document.body.innerHTML =
'<div data-composition-id="main" data-root="true" data-duration="10">' +
@@ -37,7 +42,7 @@ function playPodcast(voiceStart = 0, voiceTag = "audio", extraHtml = "") {
extraHtml +
"</div>";
const voiceReady: { value: number; bufferedTo?: number } = { value: 1 };
const music = audio("music", 10, { value: 4 });
const music = audio("music", 10, { value: musicReadyState });
const voice = audio("voice", 4, voiceReady);
window.__timelines = { main: createMockTimeline(10) };
@@ -67,19 +72,47 @@ function playPodcast(voiceStart = 0, voiceTag = "audio", extraHtml = "") {
return { music, voice, voiceReady, states, playFor, step };
}
it("a track not ready pauses everything with Buffering, and resumes when it can play", () => {
it("a track not ready at the start holds the playhead for 0.3 s at most, then plays on", () => {
const { music, voice, voiceReady, states, playFor } = playPodcast();
playFor(1);
expect(window.__player!.getTime()).toBeLessThan(0.1);
playFor(0.25);
expect(window.__player!.getTime()).toBeLessThan(0.05);
expect(music.paused).toBe(true);
expect(states.at(-1)).toEqual({ isPlaying: true, buffering: true });
playFor(1.25);
expect(window.__player!.getTime()).toBeGreaterThan(0.9);
expect(states.at(-1)).toEqual({ isPlaying: true, buffering: false });
voiceReady.value = 4;
playFor(0.5);
expect(voice.paused).toBe(false);
expect(music.paused).toBe(false);
});
it("the track the playhead follows, cold at the start, holds it for 0.3 s at most too", () => {
const { states, playFor } = playPodcast(0, "audio", 1);
playFor(0.25);
expect(window.__player!.getTime()).toBeLessThan(0.05);
expect(states.at(-1)).toEqual({ isPlaying: true, buffering: true });
playFor(1.25);
expect(window.__player!.getTime()).toBeGreaterThan(0.9);
});
it("a track not ready mid-film still pauses everything with Buffering, and resumes when it can play", () => {
const { music, voiceReady, states, playFor } = playPodcast(2);
playFor(2.5);
expect(window.__player!.getTime()).toBeGreaterThan(1.9);
expect(window.__player!.getTime()).toBeLessThan(2.1);
expect(music.paused).toBe(true);
expect(states.at(-1)).toEqual({ isPlaying: true, buffering: true });
voiceReady.value = 4;
playFor(1);
expect(window.__player!.getTime()).toBeGreaterThan(0.9);
expect(music.paused).toBe(false);
expect(voice.paused).toBe(false);
expect(window.__player!.getTime()).toBeGreaterThan(2.9);
expect(states.at(-1)).toEqual({ isPlaying: true, buffering: false });
});
@@ -93,16 +126,28 @@ it("the music plays on while a video shows a still across a copy swap", () => {
expect(states.at(-1)).toEqual({ isPlaying: true, buffering: false });
});
it("a copy that never lands stops standing in, and the film waits for it", () => {
it("a copy that never lands stops standing in, and the film waits for it through the start wait", () => {
const { music, voice, states, playFor } = playPodcast(0, "video");
heldPosters.set(voice, { poster: null, since: performance.now() - 60_000 });
playFor(1);
expect(window.__player!.getTime()).toBeLessThan(0.1);
playFor(0.25);
expect(window.__player!.getTime()).toBeLessThan(0.05);
expect(music.paused).toBe(true);
expect(states.at(-1)).toEqual({ isPlaying: true, buffering: true });
});
it("after a start that ran on without data, the next clip without data holds again once the first one has it", () => {
const { voiceReady, states, playFor } = playPodcast();
playFor(1);
voiceReady.value = 4;
playFor(0.2);
expect(states.at(-1)).toEqual({ isPlaying: true, buffering: false });
voiceReady.value = 1;
playFor(0.2);
expect(states.at(-1)).toEqual({ isPlaying: true, buffering: true });
});
it("holds on the frame a slow tick skips into a clip with no data, after asking it to play", () => {
const { voice, states, playFor, step } = playPodcast(0.5);
@@ -117,16 +162,16 @@ it("resumes for a held clip with data buffered ahead that reports only its curre
vi.spyOn(navigator, "userAgent", "get").mockReturnValue(
"AppleWebKit/605.1.15 Version/17.4 Safari/605.1.15",
);
const { voiceReady, states, playFor } = playPodcast();
const { voiceReady, states, playFor } = playPodcast(0.5);
playFor(0.5);
playFor(0.6);
voiceReady.value = 2;
playFor(0.5);
expect(states.at(-1)).toEqual({ isPlaying: true, buffering: true });
voiceReady.bufferedTo = 2;
playFor(1);
expect(window.__player!.getTime()).toBeGreaterThan(0.9);
expect(window.__player!.getTime()).toBeGreaterThan(1.1);
expect(states.at(-1)).toEqual({ isPlaying: true, buffering: false });
});
@@ -191,6 +236,7 @@ it("a hold for data does not reload a later clip", () => {
const { voiceReady, playFor } = playPodcast(
0,
"video",
4,
'<video id="later" data-track-index="0" data-start="9" data-duration="1" src="/later.mp4"></video>',
);
const later = document.getElementById("later") as HTMLVideoElement;
@@ -255,7 +301,7 @@ it("a video far behind the film lands on it when it starves", () => {
it("a pause while buffering stays paused once the track is ready", () => {
const { music, voiceReady, states, playFor } = playPodcast();
playFor(0.5);
playFor(0.2);
window.__player!.pause();
voiceReady.value = 4;
playFor(1);
+2 -2
View File
@@ -6406,7 +6406,7 @@ describe("initSandboxRuntimeModular", () => {
describe("a voiceover routed through Web Audio", () => {
const ctx = useMockAudioContext();
it("holds the timeline while the voiceover buffers instead of seeking it forward", async () => {
it("holds the timeline for the start wait while the voiceover buffers instead of seeking it forward", async () => {
const raf = createManualRaf();
vi.spyOn(performance, "now").mockImplementation(() => raf.now());
window.requestAnimationFrame =
@@ -6426,7 +6426,7 @@ describe("initSandboxRuntimeModular", () => {
await startPlayback();
expect(ctx.mediaElementSources).toBe(1);
for (let frame = 0; frame < 120; frame++) {
for (let frame = 0; frame < 15; frame++) {
ctx.time += 1 / 60;
raf.step(1000 / 60);
}
+32 -3
View File
@@ -3444,6 +3444,11 @@ export function initSandboxRuntimeModular(): void {
let lastSyncedMediaTimeSeconds: number | null = null;
let mediaClipsInWindow: RuntimeMediaClip[] = [];
// Clips due now that could not play: every track holds until they can, so none runs on alone.
// Play waits this long for a clip with no data, then runs on and lets the clip catch up; mid-film it holds.
const START_WAIT_MS = 300;
let startWaitUntilMs: number | null = null;
let catchingUp = false;
let resumingFromBuffering = false;
// The one definition of "media the transport drives": timed itself, or hosted
// by a composition whose timing it inherits. Both the media cache and the
@@ -4778,6 +4783,10 @@ export function initSandboxRuntimeModular(): void {
flushHeldSeek();
const resumingStarved = bufferingFor.length > 0;
bufferingFor = [];
if (!resumingFromBuffering) {
startWaitUntilMs = performance.now() + START_WAIT_MS;
catchingUp = false;
}
const tl = state.capturedTimeline;
if (clock.isPlaying()) return;
const dur = getSafeTimelineDurationSeconds(tl, 0);
@@ -4812,6 +4821,8 @@ export function initSandboxRuntimeModular(): void {
pause: () => {
const wasBuffering = bufferingFor.length > 0;
bufferingFor = [];
startWaitUntilMs = null;
catchingUp = false;
if (clock.isPlaying()) haltPlayback();
else if (wasBuffering) postState(true);
},
@@ -5661,6 +5672,13 @@ export function initSandboxRuntimeModular(): void {
const source = followedOrLongestRunningAudio(clock.audioElement());
if (source && !audioMayDrivePlayhead(source.el)) {
if (clock.hasAudioSource()) clock.detachAudioSource();
} else if (
source &&
catchingUp &&
source.el.readyState < HTMLMediaElement.HAVE_FUTURE_DATA
) {
// Past the start wait the playhead runs on its own clock while this track catches up.
if (clock.hasAudioSource()) clock.detachAudioSource();
} else if (source && !source.el.paused) {
clock.attachAudioSource({
el: source.el,
@@ -5888,14 +5906,25 @@ export function initSandboxRuntimeModular(): void {
}
// After media sync, so a clip that just fell due was asked to play, and fetch, before it holds.
const notReady = mediaNotReadyAt(state.currentTime);
if (clock.isPlaying() && notReady.length > 0) {
if (startWaitUntilMs !== null && performance.now() >= startWaitUntilMs) {
startWaitUntilMs = null;
catchingUp = notReady.length > 0;
}
if (notReady.length === 0) catchingUp = false;
if (clock.isPlaying() && notReady.length > 0 && !catchingUp) {
bufferingFor = notReady;
haltPlayback(true);
} else if (
bufferingFor.length > 0 &&
!mediaNotReadyAt(state.currentTime, canResumeFrom).some((el) => bufferingFor.includes(el))
(catchingUp ||
!mediaNotReadyAt(state.currentTime, canResumeFrom).some((el) => bufferingFor.includes(el)))
) {
transport.play();
resumingFromBuffering = true;
try {
transport.play();
} finally {
resumingFromBuffering = false;
}
}
postState(false);
} finally {