mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-03 12:17:03 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user